88 KiB
Slim Blog
Guide technique pour développeurs
Mars 2026
Table des matières
- Présentation du projet
- Bases de PHP utiles
- Choix techniques
- Structure des fichiers
- Architecture en domaines
- La base de données
- Installation et maintenance
- Faire évoluer le projet
- 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/loginavec ces identifiants donne accès à l'interface d'administration. L'URL de base dépend du mode d'installation :http://localhost:8080en développement sans Docker (§7.1),http://localhost:8888pour vérifier en local aprèsdocker 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 $categoryIddans PostService,?string $authorUsernamedans 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;
💡
nullet''sont deux valeurs distinctes. DansPost,$authorUsernameànullsignifie 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 sontfinal: 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 attributfrozenen Python ou d'une propriétéconsten 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.protectedexiste aussi mais est absent du projet (les classesfinaln'ont pas de sous-classes à qui exposer des membres protégés).
💡 La méthode statique
fromArray()présente dansPostetUserest 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.
PostServicene 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 dansassets/scss/, puis on relancenpm run build(ounpm run watchpour 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.phpparcourt ce dossier au démarrage, compare les versions déjà appliquées (stockées dans une tablemigrations) et exécute uniquement les nouvelles. Pour ajouter une table, il suffit de créer un fichier008_*.phpavec 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.
PostControllerdépend dePostServiceInterface;PostServicedépend dePostRepositoryInterface: 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 domainesrc/User/, séparé d'Auth. Auth consommeUser/en lecture viaUserRepositoryInterface— dépendance unidirectionnelle. L'autre dépendance inter-domaines du projet estPost/ → Category/:PostControllerinjecteCategoryServiceInterfacepour alimenter la liste des catégories dans le formulaire d'édition.PostServiceetPostRepositoryn'ont aucune connaissance deCategory/.
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 approuvé
$ip = $this->clientIpResolver->resolve($req);
// 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'estAuthControllerqui en est responsable. Cette séparation permet de tester chaque comportement indépendamment.⚠️ L'IP lue depuis
REMOTE_ADDRderrière un proxy retourne l'IP interne du proxy — le rate-limit se verrouillerait alors pour tous les utilisateurs simultanément. Le projet centralise désormais cette logique dansClientIpResolver/RequestContextet ne fait confiance aux en-têtesX-Forwarded-*que pour les proxies explicitement approuvés viaTRUSTED_PROXIES.
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é ounull.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 pageviews/pages/error.twigavec 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 :
NotFoundExceptionest une erreur d'infrastructure (ressource absente) gérée par Slim. Les exceptionsDuplicateUsername,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.4.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_PASSWORDvaut encore'changeme123'et queAPP_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êteX-Powered-By: PHP/8.xqui expose la stack techniquesession.name = sid— renomme le cookie de session (PHPSESSIDest 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. Le conteneur app accepte ces en-têtes uniquement depuis les proxies listés dans TRUSTED_PROXIES (par défaut * dans docker-compose.yml, car PHP-FPM n'est joignable qu'à travers le Nginx interne).
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 |
| TRUSTED_PROXIES | Proxies autorisés à fournir X-Forwarded-For / X-Forwarded-Proto |
127.0.0.1,::1 ou * |
| 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 siconfig/container.phpa é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 optionis_safesur 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/twigLe 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
- Consulter
docker compose logs app— PHP-FPM y écrit les erreurs fatales immédiatement. - Consulter
data/var/logs/— Monolog y écrit toutes les exceptions avec leur trace complète, même en production. - Passer temporairement
APP_ENV=developmentpour voir l'erreur directement dans le navigateur. - Remettre
APP_ENV=productionune 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.
- Créer une migration SQL pour ajouter la colonne à la table
posts - Ajouter la propriété dans le modèle
Post.php - Mettre à jour
PostRepositorypour lire/écrire ce champ - Mettre à jour
PostServicesi une logique de calcul est nécessaire - 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.
- Créer
src/Comment/avec le schéma standardComment.php— modèle immuableCommentRepositoryInterface.php— contratCommentRepository.php— implémentation PDOCommentService.php— logique métierCommentController.php— actions HTTP
- Déclarer les dépendances dans
config/container.php - Déclarer les routes dans
Routes.php - Créer la migration dans
database/migrations/ - É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 :
- Ajouter la méthode
isNouveauRole()dansSessionManageretSessionManagerInterface - Créer
NouveauRoleMiddleware.phpdanssrc/Auth/Middleware/ - 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 :
- Supprimer ou archiver
src/Post/ - Créer
src/Product/,src/Order/, etc. selon le même schéma - 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/phpunitetvendor/bin/phpstan analyseavant 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(ounpm run watchpour 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 watchdans 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 :
-
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. -
Tracer le flux d'une requête (§5.4 et §5.5) dans le code en suivant
public/index.php→Bootstrap→Routes→PostController. 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. -
Lire un test unitaire dans
tests/Auth/AuthServiceTest.phppour 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. -
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.