1834 lines
88 KiB
Markdown
1834 lines
88 KiB
Markdown
# Slim Blog
|
|
|
|
**Guide technique pour développeurs**
|
|
|
|
*Mars 2026*
|
|
|
|
---
|
|
|
|
## Table des matières
|
|
|
|
1. [Présentation du projet](#1-présentation-du-projet)
|
|
- [1.1 Qu'est-ce que Slim Blog ?](#11-quest-ce-que-slim-blog-)
|
|
- [1.2 Fonctionnalités](#12-fonctionnalités)
|
|
- [1.3 Ce que le projet n'est pas](#13-ce-que-le-projet-nest-pas)
|
|
- [1.4 Interface de l'application](#14-interface-de-lapplication)
|
|
2. [Bases de PHP utiles](#2-bases-de-php-utiles)
|
|
- [2.1 Fondamentaux du langage](#21-fondamentaux-du-langage)
|
|
- [2.1.1 PHP côté serveur](#211-php-côté-serveur)
|
|
- [2.1.2 Variables, types et strict_types](#212-variables-types-et-strict_types)
|
|
- [2.1.3 Ce qui diffère des autres langages](#213-ce-qui-diffère-des-autres-langages)
|
|
- [2.1.4 Signatures de fonctions et `void`](#214-signatures-de-fonctions-et-void)
|
|
- [2.1.5 Tableaux associatifs](#215-tableaux-associatifs)
|
|
- [2.1.6 Namespaces et autoloading](#216-namespaces-et-autoloading)
|
|
- [2.1.7 Exceptions et hiérarchie](#217-exceptions-et-hiérarchie)
|
|
- [2.2 Programmation orientée objet](#22-programmation-orientée-objet)
|
|
- [2.2.1 Classes et modificateurs PHP](#221-classes-et-modificateurs-php)
|
|
- [2.2.2 Interfaces](#222-interfaces)
|
|
- [2.2.3 Injection de dépendances](#223-injection-de-dépendances)
|
|
3. [Choix techniques](#3-choix-techniques)
|
|
- [3.1 La stack](#31-la-stack)
|
|
- [3.2 Pourquoi PDO natif plutôt qu'Eloquent ou Doctrine ?](#32-pourquoi-pdo-natif-plutôt-queloquent-ou-doctrine-)
|
|
- [3.3 Pourquoi SQLite ?](#33-pourquoi-sqlite-)
|
|
4. [Structure des fichiers](#4-structure-des-fichiers)
|
|
- [4.1 Vue d'ensemble](#41-vue-densemble)
|
|
- [4.2 Le dossier assets/](#42-le-dossier-assets)
|
|
- [4.3 Le dossier data/ (production)](#43-le-dossier-data-production)
|
|
- [4.4 Le dossier database/](#44-le-dossier-database)
|
|
- [4.5 Le dossier public/](#45-le-dossier-public)
|
|
- [4.6 Le dossier src/](#46-le-dossier-src)
|
|
- [4.7 Le dossier tests/](#47-le-dossier-tests)
|
|
- [4.8 Le dossier views/](#48-le-dossier-views)
|
|
5. [Architecture en domaines](#5-architecture-en-domaines)
|
|
- [5.1 Principe](#51-principe)
|
|
- [5.2 Les domaines](#52-les-domaines)
|
|
- [5.3 Anatomie d'un domaine](#53-anatomie-dun-domaine)
|
|
- [5.4 Le flux d'une requête](#54-le-flux-dune-requête)
|
|
- [5.5 Le chemin des données (lecture)](#55-le-chemin-des-données-lecture)
|
|
- [5.6 Pourquoi les interfaces ?](#56-pourquoi-les-interfaces-)
|
|
- [5.7 Le Container](#57-le-container)
|
|
- [5.8 Le domaine Auth](#58-le-domaine-auth)
|
|
- [5.9 Gestion des erreurs et messages flash](#59-gestion-des-erreurs-et-messages-flash)
|
|
6. [La base de données](#6-la-base-de-données)
|
|
- [6.1 Schéma](#61-schéma)
|
|
- [6.2 Migrations](#62-migrations)
|
|
- [6.3 Sécurité](#63-sécurité)
|
|
7. [Installation et maintenance](#7-installation-et-maintenance)
|
|
- [7.1 Développement local (sans Docker)](#71-développement-local-sans-docker)
|
|
- [7.2 Production (Docker)](#72-production-docker)
|
|
- [7.3 Variables d'environnement clés](#73-variables-denvironnement-clés)
|
|
- [7.4 Mettre à jour le site](#74-mettre-à-jour-le-site)
|
|
- [7.5 Logs](#75-logs)
|
|
- [7.6 Sauvegarde](#76-sauvegarde)
|
|
- [7.7 Débogage](#77-débogage)
|
|
8. [Faire évoluer le projet](#8-faire-évoluer-le-projet)
|
|
- [8.1 Backoffice](#81-backoffice)
|
|
- [8.1.1 Ajouter une fonctionnalité dans un domaine existant](#811-ajouter-une-fonctionnalité-dans-un-domaine-existant)
|
|
- [8.1.2 Créer un nouveau domaine](#812-créer-un-nouveau-domaine)
|
|
- [8.1.3 Ajouter un rôle](#813-ajouter-un-rôle)
|
|
- [8.1.4 Adapter le projet à un autre domaine](#814-adapter-le-projet-à-un-autre-domaine)
|
|
- [8.1.5 Lancer les tests](#815-lancer-les-tests)
|
|
- [8.2 Frontend](#82-frontend)
|
|
- [8.2.1 Modifier un template Twig](#821-modifier-un-template-twig)
|
|
- [8.2.2 Travailler avec le SCSS](#822-travailler-avec-le-scss)
|
|
9. [Conclusion](#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.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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 :
|
|
|
|
```php
|
|
$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`.
|
|
|
|
```php
|
|
// $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()`.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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).
|
|
|
|
```php
|
|
// ❌ 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`).
|
|
|
|
```php
|
|
// 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).
|
|
|
|
```php
|
|
// 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 :
|
|
|
|
```php
|
|
$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` :
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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**
|
|
|
|
```php
|
|
// 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**
|
|
|
|
```php
|
|
// 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**
|
|
|
|
```php
|
|
$this->view->render($res, 'pages/home.twig', ['posts' => $posts, ...]);
|
|
```
|
|
|
|
**4. Twig accède aux propriétés via les getters**
|
|
|
|
```twig
|
|
{{ 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 :
|
|
|
|
```php
|
|
// 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) :
|
|
|
|
```php
|
|
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.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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.
|
|
|
|
```php
|
|
// 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+.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```bash
|
|
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 :
|
|
|
|
```bash
|
|
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 :
|
|
|
|
```bash
|
|
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é :
|
|
>
|
|
> ```bash
|
|
> 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 :
|
|
>
|
|
> ```bash
|
|
> 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 :
|
|
>
|
|
> ```bash
|
|
> docker compose down
|
|
> rm -rf data/var/cache/twig
|
|
> docker compose up -d
|
|
> ```
|
|
|
|
### 7.5 Logs
|
|
|
|
En production, les logs PHP remontent dans Docker :
|
|
|
|
```bash
|
|
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 :
|
|
|
|
```bash
|
|
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 :
|
|
|
|
```bash
|
|
# 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é) :
|
|
|
|
```bash
|
|
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é.
|
|
|
|
```bash
|
|
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.
|
|
|
|
```php
|
|
// 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`**
|
|
|
|
```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
|
|
<?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
|
|
```
|
|
|
|
```bash
|
|
# 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**
|
|
|
|
```bash
|
|
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) :
|
|
|
|
```bash
|
|
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.
|
|
|
|
```twig
|
|
{% 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.
|
|
|
|
```scss
|
|
// 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) :
|
|
|
|
```scss
|
|
@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.
|
|
|
|
```scss
|
|
@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
|
|
<!-- 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`.
|
|
|
|
```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.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.
|
|
|
|
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.
|