Files
slim-blog/docs/GUIDE.md
2026-03-16 01:47:07 +01:00

88 KiB

Slim Blog

Guide technique pour développeurs

Mars 2026


Table des matières

  1. Présentation du projet
  2. Bases de PHP utiles
  3. Choix techniques
  4. Structure des fichiers
  5. Architecture en domaines
  6. La base de données
  7. Installation et maintenance
  8. Faire évoluer le projet
  9. Conclusion

1. Présentation du projet

1.1 Qu'est-ce que Slim Blog ?

Slim Blog est une application de blog complète écrite en PHP. Elle permet de publier des articles, de les organiser par catégories, de téléverser des images et de gérer plusieurs comptes avec des niveaux d'accès différents — le tout sans dépendance à un CMS ou à un framework lourd.

Le projet a un double objectif : fonctionner comme un vrai blog, et servir de base d'apprentissage. Chaque partie du code illustre une bonne pratique — organisation par domaines métier, séparation des responsabilités, tests unitaires — sans les surcharger de complexité inutile. On peut le lire pour apprendre, le modifier pour pratiquer, ou s'en servir comme point de départ pour une autre application.

1.2 Fonctionnalités

  • Articles avec éditeur WYSIWYG, slugs stables et recherche full-text
  • Catégories pour filtrer les articles
  • Médias : téléversement d'images converties en WebP avec déduplication
  • Comptes utilisateurs avec trois rôles : user, editor, admin
  • Réinitialisation de mot de passe par e-mail
  • Flux RSS des 20 derniers articles
  • Protection CSRF sur tous les formulaires

1.3 Ce que le projet n'est pas

Slim Blog n'est pas un CMS clé en main. Il n'y a pas de panneau d'installation graphique, pas de système de plugins, pas de marketplace de thèmes. C'est un projet qu'on installe, qu'on lit et qu'on modifie directement dans un éditeur de code.

C'est précisément son intérêt. Le code ne se cache pas derrière des couches d'abstractions : chaque fonctionnalité a un emplacement logique, chaque choix technique a une raison expliquée dans ce guide. Que vous débutiez en PHP ou que vous arriviez d'un autre langage, la structure du projet est conçue pour être lue et comprise progressivement — pas en une seule fois, mais domaine par domaine.

Ce guide accompagne cette progression. Le chapitre 2 pose les bases du langage, les chapitres suivants expliquent les choix d'architecture, et le chapitre 8 montre comment faire évoluer le projet concrètement. L'objectif n'est pas d'impressionner, c'est d'être compréhensible.

À qui s'adresse ce guide ? Le lecteur visé sait déjà programmer — en Python, JavaScript, ou dans un autre langage — et est à l'aise avec les concepts fondamentaux : variables, boucles, fonctions, objets. En revanche, il n'a jamais ou peu écrit de PHP. Le chapitre 2 ne réexplique pas ces concepts : il montre comment PHP les traite, en insistant sur les endroits où le langage fait des choix inhabituels ou impose des conventions qui peuvent surprendre.

1.4 Interface de l'application

Avant de plonger dans le code, il est utile de savoir ce que l'application fait concrètement. Slim Blog expose deux faces : une partie publique accessible à tous les visiteurs, et une interface d'administration réservée aux comptes connectés.

Partie publique

URL Ce qu'on y trouve
/ Liste des articles avec filtre par catégorie et recherche full-text
/article/{slug} Détail d'un article
/rss.xml Flux RSS des 20 derniers articles
/auth/login Formulaire de connexion
/password/forgot Demande de réinitialisation de mot de passe
/password/reset Formulaire de nouveau mot de passe (lien reçu par e-mail)

Interface d'administration

L'accès à /admin redirige automatiquement vers /admin/posts. Trois niveaux d'accès coexistent :

Zone URL Accès requis
Articles /admin/posts Tout utilisateur connecté (périmètre selon le rôle)
Médias /admin/media Tout utilisateur connecté (périmètre selon le rôle)
Catégories /admin/categories Éditeur ou admin
Utilisateurs /admin/users Admin uniquement
Mon compte /account/password Tout utilisateur connecté

Un utilisateur avec le rôle user ne voit que ses propres articles et ses propres médias. Un editor et un admin voient et gèrent l'ensemble des articles et des médias, et accèdent à la gestion des catégories. Seul l'admin peut créer ou supprimer des comptes et modifier le rôle d'un utilisateur existant.

💡 Le compte admin initial est créé automatiquement au premier démarrage avec les identifiants définis dans .env (ADMIN_USERNAME, ADMIN_EMAIL, ADMIN_PASSWORD). Après l'installation, se connecter sur /auth/login avec ces identifiants donne accès à l'interface d'administration. L'URL de base dépend du mode d'installation : http://localhost:8080 en développement sans Docker (§7.1), http://localhost:8888 pour vérifier en local après docker compose up (§7.2).

2. Bases de PHP utiles

Ce chapitre n'est pas une introduction à la programmation. Il suppose que vous savez déjà ce qu'est une boucle, une fonction ou un objet. Son objectif est plus précis : montrer comment PHP les traite, et signaler les endroits où le langage diverge de ce que vous connaissez peut-être de Python, JavaScript ou d'un autre langage.

La structure reste en deux parties — fondamentaux du langage, puis POO — mais chaque section se concentre sur ce qui est spécifique à PHP plutôt que sur les concepts généraux. Les exemples sont tirés directement du projet.

2.1 Fondamentaux du langage

2.1.1 PHP côté serveur

Contrairement à JavaScript qui s'exécute dans le navigateur, PHP s'exécute exclusivement sur le serveur. Le navigateur ne voit jamais le code PHP : il reçoit uniquement le HTML que PHP a produit.

Dans Slim Blog, le seul fichier accessible depuis le web est public/index.php. Toutes les requêtes HTTP y arrivent ; Slim lit l'URL, appelle le bon contrôleur, qui demande les données à un service, qui les lit depuis la base SQLite, et renvoie du HTML généré par Twig.

2.1.2 Variables, types et strict_types

PHP est dynamiquement typé par défaut — comme Python ou JavaScript. Mais PHP 8 permet de déclarer les types explicitement, et Slim Blog active declare(strict_types=1) dans chaque fichier. Cette directive change le comportement de PHP : sans elle, PHP convertit silencieusement un string en int si une fonction en attend un ; avec elle, il lève une erreur immédiatement. C'est le même contrat qu'un langage statiquement typé, appliqué à la volée.

// Les variables commencent par $ — syntaxe propre à PHP
$title = 'Mon article';
$id    = 42;

// Type nullable : le préfixe ? signifie "ce type ou null"
// Équivalent de Optional[int] en Python ou int | null en TypeScript
?int $authorId = null;

// Signature typée complète — paramètres et valeur de retour
// Extrait de PostService
public function createPost(string $title, string $content,
                           int $authorId, ?int $categoryId = null): int

💡 Le ? devant un type est omniprésent dans le projet : ?int $categoryId dans PostService, ?string $authorUsername dans Post. Il indique une valeur qui peut légitimement être absente — pas un oubli.

2.1.3 Ce qui diffère des autres langages

PHP partage les structures de contrôle habituelles (if, foreach, while). Trois points méritent attention car ils surprennent souvent.

=== vs == — PHP a deux opérateurs d'égalité. == compare les valeurs après conversion de type : 0 == null est true, 0 == false est true, "1" == 1 est true. === compare la valeur et le type : 0 === null est false. Slim Blog utilise toujours === pour éviter ces surprises.

?? (null coalescent) — retourne l'opérande de gauche s'il n'est pas null, sinon celui de droite. C'est l'équivalent de or en Python pour les valeurs nulles, ou de ?? en C# et TypeScript.

// Extrait de Post::fromArray()
$title = (string) ($data['title'] ?? '');

// Extrait du constructeur de Post
$this->createdAt = $createdAt ?? new DateTime();

foreach avec clé — PHP permet de récupérer la clé et la valeur en même temps, avec la syntaxe as $clé => $valeur. C'est l'équivalent de .items() en Python ou de Object.entries() en JavaScript.

// Extrait de RssController
foreach ($posts as $post) {
    $item = $channel->addChild('item');
    $item->addChild('title', htmlspecialchars($post->getTitle()));
    $item->addChild('link', $baseUrl . '/article/' . $post->getStoredSlug());
}

// Avec clé — équivalent de for k, v in dict.items(): en Python
foreach ($scores as $nom => $points) {
    echo "$nom : $points pts\n";
}

La boucle while fonctionne comme dans tous les langages. Le projet l'utilise notamment dans PostService::generateUniqueSlug() pour tester des variantes jusqu'à trouver un slug libre :

$slug = $baseSlug;
$counter = 1;
while ($this->postRepository->slugExists($slug, $excludeId)) {
    $slug = $baseSlug . '-' . $counter;
    ++$counter;
}
return $slug; // ex: "mon-article-3" si les deux premiers étaient pris

2.1.4 Signatures de fonctions et void

La syntaxe des fonctions PHP 8 est proche de TypeScript : on déclare le type de chaque paramètre et le type de retour après :. Ce qui peut surprendre est le type void — une fonction qui ne retourne rien le déclare explicitement, plutôt que de simplement omettre le return.

// $categoryId est optionnel — sa valeur par défaut est null
public function getAllPosts(?int $categoryId = null): array
{
    return $this->postRepository->findAll($categoryId);
}

// void : cette méthode ne retourne rien
// PHPStan vérifiera qu'elle ne contient pas de "return $valeur" accidentel
public function deletePost(int $id): void
{
    $affected = $this->postRepository->delete($id);
    if ($affected === 0) {
        throw new NotFoundException('Article', $id);
    }
}

Les fonctions libres (hors classe) sont rares dans le projet. La quasi-totalité du code est organisée en méthodes de classe — ce que la section 2.2 explique.

2.1.5 Tableaux associatifs

En PHP, un tableau peut être indexé (clés numériques) ou associatif (clés textuelles). C'est la même structure — l'équivalent d'un dict Python ou d'un objet littéral JavaScript. La syntaxe est ['clé' => valeur].

C'est la structure que PDO utilise pour toutes les lectures en base : chaque ligne retournée via FETCH_ASSOC est un tableau associatif, et chaque INSERT se construit avec un tableau associatif passé à execute().

// Extrait de PostRepository::create()
$stmt = $this->db->prepare('
    INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
    VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)
');
$stmt->execute([
    ':title'       => $post->getTitle(),
    ':content'     => $post->getContent(),
    ':slug'        => $slug,
    ':author_id'   => $authorId,
    ':category_id' => $categoryId,
    'created_at'  => date('Y-m-d H:i:s'),
    'updated_at'  => date('Y-m-d H:i:s'),
]);

// isset() vérifie qu'une clé existe ET n'est pas null
// Extrait de Post::fromArray()
$authorId = isset($data['author_id']) ? (int) $data['author_id'] : null;

💡 null et '' sont deux valeurs distinctes. Dans Post, $authorUsername à null signifie que l'auteur a supprimé son compte ; une chaîne vide signifierait un auteur avec un nom vide. Cette distinction évite des comportements imprévus à l'affichage.

2.1.6 Namespaces et autoloading

Les namespaces organisent les classes pour éviter les conflits de noms — l'équivalent des modules en Python ou des imports ES en JavaScript. Composer gère l'autoloading : il charge automatiquement le fichier correspondant à un namespace, sans require manuel.

// Début de chaque fichier du projet
namespace App\Post; // correspond au dossier src/Post/

// On importe une classe d'un autre namespace avec 'use'
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;

// Après le 'use', on utilise le nom court
throw new NotFoundException('Article', $slug);

// Sans "use", il faudrait écrire le chemin complet à chaque fois :
// throw new \App\Shared\Exception\NotFoundException(...)

💡 La correspondance namespace ↔ dossier est déclarée dans composer.json (App\\ => src/). Composer génère une carte de tous les fichiers au moment de l'installation ; PHP charge le bon fichier automatiquement à la première utilisation d'une classe.

2.1.7 Exceptions et hiérarchie

PHP utilise les exceptions comme tous les langages modernes. Ce qui est spécifique au projet, c'est la hiérarchie choisie : Slim Blog distingue deux familles d'exceptions selon leur nature.

NotFoundException hérite de \RuntimeException — c'est une erreur d'infrastructure (une ressource est absente). Slim l'intercepte et renvoie une réponse HTTP 404.

Les exceptions du domaine Auth (DuplicateUsernameException, DuplicateEmailException, WeakPasswordException) héritent de \InvalidArgumentException — ce sont des erreurs métier (une règle de gestion n'est pas respectée). Elles sont attrapées dans les contrôleurs et converties en messages flash.

// src/Shared/Exception/NotFoundException.php
final class NotFoundException extends \RuntimeException
{
    public function __construct(string $entity, int|string $identifier)
    {
        parent::__construct("{$entity} introuvable : {$identifier}");
    }
}

// Utilisation dans PostService
$post = $this->postRepository->findById($id);
if ($post === null) {
    throw new NotFoundException('Article', $id); // → HTTP 404
}

Cette distinction a une conséquence concrète dans les contrôleurs : un seul catch (\InvalidArgumentException $e) suffit pour attraper toutes les exceptions métier du domaine Auth, car elles partagent le même ancêtre. Le chapitre 5.9 détaille ce mécanisme.

2.2 Programmation orientée objet

PHP supporte pleinement la POO. Ce qui peut surprendre venant d'autres langages, ce sont les modificateurs propres à PHP — notamment final, readonly, et la distinction self:: / $this->.

2.2.1 Classes et modificateurs PHP

Dans Slim Blog, chaque domaine a un modèle immuable : Post, User, Category, Media. L'immutabilité est garantie par le mot-clé readonly sur les propriétés du constructeur — une fois l'objet créé, aucune propriété ne peut être réassignée.

// Extrait simplifié de src/User/User.php
final class User
{
    public const ROLE_USER   = 'user';
    public const ROLE_EDITOR = 'editor';
    public const ROLE_ADMIN  = 'admin';

    // "readonly" : la propriété ne peut être écrite qu'une fois, dans le constructeur
    // "private" : elle n'est accessible que depuis l'intérieur de la classe
    public function __construct(
        private readonly int $id,
        private readonly string $username,
        private readonly string $email,
        private readonly string $passwordHash,
        private readonly string $role = self::ROLE_USER,
        ?DateTime $createdAt = null,
    ) {}

    public function getUsername(): string { return $this->username; }

    public function isAdmin(): bool { return $this->role === self::ROLE_ADMIN; }
}

Quatre modificateurs à retenir :

  • final — la classe ne peut pas être étendue par héritage. Tous les modèles et services de Slim Blog sont final : cela ferme la porte à des comportements imprévus introduits par une sous-classe.
  • readonly — la propriété est en lecture seule après le constructeur. C'est l'équivalent d'un attribut frozen en Python ou d'une propriété const en C++.
  • self:: — référence la classe elle-même (pour les constantes et méthodes statiques). $this-> référence l'instance courante.
  • private / public — contrôle d'accès habituel. protected existe aussi mais est absent du projet (les classes final n'ont pas de sous-classes à qui exposer des membres protégés).

💡 La méthode statique fromArray() présente dans Post et User est un named constructor : elle construit un objet depuis un tableau associatif (une ligne de base de données). On l'appelle sur la classe, pas sur une instance : Post::fromArray($row).

2.2.2 Interfaces

Une interface définit un contrat : elle liste des méthodes sans les implémenter. Toute classe déclarée avec implements doit fournir chacune de ces méthodes — c'est vérifié à la compilation par PHP et par PHPStan.

// Extrait de src/Post/PostRepositoryInterface.php
interface PostRepositoryInterface
{
    public function findBySlug(string $slug): ?Post;
    public function findById(int $id): ?Post;
    public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int;
    public function delete(int $id): int;
    // ... autres méthodes
}

// PostRepository implémente ce contrat avec PDO + SQLite
final class PostRepository implements PostRepositoryInterface
{
    public function findBySlug(string $slug): ?Post
    {
        $stmt = $this->db->prepare(
            'SELECT posts.*, users.username AS author_username
             FROM posts
             LEFT JOIN users ON users.id = posts.author_id
             WHERE posts.slug = :slug'
        );
        $stmt->execute([':slug' => $slug]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        return $row ? Post::fromArray($row) : null;
    }
    // ...
}

Dans Slim Blog, chaque repository a son interface. Cela apporte deux bénéfices concrets :

  • Tests unitaires — dans les tests, on remplace le repository réel par un mock qui ne touche pas la base de données. Le service est testé en isolation complète.
  • Interchangeabilité — passer de SQLite à PostgreSQL ne nécessite qu'une nouvelle implémentation de l'interface. PostService ne change pas d'une ligne.

PostService déclare PostRepositoryInterface $postRepository dans son constructeur, jamais PostRepository directement. Dépendre des abstractions plutôt que des implémentations rend le code testable et évolutif.

2.2.3 Injection de dépendances

Plutôt que de créer ses dépendances soi-même, une classe les reçoit via son constructeur. C'est l'injection de dépendances (Dependency Injection).

// ❌ Couplage fort — PostService crée lui-même ses dépendances
// Impossible à tester : on ne peut pas substituer un faux repository.
class PostService {
    public function __construct() {
        $this->repo = new PostRepository();
    }
}

// ✅ Injection — les dépendances sont fournies de l'extérieur
// Extrait réel de src/Post/PostService.php
final class PostService
{
    public function __construct(
        private readonly PostRepositoryInterface $postRepository,
        private readonly HtmlSanitizerInterface $htmlSanitizer,
    ) {}
}

Qui assemble les dépendances ? PHP-DI dans config/container.php. Il résout automatiquement les dépendances par autowiring — en lisant les types déclarés dans les constructeurs — et ne nécessite une configuration explicite que pour les bindings interface → classe concrète et les dépendances scalaires (chemins, paramètres .env).

// Extrait de config/container.php
// Binding interface → implémentation : PHP-DI injecte PostRepository partout où
// PostRepositoryInterface est demandé. PostService lui-même est résolu par autowiring.
PostRepositoryInterface::class => autowire(PostRepository::class),
PostServiceInterface::class    => autowire(PostService::class),

💡 Pour ajouter un nouveau service : créer la classe avec ses dépendances typées en constructeur, puis déclarer le binding interface → classe dans config/container.php. Si toutes les dépendances sont elles-mêmes typées sur des interfaces déjà liées, PHP-DI résout tout automatiquement — aucune factory à écrire.


3. Choix techniques

3.1 La stack

Chaque outil a été choisi pour une raison précise.

Rôle Outil Pourquoi ce choix
Framework HTTP Slim 4 Micro-framework minimaliste : ne fait que le routage et la gestion des requêtes/réponses. Pas de magie cachée.
Base de données SQLite + PDO natif SQLite est un fichier, zéro configuration serveur. PDO natif donne un accès SQL direct, typé et sans dépendance supplémentaire.
Templates Twig Séparation nette entre logique PHP et présentation HTML. Héritage de gabarits, syntaxe lisible.
CSS Sass (7-1/BEM) Sources organisées en couches (base, composants, pages). BEM évite les conflits de nommage.
Éditeur WYSIWYG Trumbowyg Éditeur HTML léger, intégré facilement, sortie HTML sanitisée côté serveur.
E-mails PHPMailer Bibliothèque mature pour l'envoi SMTP avec support TLS/SSL.
Logging Monolog Standard de facto en PHP, écriture dans des fichiers rotatifs.
Sanitisation HTML HTMLPurifier Nettoie le HTML saisi par les utilisateurs pour éliminer les XSS.
Protection CSRF Slim CSRF Génère et valide des tokens sur tous les formulaires POST.
Injection de dépendances PHP-DI Résolution automatique des dépendances par autowiring.
Analyse statique PHPStan niveau 8 Détecte les erreurs de typage avant l'exécution. Niveau 8 sur 9, le plus utilisé en production.

3.2 Pourquoi PDO natif plutôt qu'Eloquent ou Doctrine ?

Les deux repères les plus courants pour l'accès à la base de données en PHP au-dessus de PDO : un Active Record comme Eloquent (Laravel), ou un ORM complet comme Doctrine. Le projet utilise PDO directement, sans surcouche.

PDO vs Eloquent (Active Record)

Eloquent est l'ORM de Laravel. Son modèle Active Record est séduisant — les relations s'écrivent en quelques lignes — mais il introduit un couplage fort : chaque modèle étend une classe de base, et les propriétés sont dynamiques (elles n'existent que lors de l'exécution).

// Eloquent : le modèle hérite de Model et déclare ses relations
class Post extends Model {
    protected $table = 'posts';
    protected $guarded = [];
    public function author() {
        return $this->belongsTo(User::class, 'author_id');
    }
}

// Accès via propriété magique — pratique, mais invisible pour PHPStan
$posts = Post::with('author')->latest()->limit(10)->get();
echo $posts[0]->author->username; // $author n'a aucun type déclaré

Deux problèmes concrets pour ce projet. D'abord, les propriétés dynamiques rendent l'analyse statique (PHPStan niveau 8) inefficace : $post->author->username est invisible au typage. Ensuite, étendre Model couple chaque entité à Laravel — la classe Post ne peut plus être testée sans bootstrapper le framework.

Dans Slim Blog, les modèles (Post, User…) sont des classes final readonly sans héritage. Tous les champs sont déclarés et typés — PHPStan les vérifie entièrement.

PDO vs Doctrine (ORM complet)

Doctrine est l'ORM le plus complet de l'écosystème PHP. Récupérer les 10 articles les plus récents avec leur auteur nécessite de définir des entités annotées, un EntityManager et des relations ManyToOne — puis d'écrire une requête DQL ou un QueryBuilder. Avec PDO, la requête SQL est écrite directement et reste lisible et débogable :

$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.id DESC LIMIT :limit');
$stmt->bindValue(':limit', 10, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);

Le SQL est prévisible et exécutable directement dans un client SQLite. Doctrine apporte une richesse justifiée sur de grands projets d'équipe (lazy loading, unit of work, cache de second niveau) — mais cette richesse a un coût en configuration et en courbe d'apprentissage disproportionné pour un projet de cette taille.

💡 PDO natif est pertinent quand le SQL est maîtrisé, que le projet n'a pas besoin de lazy loading ni de cache de second niveau, et que la lisibilité du code prime sur la magie. Dès qu'un projet grossit en équipe ou en complexité de domaine, migrer vers Doctrine redevient raisonnable — le pattern Repository de Slim Blog le rend possible sans toucher au code métier.

3.3 Pourquoi SQLite ?

En développement comme en production (petit trafic), SQLite suffit largement. Pas besoin d'installer et configurer MySQL ou PostgreSQL. La base entière est un seul fichier, facilement sauvegardé et transporté.


4. Structure des fichiers

4.1 Vue d'ensemble

slim-blog/
├── .dockerignore             ← Fichiers exclus des images Docker
├── .env                      ← Variables d'environnement (non versionné)
├── .env.example              ← Modèle de configuration
├── .gitignore                ← Fichiers exclus du dépôt Git
├── assets/                   ← Sources SCSS
├── composer.json             ← Dépendances PHP
├── composer.lock             ← Versions exactes verrouillées (PHP)
├── CONTRIBUTING.md           ← Guide de contribution
├── database/                 ← Migrations SQL
├── docker-compose.yml        ← Orchestration des conteneurs
├── docker/                   ← Configuration Nginx et PHP
├── docs/                     ← Documentation
├── package-lock.json         ← Versions exactes verrouillées (JS)
├── package.json              ← Dépendances JS (Sass)
├── phpstan.neon              ← Configuration de l'analyse statique
├── phpunit.xml               ← Configuration des tests
├── public/                   ← Point d'entrée web (index.php)
├── README.md                 ← Documentation principale
├── src/                      ← Code PHP (domaines)
├── tests/                    ← Tests unitaires PHPUnit
└── views/                    ← Templates Twig

4.2 Le dossier assets/

Contient les sources SCSS (Sass). Ce dossier n'est jamais servi directement par le serveur web ; il est compilé en CSS par la commande npm run build, et le résultat est écrit dans public/assets/css/.

assets/                       ← sources SCSS (non servi directement)
└── scss/                     ← point d'entrée et partiels
    ├── abstracts/            ← variables et mixins (aucun CSS généré)
    │   ├── _mixins.scss      ← mixin responsive (@include mobile)
    │   └── _variables.scss   ← couleurs, espacements, breakpoints
    ├── base/                 ← reset et typographie globale
    ├── components/           ← éléments réutilisables (boutons, cartes…)
    ├── layout/               ← zones structurelles (header, footer)
    ├── main.scss             ← point d'entrée unique, importe tout
    └── pages/                ← surcharges spécifiques à chaque vue

💡 Les fichiers SCSS sont compilés en un seul fichier public/assets/css/main.css. Le navigateur ne charge que ce fichier CSS final. Pour modifier le style, on édite les fichiers dans assets/scss/, puis on relance npm run build (ou npm run watch pour recompiler automatiquement à chaque sauvegarde).

4.3 Le dossier data/ (production)

Créé automatiquement par Docker au premier démarrage, il contient tout ce qui persiste entre les redéploiements.

data/
├── database/          ← Fichier SQLite (app.sqlite)
├── public/media/      ← Images téléversées
└── var/               ← Cache Twig/HTMLPurifier, logs

4.4 Le dossier database/

Contient les migrations SQL qui construisent le schéma de la base de données. Chaque migration est un fichier PHP numéroté qui retourne un tableau avec deux clés : up (appliquer) et down (annuler).

database/                ← migrations SQL, une par table
└── migrations/          ← exécutées dans l'ordre alphanumérique
    ├── 001_create_users.php
    ├── 002_create_categories.php
    ├── 003_create_posts.php
    ├── 004_create_media.php
    ├── 005_create_password_resets.php
    ├── 006_create_posts_fts.php
    └── 007_create_login_attempts.php

Chaque fichier retourne un tableau associatif avec les requêtes SQL. Voici la migration qui crée la table users :

// database/migrations/001_create_users.php
return [
    'up' => "
        CREATE TABLE IF NOT EXISTS users (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            username      TEXT UNIQUE NOT NULL,
            email         TEXT UNIQUE NOT NULL,
            password_hash TEXT NOT NULL,
            role          TEXT NOT NULL DEFAULT 'user',
            created_at    DATETIME DEFAULT CURRENT_TIMESTAMP
        )
    ",
    'down' => 'DROP TABLE IF EXISTS users',
];

💡 Le Migrator dans src/Shared/Database/Migrator.php parcourt ce dossier au démarrage, compare les versions déjà appliquées (stockées dans une table migrations) et exécute uniquement les nouvelles. Pour ajouter une table, il suffit de créer un fichier 008_*.php avec la même structure.

4.5 Le dossier public/

public/index.php est le point d'entrée unique de l'application (ou front controller). Toutes les requêtes HTTP y arrivent, et c'est lui qui démarre l'application via Bootstrap.

public/media/index.php est le seul autre fichier PHP dans ce dossier. Il retourne systématiquement 403 et constitue une deuxième ligne de défense contre l'exécution de code PHP dans le répertoire des uploads (la première étant la règle Nginx location ~* /media/.*\.php$).

💡 Le serveur web (Nginx) est configuré pour rediriger toutes les URL vers index.php. Le routeur Slim analyse ensuite l'URL pour appeler le bon contrôleur.

4.6 Le dossier src/

C'est le cœur de l'application. Il est organisé par domaines métier, chacun dans son propre dossier.

src/
├── Auth/              ← Authentification, sessions, réinitialisation de mot de passe
│   └── Middleware/    ← Middlewares de contrôle d'accès (Auth, Editor, Admin)
├── Category/          ← Catégories d'articles
├── Media/             ← Téléversement et gestion des images
├── Post/              ← Articles du blog
├── User/              ← Modèle utilisateur, persistance, gestion des comptes
└── Shared/            ← Infrastructure commune
    ├── Bootstrap.php  ← Démarrage de l'application
    ├── Config.php     ← Chemins (SQLite, cache Twig)
    ├── Database/      ← Migrations
    ├── Extension/     ← Extensions Twig
    ├── Exception/     ← Exceptions métier (NotFoundException…)
    ├── Html/          ← Sanitisation HTML
    │   ├── HtmlPurifierFactory.php
    │   ├── HtmlSanitizer.php
    │   └── HtmlSanitizerInterface.php
    ├── Http/          ← Session et messages flash
    ├── Mail/          ← Envoi d'e-mails
    ├── Routes.php     ← Déclaration des routes
    └── Util/          ← Utilitaires partagés (SlugHelper, DateParser)

4.7 Le dossier tests/

Contient les tests unitaires PHPUnit. La structure reproduit celle de src/ : chaque classe testée a son fichier de test correspondant.

tests/
├── ControllerTestCase.php                ← Classe de base abstraite (helpers PSR-7, assertions HTTP)
│                                            Partagée par les 8 suites de contrôleurs
├── Auth/                                 ← tests du domaine Auth
│   ├── AccountControllerTest.php         ← changement de mot de passe (8 tests)
│   ├── AuthControllerTest.php            ← connexion / déconnexion (8 tests)
│   ├── AuthServiceRateLimitTest.php
│   ├── AuthServiceTest.php
│   ├── LoginAttemptRepositoryTest.php
│   ├── PasswordResetControllerTest.php   ← flux reset mot de passe (18 tests)
│   ├── PasswordResetRepositoryTest.php
│   └── PasswordResetServiceTest.php
├── Category/                             ← tests du domaine Category
│   ├── CategoryControllerTest.php        ← CRUD catégories (8 tests)
│   ├── CategoryRepositoryTest.php
│   └── CategoryServiceTest.php
├── Media/                                ← tests du domaine Media
│   ├── MediaControllerTest.php           ← upload, suppression, droits (13 tests)
│   ├── MediaRepositoryTest.php
│   └── MediaServiceTest.php
├── Post/                                 ← tests du domaine Post
│   ├── PostControllerTest.php            ← CRUD articles, droits, 404 (22 tests)
│   ├── PostRepositoryTest.php
│   ├── PostServiceTest.php
│   └── RssControllerTest.php             ← flux RSS (9 tests)
├── Shared/                               ← tests de l'infrastructure commune
│   ├── DateParserTest.php
│   ├── HtmlSanitizerTest.php
│   ├── SessionManagerTest.php
│   └── SlugHelperTest.php
└── User/                                 ← tests du domaine User
    ├── UserControllerTest.php            ← gestion utilisateurs, rôles (18 tests)
    ├── UserRepositoryTest.php
    ├── UserServiceTest.php
    └── UserTest.php

Chaque fichier de test contient une classe qui hérite de PHPUnit\Framework\TestCase. Les dépendances extérieures (base de données, session) sont remplacées par des mocks : des objets factices qui simulent le comportement réel sans effets de bord.

// Extrait de tests/User/UserTest.php
// Chaque méthode de test commence par "test" et vérifie un comportement précis.
final class UserTest extends TestCase
{
    public function testValidConstruction(): void
    {
        $user = new User(1, 'alice', 'alice@example.com',
            password_hash('secret123', PASSWORD_BCRYPT));

        // assertSame vérifie valeur + type (équivalent de ===)
        $this->assertSame('alice', $user->getUsername());
        $this->assertSame(User::ROLE_USER, $user->getRole());
    }
}

// Lancer tous les tests :
vendor/bin/phpunit

L'exemple ci-dessus teste le modèle User sans dépendances extérieures. Pour tester un service qui dépend d'un repository, on remplace le repository réel par un mock : un objet simulé qui répond à la place de la vraie base de données.

// Extrait de tests/User/UserServiceTest.php
// setUp() est appelé avant chaque test — les mocks sont recréés proprement.
protected function setUp(): void
{
    $this->userRepository = $this->createMock(UserRepositoryInterface::class);

    // Le service reçoit le faux objet — il ne sait pas que c'est un mock.
    $this->service = new UserService($this->userRepository);
}

public function testCreateUserWithValidData(): void
{
    // On programme le mock : findByUsername() retournera null (pas de doublon).
    $this->userRepository->method('findByUsername')->willReturn(null);
    $this->userRepository->method('findByEmail')->willReturn(null);

    $user = $this->service->createUser('Alice', 'alice@example.com', 'motdepasse1');
    $this->assertSame('alice', $user->getUsername()); // normalisé en minuscules
}

💡 Lancer les tests avant toute modification importante est une bonne habitude. Une suite verte confirme que le comportement existant est préservé. Pour lancer uniquement les tests d'un domaine : vendor/bin/phpunit tests/User/

4.8 Le dossier views/

Contient les templates Twig organisés par contexte.

views/
├── admin/                 ← Interface d'administration
├── emails/                ← Templates d'e-mails
├── layout.twig            ← Structure HTML commune
├── pages/                 ← Pages publiques
|   ├── account/           ← Changement de mot de passe
|   ├── auth/              ← Connexion, mot de passe oublié/réinitialisation
|   └── post/              ← Détail d'un article
└── partials/              ← Fragments réutilisables (header, footer)

5. Architecture en domaines

Les concepts de la section 2 ne sont pas théoriques : ils s'assemblent tous dans chaque domaine du projet. Les interfaces (§2.2.2) permettent les tests unitaires. Les classes readonly (§2.2.1) garantissent l'immuabilité des modèles. L'injection de dépendances (§2.2.3) permet au container d'assembler les pièces. Les exceptions métier (§2.1.7) remplacent les retours null silencieux. La suite de ce chapitre montre comment ces briques s'articulent dans une architecture complète.

5.1 Principe

Le code est découpé par domaine métier, pas par type technique. On ne regroupe pas tous les contrôleurs dans un dossier controllers/, tous les modèles dans models/, etc. Chaque domaine est un dossier autonome qui contient tout ce qui le concerne.

Avantage : pour comprendre ou modifier la gestion des catégories, on ouvre Category/ et on trouve tout. Pas besoin de naviguer entre plusieurs dossiers éparpillés.

5.2 Les domaines

Domaine Réutilisable ? Contenu
Auth/ Oui Sessions, authentification, réinitialisation de mot de passe par e-mail, protection anti-brute-force
Category/ Oui CRD de catégories (pas d'édition). Seul le nom de la table est à adapter pour un autre usage.
Media/ Oui Téléversement, déduplication SHA-256, conversion WebP
User/ Oui Modèle utilisateur, persistance, création, modification de rôle et suppression de comptes
Shared/ Oui Infrastructure complète : routes, session, e-mails, logs, utilitaires
Post/ Non Spécifique au blog. À remplacer par Product/ pour une boutique

5.3 Anatomie d'un domaine

Le domaine Post/ illustre l'anatomie complète.

Fichier Rôle
Post.php Modèle immuable. Représente un article avec ses données.
PostRepositoryInterface.php Contrat : liste les méthodes de persistance sans les implémenter.
PostRepository.php Implémentation PDO : requêtes SQL réelles.
PostService.php Logique métier : création d'un slug, validation, appel du repository.
PostController.php Actions HTTP : reçoit une requête, appelle le service, renvoie une réponse.
PostExtension.php Extension Twig du domaine Post. Expose post_excerpt, post_url, post_thumbnail, post_initials dans les templates.
RssController.php Contrôleur dédié au flux RSS 2.0 (/rss.xml), distinct du PostController.

Ce qui ne se voit pas dans ce tableau, c'est la direction des dépendances : PostController connaît PostService, mais PostService ne connaît que PostRepositoryInterface — jamais PostRepository directement. PostRepository, lui, ne connaît rien d'autre que PDO.

PostController
    ↓ dépend de
PostService
    ↓ dépend de
PostRepositoryInterface   ← implémente — PostRepository
                                               ↓ dépend de
                                             PDO (SQLite)

💡 Règle : chaque couche ne connaît que la couche immédiatement en dessous, via son interface. PostController dépend de PostServiceInterface ; PostService dépend de PostRepositoryInterface : c'est à ce niveau que l'isolation est garantie, ce qui rend les tests unitaires possibles sans base de données.

5.4 Le flux d'une requête

Voici ce qui se passe quand un utilisateur soumet le formulaire de création d'article (/admin/posts/create) :

Navigateur
│ POST /admin/posts/create
▼
Nginx (prod) / php -S (dev)
│ transmet la requête à PHP-FPM (ou traite directement)
▼
public/index.php → Bootstrap
│ initialise l'app, le container, les middlewares
▼
Routeur Slim
│ résout l'URL → PostController::create()
│ applique AuthMiddleware (vérifie la session)
▼
PostController::create()
│ extrait et valide les données POST
▼
PostService::createPost()
│ sanitise le HTML (HTMLPurifier)
│ génère un slug unique
▼
PostRepository::create()
│ INSERT en base SQLite via PDO
▼
PostController → HTTP 302 → /admin/posts

5.5 Le chemin des données (lecture)

Le flux ci-dessus décrit l'écriture. Voici le chemin inverse lors d'une lecture : comment une ligne SQLite devient un objet Post affiché dans Twig.

1. PDO lit la base et retourne un tableau brut

// PDOStatement::fetch(PDO::FETCH_ASSOC) — résultat brut depuis SQLite
['id' => 12, 'title' => 'Mon article', 'author_username' => 'alice', ...]

2. Le repository construit un objet typé via le named constructor

// Post::fromArray($row) — propriétés readonly, l'objet ne peut plus être modifié
Post { id: 12, title: "Mon article", authorUsername: "alice", ... }

3. Le contrôleur passe les objets à Twig

$this->view->render($res, 'pages/home.twig', ['posts' => $posts, ...]);

4. Twig accède aux propriétés via les getters

{{ post.title }}  {# appelle $post->getTitle() #}
{{ post.authorUsername ?? 'inconnu' }}

💡 Les concepts de la section 2 s'emboîtent ici : le tableau associatif (§2.1.5) est transformé par un named constructor (§2.2.1) en objet readonly (§2.2.1), retourné via l'interface (§2.2.2) injectée dans le service (§2.2.3).

5.6 Pourquoi les interfaces ?

Les services dépendent des interfaces, jamais des classes concrètes. Cela apporte deux bénéfices concrets.

  • Tests unitaires : dans les tests, on remplace le repository réel par un mock qui ne touche pas la base de données. Le service est testé en isolation.
  • Interchangeabilité : si demain on veut utiliser PostgreSQL au lieu de SQLite, on crée une nouvelle implémentation de l'interface. Le reste du code ne change pas.

5.7 Le Container

config/container.php est le fichier de configuration de PHP-DI, le conteneur d'injection de dépendances du projet. Il déclare deux types d'entrées.

Bindings interface → implémentation — indiquent à PHP-DI quelle classe concrète utiliser quand une interface est demandée :

// config/container.php
PostRepositoryInterface::class => autowire(PostRepository::class),
PostServiceInterface::class    => autowire(PostService::class),

Factories scalaires — pour les dépendances qui ont besoin de paramètres issus de .env ou du système de fichiers (PDO, Twig, Monolog) :

PDO::class => factory(function (): PDO {
    return new PDO('sqlite:' . Config::getDatabasePath(), options: [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
}),

Tout le reste est résolu par autowiring : PHP-DI lit les types des paramètres de constructeur et les injecte automatiquement, sans configuration supplémentaire. On n'instancie jamais les objets directement dans les contrôleurs — tout passe par le container.

5.8 Le domaine Auth

Le domaine Auth est le plus complet du projet. Il regroupe la connexion, la réinitialisation de mot de passe par e-mail, la gestion des sessions et la protection anti-brute-force. C'est un bon point de lecture après Post/.

Il contient treize fichiers PHP dans src/Auth/ :

-- Services --
AuthService.php                   — connexion, sessions, vérification des rôles
AuthServiceInterface.php          — contrat du service d'authentification
PasswordResetService.php          — génération du token, envoi e-mail, validation
PasswordResetRepositoryInterface.php
PasswordResetRepository.php       — persistance des tokens de réinitialisation
LoginAttemptRepositoryInterface.php
LoginAttemptRepository.php        — persistance des tentatives par IP

-- Contrôleurs --
AuthController.php                — formulaire de connexion / déconnexion
AccountController.php             — changement de mot de passe (utilisateur)
PasswordResetController.php       — formulaire "mot de passe oublié"

-- Middlewares (Middleware/) --
AuthMiddleware.php                — redirige vers /auth/login si non connecté
AdminMiddleware.php               — redirige si rôle != admin
EditorMiddleware.php              — redirige si rôle != editor ni admin

💡 Le modèle utilisateur (User.php), sa persistance (UserRepository) et la gestion des comptes (UserController) sont dans le domaine src/User/, séparé d'Auth. Auth consomme User/ en lecture via UserRepositoryInterface — dépendance unidirectionnelle. L'autre dépendance inter-domaines du projet est Post/ → Category/ : PostController injecte CategoryServiceInterface pour alimenter la liste des catégories dans le formulaire d'édition. PostService et PostRepository n'ont aucune connaissance de Category/.

Connexion et rate limiting

AuthController::login() orchestre trois responsabilités dans l'ordre : vérifier le rate limit, authentifier, ouvrir la session.

// 0. Résolution de l'IP réelle derrière un reverse proxy (Caddy/Nginx)
// En production Docker, REMOTE_ADDR retourne l'IP interne du proxy.
// X-Forwarded-For contient l'IP d'origine du client — on lit le premier
// segment, qui est injecté par Nginx/Caddy et ne peut pas être forgé.
$forwarded = trim((string) ($serverParams['HTTP_X_FORWARDED_FOR'] ?? ''));
$ip        = $forwarded !== '' ? trim(explode(',', $forwarded)[0])
                                : ($serverParams['REMOTE_ADDR'] ?? '0.0.0.0');

// 1. Vérification du rate limit (avant toute authentification)
$remainingMinutes = $this->authService->checkRateLimit($ip);
if ($remainingMinutes > 0) {
    // Flash + redirect — l'IP est verrouillée pour $remainingMinutes min
}

// 2. Authentification
$user = $this->authService->authenticate($username, $password);
if ($user === null) {
    $this->authService->recordFailure($ip); // incrémente le compteur
    // Flash + redirect
}

// 3. Succès : réinitialiser le compteur, ouvrir la session
$this->authService->resetRateLimit($ip);
$this->authService->login($user); // écrit userId/username/role en session

💡 AuthService::authenticate() ne gère pas le rate limiting — c'est AuthController qui en est responsable. Cette séparation permet de tester chaque comportement indépendamment.

⚠️ L'IP lue depuis REMOTE_ADDR derrière un proxy retourne l'IP interne du proxy — le rate-limit se verrouillerait alors pour tous les utilisateurs simultanément. La logique ci-dessus lit HTTP_X_FORWARDED_FOR en priorité, ce qui est sûr dans un contexte Docker où seul Nginx/Caddy contrôle cet en-tête.

Réinitialisation de mot de passe

PasswordResetController::forgot() soumet le endpoint POST /password/forgot au même rate limiting par IP que la connexion (5 tentatives, verrouillage 15 min) via AuthServiceInterface::checkRateLimit() et recordFailure().

Chaque tentative est enregistrée systématiquement, qu'un email existe ou non. Réinitialiser le compteur uniquement en cas de succès constituerait un canal caché permettant à un attaquant d'observer le compteur et d'en déduire si une adresse est enregistrée — ce qui annulerait la protection anti-énumération du retour silencieux. resetRateLimit() n'est donc jamais appelé sur ce endpoint.

Synchronisation de l'index FTS

Migrator::run() appelle syncFtsIndex() à chaque démarrage. Cette méthode insère dans posts_fts les articles dont le rowid est absent de l'index — sans toucher aux entrées existantes (idempotent).

Ce mécanisme est nécessaire car les triggers FTS5 ne couvrent que les opérations effectuées après leur création. Les articles présents en base au moment de la migration 006 ne sont pas indexés rétroactivement par les triggers. Sans cette synchronisation, la recherche retourne zéro résultat sur une base existante.

strip_tags() est disponible dans ce contexte car sqliteCreateFunction() est appelé dans container.php avant Migrator::run() dans la séquence de démarrage.

PasswordResetService gère le cycle complet en trois étapes :

  • requestReset($email) — cherche l'utilisateur, génère un token aléatoire, stocke son hash SHA-256 en base (jamais le token brut), envoie le lien par e-mail. Si l'e-mail est inconnu, retour silencieux pour ne pas révéler l'existence du compte.
  • validateToken($tokenRaw) — calcule le hash du token reçu, vérifie qu'il existe en base et n'est pas expiré (1 heure). Retourne l'utilisateur associé ou null.
  • resetPassword($tokenRaw, $newPassword) — valide le token, hache le nouveau mot de passe, met à jour la base, marque le token comme consommé (used_at).

Exceptions métier

Le domaine Auth définit trois exceptions dans src/User/Exception/ qui étendent toutes \InvalidArgumentException :

DuplicateUsernameException  — nom d'utilisateur déjà pris
DuplicateEmailException     — adresse e-mail déjà utilisée
WeakPasswordException       — mot de passe inférieur à 8 caractères

Elles sont levées par UserService (DuplicateUsernameException, DuplicateEmailException, WeakPasswordException) et par AuthService et PasswordResetService (WeakPasswordException), puis attrapées dans les contrôleurs via un seul bloc catch(\InvalidArgumentException) : le message de l'exception est transmis directement au flash et affiché dans le formulaire.

5.9 Gestion des erreurs et messages flash

Le gestionnaire d'erreurs

Bootstrap::configureErrorHandling() configure deux comportements selon l'environnement :

  • Développement (APP_ENV=development) : Slim affiche sa page de débogage avec la trace complète de l'exception. Toutes les erreurs sont visibles.
  • Production (APP_ENV=production, valeur par défaut) : un gestionnaire personnalisé intercepte toutes les exceptions et rend la page views/pages/error.twig avec un message générique.

Les codes HTTP gérés en production :

403  — Vous n'avez pas accès à cette page.
404  — La page que vous cherchez est introuvable.
500  — Une erreur interne est survenue. Veuillez réessayer plus tard.

Toutes les exceptions sont loguées via Monolog, quel que soit l'environnement. Les logs sont dans var/logs/.

NotFoundException et erreurs 404

Quand un service ne trouve pas une ressource, il lève une NotFoundException. Slim la convertit en réponse HTTP 404 via son gestionnaire d'erreurs. En production, error.twig est rendue avec le message correspondant.

// PostService::getPostBySlug()
$post = $this->postRepository->findBySlug($slug);
if ($post === null) {
    throw new NotFoundException('Article', $slug);
    // → Slim intercepte → HTTP 404 → error.twig en production
}

Messages flash

Les erreurs métier (formulaire invalide, doublon, upload refusé) ne passent pas par le gestionnaire d'erreurs : elles sont attrapées dans le contrôleur, converties en messages flash, puis affichées après redirection.

// Pattern utilisé dans tous les contrôleurs
try {
    $this->userService->createUser($username, $email, $password, $role);
    $this->flash->set('user_success', "L'utilisateur a été créé");
    return $res->withHeader('Location', '/admin/users')->withStatus(302);
} catch (\InvalidArgumentException $e) {
    // DuplicateUsernameException, WeakPasswordException, etc.
    // Toutes étendent \InvalidArgumentException — un seul catch suffit.
    $this->flash->set('user_error', $e->getMessage());
    return $res->withHeader('Location', '/admin/users/create')->withStatus(302);
}

// Le flash est lu une seule fois dans le contrôleur suivant (après redirect)
'error' => $this->flash->get('user_error'), // null si absent

💡 La distinction est importante : NotFoundException est une erreur d'infrastructure (ressource absente) gérée par Slim. Les exceptions DuplicateUsername, WeakPassword, etc. sont des erreurs métier gérées par le contrôleur. Les deux chemins aboutissent à des réponses différentes : page d'erreur pour les premières, formulaire avec message pour les secondes.


6. La base de données

6.1 Schéma

La base SQLite contient sept tables. Voici leur structure simplifiée.

categories

id    INTEGER  — clé primaire
name  TEXT     — unique
slug  TEXT     — unique, utilisé dans les URLs

users

id            INTEGER  — clé primaire
username      TEXT     — unique
email         TEXT     — unique
password_hash TEXT
role          TEXT     — 'user', 'editor' ou 'admin'
created_at    DATETIME

posts

id          INTEGER
title       TEXT
content     TEXT     — HTML sanitisé
slug        TEXT     — unique, stable (jamais régénéré)
author_id   INTEGER  → users(id) ON DELETE SET NULL
category_id INTEGER  → categories(id) ON DELETE SET NULL
created_at  DATETIME
updated_at  DATETIME

media

id         INTEGER  — clé primaire
filename   TEXT
url        TEXT
hash       TEXT     — SHA-256 de l'image (déduplication)
user_id    INTEGER  → users(id) ON DELETE SET NULL
created_at DATETIME

password_resets

id         INTEGER  — identifiant auto-incrémenté
user_id    INTEGER  → users(id) ON DELETE CASCADE
token_hash TEXT     — SHA-256 du token (jamais le token brut)
expires_at DATETIME — 1 heure après création
used_at    DATETIME — NULL jusqu'à consommation (traçabilité)
created_at DATETIME

posts_fts (table virtuelle FTS5)

Table virtuelle SQLite pour la recherche full-text. Maintenue automatiquement par des triggers sur posts. Permet des recherches rapides sur le titre, le contenu et le nom de l'auteur.

login_attempts

ip           TEXT          — clé primaire (adresse IP)
attempts     INTEGER       — nombre de tentatives échouées
locked_until TEXT          — NULL tant que le seuil n'est pas atteint
updated_at   TEXT NOT NULL — date de la dernière tentative (défaut : maintenant)

Table de protection anti-brute-force : stocke les tentatives de connexion échouées par adresse IP. Après 5 échecs, locked_until est rempli (verrouillage 15 min). Les entrées expirées sont purgées automatiquement par LoginAttemptRepository à chaque tentative.

6.2 Migrations

Les migrations sont des fichiers PHP dans database/migrations/. Elles s'exécutent automatiquement au démarrage via Migrator::run(). Chaque migration ne s'exécute qu'une fois (une table migrations trace l'historique).

Le provisionnement des données initiales (compte admin) est géré séparément par Seeder::seed(), appelé après Migrator::run() dans la séquence de démarrage. Cette séparation garantit que Migrator ne contient que du DDL, et Seeder que des données.

💡 Pour ajouter une colonne ou une table, créer un nouveau fichier de migration. Ne jamais modifier une migration déjà exécutée en production.

6.3 Sécurité

  • Les mots de passe sont hachés avec password_hash() (bcrypt). La valeur brute n'est jamais stockée.
  • Les tokens de réinitialisation sont stockés sous forme de hachage SHA-256. Le token brut envoyé par e-mail n'est jamais persisté.
  • PDO utilise des requêtes préparées (prepare + execute) : pas de risque d'injection SQL.

7. Installation et maintenance

7.1 Développement local (sans Docker)

Prérequis : PHP 8.1+ avec les extensions pdo_sqlite, mbstring, fileinfo, gd (WebP), dom. Composer. Node.js 18+.

git clone https://git.netig.net/netig/slim-blog
cd slim-blog
composer install
npm install && npm run build
cp .env.example .env
php -S localhost:8080 -t public

Le serveur est accessible sur http://localhost:8080. Les migrations s'exécutent automatiquement à la première requête. Le compte admin est créé avec les identifiants définis dans .env.

7.2 Production (Docker)

Prérequis : Docker, Docker Compose.

git clone https://git.netig.net/netig/slim-blog
cd slim-blog
cp .env.example .env
# Modifier .env : APP_ENV=production, APP_URL, ADMIN_PASSWORD
docker compose up -d --build

💡 Le démarrage est bloqué intentionnellement si ADMIN_PASSWORD vaut encore 'changeme123' et que APP_ENV=production. C'est une protection contre les déploiements non sécurisés.

Durcissement de sécurité

Deux fichiers de configuration contribuent au durcissement de la stack en production.

docker/php/php.ini :

  • expose_php = Off — supprime l'en-tête X-Powered-By: PHP/8.x qui expose la stack technique
  • session.name = sid — renomme le cookie de session (PHPSESSID est un fingerprint PHP connu des scanners automatisés)

docker/nginx/default.conf injecte quatre en-têtes HTTP sur toutes les réponses :

En-tête Valeur Protection
X-Frame-Options SAMEORIGIN Clickjacking
X-Content-Type-Options nosniff Sniffing MIME
Referrer-Policy strict-origin-when-cross-origin Fuite d'URL vers l'extérieur
Permissions-Policy camera=(), microphone=(), geolocation=() APIs navigateur non utilisées

Ces réglages sont actifs dès docker compose up sans configuration supplémentaire.

Séquence de démarrage

Le service app (PHP-FPM) expose un healthcheck TCP sur le port 9000. Nginx ne démarre qu'une fois le healthcheck passé (condition: service_healthy) — ce qui garantit qu'aucune requête n'est transmise à PHP-FPM avant qu'il soit prêt. Sans cela, Nginx démarrerait immédiatement et retournerait des 502 pendant les premières secondes (migrations, seed, sync public/).

Le healthcheck est configuré avec un start_period de 20 secondes : les échecs pendant cette fenêtre ne sont pas comptabilisés, ce qui laisse le temps à entrypoint.sh de terminer son initialisation sans déclencher de faux positifs.

Pour suivre la progression du démarrage :

docker compose up -d --build
docker compose ps          # colonne STATUS : "starting" → "healthy"
docker compose logs -f app # logs d'initialisation en temps réel

Vérification locale

L'application est accessible sur http://localhost:8888 une fois le service app passé à l'état healthy (visible dans docker compose ps). Ce n'est pas instantané au premier démarrage — compter 10 à 30 secondes le temps que PHP-FPM initialise et que les migrations s'exécutent.

Reverse proxy requis pour l'exposition publique

Le conteneur nginx écoute sur 127.0.0.1:8888 — uniquement sur l'interface loopback de la machine hôte. Il n'est pas accessible depuis Internet sans un reverse proxy configuré sur le serveur. Ce choix est délibéré : c'est le reverse proxy qui prend en charge le TLS et redirige le trafic HTTPS vers le conteneur.

La config Nginx interne transmet déjà les en-têtes nécessaires à PHP (X-Forwarded-For, X-Forwarded-Proto) pour que l'application connaisse l'IP réelle du client et sache si la connexion est HTTPS.

Exemple minimal avec Caddy (/etc/caddy/Caddyfile) :

blog.exemple.com {
    reverse_proxy 127.0.0.1:8888
}

Caddy gère automatiquement l'obtention et le renouvellement du certificat TLS. Avec Nginx sur l'hôte, ajouter un bloc proxy_pass http://127.0.0.1:8888; dans la section location / du virtualhost HTTPS, en incluant les en-têtes habituels (proxy_set_header Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto).

7.3 Variables d'environnement clés

Variable Description Exemple
APP_ENV development ou production production
APP_NAME Nom du blog (flux RSS, e-mails) Slim Blog
APP_URL URL publique (liens e-mails, flux RSS) https://blog.exemple.com
TIMEZONE Fuseau horaire PHP Europe/Paris
ADMIN_USERNAME Nom d'utilisateur du compte admin admin
ADMIN_EMAIL E-mail du compte admin admin@example.com
ADMIN_PASSWORD Mot de passe admin (obligatoire en production) (à changer)
MAIL_HOST Serveur SMTP smtp.exemple.com
MAIL_PORT Port SMTP (587 TLS, 465 SSL) 587
MAIL_USERNAME Identifiant SMTP noreply@exemple.com
MAIL_PASSWORD Mot de passe SMTP (confidentiel)
MAIL_ENCRYPTION Chiffrement SMTP (tls ou ssl) tls
MAIL_FROM Adresse expéditrice noreply@exemple.com
MAIL_FROM_NAME Nom d'affichage de l'expéditeur Slim Blog
UPLOAD_MAX_SIZE Taille max upload en octets 5242880 (5 Mo)

7.4 Mettre à jour le site

Après un changement de code ou de configuration :

docker compose build
docker compose up -d

Le script d'entrée Docker gère automatiquement la synchronisation des fichiers statiques et l'exécution des nouvelles migrations.

⚠️ Cache du container PHP-DI en production — En production, PHP-DI compile le container dans data/var/cache/di/ pour éviter la réflexion PHP à chaque requête. L'entrypoint Docker vide ce cache automatiquement à chaque déploiement (docker compose build && docker compose up -d). Si vous redémarrez le container sans rebuilder l'image (ex : docker compose restart app), le cache n'est pas vidé — dans ce cas, videz-le manuellement si config/container.php a été modifié :

docker compose exec app rm -rf /var/www/app/var/cache/di
docker compose restart app

⚠️ Cache Twig en production — Le dossier data/var/cache/twig/ est monté en volume persistant et survit aux redéploiements. Si un template ou une extension Twig a été modifié (notamment l'ajout d'une option is_safe sur une fonction), le cache compilé peut afficher un comportement obsolète (ex : balises HTML affichées en texte brut). Dans ce cas, vider le cache manuellement :

docker compose exec app rm -rf /var/www/app/var/cache/twig

Le cache se reconstruit automatiquement à la prochaine requête. Si le problème persiste après un redéploiement, vider également le volume local avant de relancer :

docker compose down
rm -rf data/var/cache/twig
docker compose up -d

7.5 Logs

En production, les logs PHP remontent dans Docker :

docker compose logs -f app

Les logs Monolog de l'application sont dans data/var/logs/.

7.6 Sauvegarde

La base SQLite est dans data/database/app.sqlite. Les images téléversées sont dans data/public/media/. Ces deux dossiers suffisent pour une sauvegarde complète.

7.7 Débogage

Afficher les erreurs en développement

Par défaut en production, les exceptions sont interceptées et une page d'erreur générique est affichée. En développement, Slim affiche la trace complète. S'assurer que .env contient :

APP_ENV=development

Avec cette valeur, toute exception non attrapée produit une page de débogage avec la pile d'appels complète, le fichier et la ligne concernés.

L'application ne démarre pas

Les erreurs au démarrage (migration échouée, variable d'environnement manquante, extension PHP absente) ne produisent parfois aucun message visible. Consulter les logs en premier recours :

# Avec Docker
docker compose logs app

# Sans Docker — PHP écrit dans stderr, redirigé vers le terminal
php -S localhost:8080 -t public

Les logs Monolog de l'application sont dans data/var/logs/.

Une migration ne s'exécute pas

Le Migrator trace les migrations déjà appliquées dans une table migrations. Si une migration a été modifiée après avoir été exécutée, le Migrator ne la réexécute pas. Pour forcer une réexécution en développement, supprimer le fichier SQLite (data/database/app.sqlite) : les migrations repartent de zéro au prochain démarrage.

⚠️ Ne jamais supprimer la base en production. Créer une nouvelle migration à la place.

PHPStan signale une erreur de type

PHPStan analyse le code statiquement sans l'exécuter. Une erreur typique :

Parameter #1 $id of method App\Post\PostRepository::findById()
expects int, int|null given.

Le message indique le fichier, la ligne et la nature du désaccord. La solution est presque toujours d'ajouter une vérification if ($id === null) avant l'appel, ou d'ajuster le type déclaré. Ne jamais ajouter une ligne // @phpstan-ignore sans comprendre pourquoi PHPStan se plaint : l'erreur signale presque toujours un vrai bug potentiel.

Une page affiche une erreur 500 en production

  1. Consulter docker compose logs app — PHP-FPM y écrit les erreurs fatales immédiatement.
  2. Consulter data/var/logs/ — Monolog y écrit toutes les exceptions avec leur trace complète, même en production.
  3. Passer temporairement APP_ENV=development pour voir l'erreur directement dans le navigateur.
  4. Remettre APP_ENV=production une fois le problème identifié.

La première requête répond 200, toutes les suivantes répondent 500

C'est la signature d'un container PHP-DI compilé corrompu. En production, PHP-DI compile le container au premier hit et écrit le résultat dans var/cache/di/. Ce cache est normalement vidé automatiquement par l'entrypoint Docker à chaque déploiement.

Si le problème survient après un docker compose restart app sans rebuild (le cache DI n'est alors pas vidé) :

docker compose exec app rm -rf /var/www/app/var/cache/di
docker compose restart app

Le rendu HTML est affiché en texte brut (balises visibles)

Le cache Twig compilé en production peut être obsolète après une modification d'une extension Twig (ex : ajout de is_safe => ['html'] sur une fonction). Le template compilé continue d'échapper le HTML jusqu'à ce que le cache soit invalidé.

docker compose exec app rm -rf /var/www/app/var/cache/twig

8. Faire évoluer le projet

Avant de modifier quoi que ce soit, un réflexe utile : lancer vendor/bin/phpunit et vendor/bin/phpstan analyse une première fois pour avoir une base verte. Si quelque chose casse après votre modification, vous saurez exactement ce que vous avez introduit.

Quand quelque chose ne marche plus, l'ordre de vérification est toujours le même. D'abord les logs : docker compose logs app en production, ou le terminal qui exécute php -S en développement. L'erreur y est presque toujours, plus explicite que ce qu'affiche le navigateur. Ensuite PHPStan : vendor/bin/phpstan analyse détecte les erreurs de typage avant même d'exécuter le code — un paramètre du mauvais type ou un retour null non géré y apparaît immédiatement. Enfin les tests : vendor/bin/phpunit confirme si le comportement existant est préservé ou si une régression a été introduite. Si les trois sont verts et que le bug persiste, passer APP_ENV=development pour voir la trace complète dans le navigateur.

8.1 Backoffice

Les modifications Backoffice concernent le code PHP : domaines métier, routes, middlewares et tests. Elles ne nécessitent aucune recompilation CSS.

8.1.1 Ajouter une fonctionnalité dans un domaine existant

Exemple : ajouter un champ « temps de lecture » aux articles.

  1. Créer une migration SQL pour ajouter la colonne à la table posts
  2. Ajouter la propriété dans le modèle Post.php
  3. Mettre à jour PostRepository pour lire/écrire ce champ
  4. Mettre à jour PostService si une logique de calcul est nécessaire
  5. Adapter les templates Twig pour afficher la valeur

💡 Ne jamais modifier une migration déjà exécutée en production. Toujours créer un nouveau fichier de migration.

8.1.2 Créer un nouveau domaine

Exemple : ajouter un domaine Commentaire.

  1. Créer src/Comment/ avec le schéma standard
    • Comment.php — modèle immuable
    • CommentRepositoryInterface.php — contrat
    • CommentRepository.php — implémentation PDO
    • CommentService.php — logique métier
    • CommentController.php — actions HTTP
  2. Déclarer les dépendances dans config/container.php
  3. Déclarer les routes dans Routes.php
  4. Créer la migration dans database/migrations/
  5. Écrire les tests dans tests/Comment/

Brancher le domaine dans config/container.php

C'est l'étape où les gens bloquent le plus souvent. Il faut déclarer le binding interface → classe pour chaque contrat du domaine. PHP-DI résout ensuite le service et le contrôleur automatiquement par autowiring.

// config/container.php — ajouter dans le tableau de retour

// 1. Repository — binding interface → implémentation PDO
CommentRepositoryInterface::class => autowire(CommentRepository::class),

// 2. Service — si CommentService ne dépend que d'interfaces déjà liées,
//    aucune factory supplémentaire : l'autowiring suffit.

// 3. Contrôleur — idem, résolu automatiquement si toutes ses dépendances
//    sont typées sur des interfaces déjà déclarées dans ce fichier.

💡 PHP-DI lit les types des paramètres de constructeur et injecte automatiquement. On n'écrit une factory() explicite que lorsqu'une dépendance est scalaire (chemin, clé .env) ou nécessite une logique d'initialisation non déductible du type seul.

Déclarer les routes dans Routes.php

// src/Shared/Routes.php — ajouter dans Routes::register()
// Ajouter en tête de fichier : use App\Auth\Middleware\AuthMiddleware;

// Routes publiques (lecture)
$app->get('/article/{slug}/comments', [CommentController::class, 'index']);

// Routes protégées (écriture)
$app->group('/comments', function ($group) {
    $group->post('/create', [CommentController::class, 'create']);
    $group->post('/delete/{id}', [CommentController::class, 'delete']);
})->add(AuthMiddleware::class);

À quoi ressemble un CommentController minimal

Pour lever toute ambiguïté sur ce que "créer un contrôleur" signifie concrètement, voici une implémentation minimale mais fonctionnelle des trois actions déclarées dans les routes ci-dessus. Elle reproduit les mêmes patterns que PostController : injection dans le constructeur, lecture de la session, flash + redirection en cas d'erreur.

<?php
// src/Comment/CommentController.php

declare(strict_types=1);

namespace App\Comment;

use App\Shared\Exception\NotFoundException;
use App\Shared\Http\FlashServiceInterface;
use App\Shared\Http\SessionManagerInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Exception\HttpNotFoundException;
use Slim\Views\Twig;

final class CommentController
{
    public function __construct(
        private readonly CommentServiceInterface $commentService,
        private readonly Twig $view,
        private readonly FlashServiceInterface $flash,
        private readonly SessionManagerInterface $sessionManager,
    ) {}

    /**
     * Affiche les commentaires d'un article (route publique).
     *
     * @param array<string, string> $args Paramètres de route (slug de l'article)
     */
    public function index(Request $req, Response $res, array $args): Response
    {
        $slug = (string) ($args['slug'] ?? '');

        try {
            $comments = $this->commentService->getCommentsByPostSlug($slug);
        } catch (NotFoundException) {
            throw new HttpNotFoundException($req);
        }

        return $this->view->render($res, 'pages/post/detail.twig', [
            'comments' => $comments,
            'slug'     => $slug,
        ]);
    }

    /**
     * Traite la soumission d'un nouveau commentaire (route protégée).
     *
     * L'auteur est l'utilisateur connecté, lu depuis la session.
     * En cas d'erreur de validation, redirige vers l'article avec un message flash.
     */
    public function create(Request $req, Response $res): Response
    {
        /** @var array<string, mixed> $data */
        $data    = (array) $req->getParsedBody();
        $content = trim((string) ($data['content'] ?? ''));
        $postId  = (int) ($data['post_id'] ?? 0);
        $slug    = (string) ($data['slug'] ?? '');

        try {
            $this->commentService->createComment(
                $content,
                $postId,
                $this->sessionManager->getUserId() ?? 0,
            );
            $this->flash->set('comment_success', 'Commentaire publié.');
        } catch (\InvalidArgumentException $e) {
            // Erreur métier (contenu vide, post inexistant…) — flash + retour à l'article
            $this->flash->set('comment_error', $e->getMessage());
        }

        return $res->withHeader('Location', "/article/{$slug}")->withStatus(302);
    }

    /**
     * Supprime un commentaire (route protégée).
     *
     * Seul l'auteur du commentaire ou un admin peut le supprimer.
     *
     * @param array<string, string> $args Paramètres de route (id du commentaire)
     */
    public function delete(Request $req, Response $res, array $args): Response
    {
        $id = (int) ($args['id'] ?? 0);

        try {
            $comment = $this->commentService->getCommentById($id);
        } catch (NotFoundException) {
            throw new HttpNotFoundException($req);
        }

        // Vérification des droits : auteur du commentaire ou admin
        $isOwner = $comment->getAuthorId() === $this->sessionManager->getUserId();
        if (!$isOwner && !$this->sessionManager->isAdmin()) {
            $this->flash->set('comment_error', 'Vous ne pouvez pas supprimer ce commentaire.');
            return $res->withHeader('Location', '/')->withStatus(302);
        }

        try {
            $this->commentService->deleteComment($id);
            $this->flash->set('comment_success', 'Commentaire supprimé.');
        } catch (NotFoundException) {
            // Supprimé entre la vérification et le DELETE (race condition)
            throw new HttpNotFoundException($req);
        }

        // Redirige vers le referer si disponible, sinon vers l'accueil
        $referer = $req->getHeaderLine('Referer');
        $target  = $referer !== '' ? $referer : '/';

        return $res->withHeader('Location', $target)->withStatus(302);
    }
}

Ce contrôleur n'invente rien : chaque pattern est déjà présent dans PostController. index() appelle le service et passe les données à Twig. create() lit $req->getParsedBody(), délègue à CommentService, et redirige avec un flash. delete() vérifie les droits avant d'agir, exactement comme PostController::delete(). CommentService et CommentRepository sont à écrire selon le même schéma que PostService et PostRepository.

8.1.3 Ajouter un rôle

Les rôles sont définis comme constantes dans User.php et vérifiés par des middlewares (AuthMiddleware, EditorMiddleware, AdminMiddleware). Pour ajouter un rôle :

  1. Ajouter la méthode isNouveauRole() dans SessionManager et SessionManagerInterface
  2. Créer NouveauRoleMiddleware.php dans src/Auth/Middleware/
  3. Protéger les routes concernées dans Routes.php

8.1.4 Adapter le projet à un autre domaine

Les domaines Auth, Category, Media, User et Shared sont réutilisables sans modification. Seul Post/ est spécifique au blog. Pour transformer le projet en boutique :

  1. Supprimer ou archiver src/Post/
  2. Créer src/Product/, src/Order/, etc. selon le même schéma
  3. Adapter les routes et les templates

L'infrastructure (authentification, sessions, e-mails, uploads, CSS) est entièrement réutilisable.

8.1.5 Lancer les tests

Les tests unitaires vérifient la logique métier des services, des repositories et des contrôleurs sans démarrer l'application ni toucher la base de données. Le projet compte 26 suites réparties dans cinq dossiers, plus une classe de base partagée :

tests/
├── ControllerTestCase.php   ← Classe de base abstraite (helpers PSR-7, assertions HTTP)
├── Auth/       AccountControllerTest, AuthControllerTest, AuthServiceTest,
│               AuthServiceRateLimitTest, LoginAttemptRepositoryTest,
│               PasswordResetControllerTest, PasswordResetRepositoryTest,
│               PasswordResetServiceTest
├── Category/   CategoryControllerTest, CategoryRepositoryTest, CategoryServiceTest
├── Media/      MediaControllerTest, MediaRepositoryTest, MediaServiceTest
├── Post/       PostControllerTest, PostRepositoryTest, PostServiceTest, RssControllerTest
├── Shared/     DateParserTest, HtmlSanitizerTest, SessionManagerTest, SlugHelperTest
└── User/       UserControllerTest, UserRepositoryTest, UserServiceTest, UserTest
# Lancer toute la suite
vendor/bin/phpunit

# Lancer uniquement les tests d'un domaine
vendor/bin/phpunit tests/Auth/

# Lancer un seul fichier de test
vendor/bin/phpunit tests/Auth/AuthServiceTest.php

# Afficher le nom de chaque test exécuté
vendor/bin/phpunit --testdox

Une suite verte ressemble à ceci :

OK (42 tests, 97 assertions)

Une suite avec échec indique le test concerné, la valeur attendue et la valeur obtenue :

FAILED (failures: 1)

AuthServiceTest::testCreateUserWithValidData
Failed asserting that 'Alice' is identical to 'alice'.

Ici le test révèle que la normalisation en minuscules n'est pas appliquée — c'est exactement ce genre de régression qu'un test est censé détecter.

Analyse statique

vendor/bin/phpstan analyse

PHPStan analyse le code sans l'exécuter et signale les erreurs de types. Une sortie propre ressemble à :

 [OK] No errors

En cas d'erreur, PHPStan indique le fichier, la ligne et la nature du problème. Voir §7.7 pour interpréter et corriger ces messages.

Couverture de code (requiert Xdebug ou PCOV) :

vendor/bin/phpunit --coverage-text

💡 Lancer vendor/bin/phpunit et vendor/bin/phpstan analyse avant chaque commit garantit qu'aucune régression n'est introduite. PHPStan niveau 8 est configuré en mode strict : s'il passe, le typage est solide.


8.2 Frontend

Le Frontend regroupe les templates Twig (structure HTML) et les sources SCSS (styles visuels). Ces deux couches sont indépendantes du PHP : on peut modifier une page ou changer une couleur sans toucher au code métier.

8.2.1 Modifier un template Twig

Les templates Twig sont dans views/. Ils héritent tous de layout.twig qui définit la structure HTML commune (head, header, footer).

Pour modifier l'apparence d'une page, ouvrir le fichier .twig correspondant dans views/pages/ ou views/admin/. Pour modifier un élément commun (navigation, pied de page), éditer les partials/.

L'héritage Twig fonctionne avec deux mécanismes :

  • extends : un template enfant déclare {% extends 'layout.twig' %} pour hériter de la structure globale.
  • block : les zones variables sont définies dans le layout ({% block content %}{% endblock %}) et remplies par chaque page.
{% extends 'layout.twig' %}

{% block title %}
    Slim Blog
{% endblock %}

{% block content %}
    <div class="card-list card-list--contained">
        {% for post in posts %}
            <article class="card">
                <h2 class="card__title">
                    <a href="{{ post_url(post) }}">{{ post.title }}</a>
                </h2>
                <p class="card__excerpt">{{ post_excerpt(post, 200) }}</p>
            </article>
        {% else %}
            <p>Aucun article publié.</p>
        {% endfor %}
    </div>
{% endblock %}

💡 Après modification du SCSS, recompiler avec npm run build (ou npm run watch pour la recompilation automatique).

8.2.2 Travailler avec le SCSS

Les styles sont écrits en SCSS (Sass) dans assets/scss/ et compilés vers public/assets/css/ par l'outil en ligne de commande Sass. Le fichier CSS généré n'est jamais modifié directement.

Architecture 7-1

Le dossier assets/scss/ suit une version simplifiée de l'architecture 7-1 : les sources sont réparties en couches, chacune dans son sous-dossier. Le fichier main.scss est le seul point d'entrée ; il importe les couches dans un ordre déterminé.

assets/scss/
├── abstracts/             ← variables et mixins (ne génèrent aucun CSS)
│   ├── _variables.scss    ← design tokens (couleurs, espacements…)
│   └── _mixins.scss       ← breakpoints réutilisables
├── base/                  ← reset et typographie globale
├── components/            ← éléments réutilisables (boutons, cartes…)
├── layout/                ← zones structurelles (header, footer)
├── pages/                 ← surcharges spécifiques à chaque vue
└── main.scss              ← point d'entrée unique, importe tout

Variables

Le fichier abstracts/_variables.scss est la source de vérité pour toutes les valeurs du projet. Modifier une variable dans ce fichier propage automatiquement la modification à tous les composants qui l'utilisent.

// Couleurs
$color-primary: #007bff;
$color-danger:  #dc3545;
$color-text:    #212529;
$color-border:  #dee2e6;

// Espacements
$spacing-sm: 0.5rem;
$spacing-md: 1rem;
$spacing-lg: 1.5rem;

// Responsive
$breakpoint-mobile: 600px;

Pour utiliser ces variables dans un fichier de composant, il faut les importer en tête du fichier avec @use (et non @import, qui est obsolète en Sass moderne) :

@use '../abstracts/variables' as *;

.btn {
    padding: $spacing-sm $spacing-md;
    background: $color-primary;
}

Mixins

Le fichier abstracts/_mixins.scss définit des blocs CSS réutilisables appelés avec @include. Le projet contient un mixin principal : mobile, qui applique des styles en dessous du breakpoint mobile.

@use '../abstracts/mixins' as *;

.card {
    display: flex;
    flex-direction: row;

    @include mobile {
        // styles appliqués en dessous de 600px
        flex-direction: column;
    }
}

Convention BEM

Les classes CSS suivent la convention BEM (Block — Element — Modifier). Elle permet de lire immédiatement le rôle de chaque classe et d'éviter les conflits de nommage.

  • Block : composant indépendant. Exemple : .card, .btn
  • Element : partie du bloc, séparée par __. Exemple : .card__title, .card__body
  • Modifier : variante du bloc ou de l'élément, séparée par --. Exemple : .btn--primary, .card-list--contained
<!-- HTML -->
<div class="card">
    <div class="card__content">
        <div class="card__body">
            <h2 class="card__title">Titre</h2>
            <p class="card__excerpt">Extrait</p>
        </div>
        <div class="card__actions">
            <a href="#" class="card__actions-link">Lire la suite →</a>
        </div>
    </div>
</div>

<button class="btn btn--primary btn--lg">Publier</button>
<button class="btn-link">Déconnexion</button>

Ajouter un composant

Pour ajouter un nouveau composant (exemple : une boîte de dialogue modale), créer un fichier dans assets/scss/components/ et l'importer dans main.scss.

// 1. Créer assets/scss/components/_modal.scss
@use '../abstracts/variables' as *;

.modal {
    position: fixed;
    background: $color-bg-white;
    border: 1px solid $color-border;
    border-radius: $border-radius;
    padding: $spacing-lg;
}

.modal__title {
    font-size: 1.25rem;
    margin-bottom: $spacing-md;
}

.modal--large {
    max-width: 800px;
}

// 2. Ajouter l'import dans assets/scss/main.scss
@use "components/modal";

💡 Ne jamais écrire de CSS directement dans main.scss. Ce fichier est uniquement un orchestrateur d'imports. Tout style concret va dans le fichier partiel correspondant à son contexte.

Commandes

Commande Usage
npm run build Compile tout (CSS + assets vendor)
npm run watch Recompile automatiquement le SCSS à chaque modification
npm run clean Supprime le dossier public/assets/

💡 En développement, lancer npm run watch dans un terminal dédié. Les modifications SCSS sont immédiatement appliquées au rechargement de la page, sans redémarrer le serveur PHP.


9. Conclusion

Si vous découvrez le projet, voici une progression concrète pour le prendre en main :

  1. Lire src/Post/ de bout en bout — c'est le domaine le plus complet, il contient tous les patterns du projet (modèle, interface, repository, service, contrôleur). C'est la seule fois où vous aurez besoin de lire un domaine en entier ; les autres se comprennent ensuite par analogie.

  2. Tracer le flux d'une requête (§5.4 et §5.5) dans le code en suivant public/index.phpBootstrapRoutesPostController. Cette étape ancre la lecture abstraite dans la réalité de l'exécution : après ça, vous savez exactement où intervenir pour n'importe quelle modification.

  3. Lire un test unitaire dans tests/Auth/AuthServiceTest.php pour comprendre comment les mocks remplacent les dépendances réelles. C'est fait après les deux premières étapes car les mocks ne prennent sens que quand on a déjà vu les vraies classes en action.

  4. Faire une petite modification : ajouter un champ, créer une route, modifier un template — et vérifier que les tests passent toujours. C'est en dernier parce que c'est là que tout se consolide : une modification réussie prouve qu'on a compris, pas seulement qu'on a lu.

Le dossier docs/ contient un fichier complémentaire à ce guide : ARCHITECTURE.md détaille les décisions de conception (pourquoi ce pattern, pourquoi cette contrainte). Ce guide-ci est autonome — il n'est pas nécessaire de le lire pour démarrer — mais il approfondit certains points si le besoin s'en fait sentir. Les commentaires PHPDoc dans le code accompagnent également chaque étape. En cas de doute sur un pattern, le domaine Post/ fait toujours référence.