Refatoring : Working state

This commit is contained in:
julien
2026-03-16 15:37:57 +01:00
parent aa00bec846
commit a5ca0df375
16 changed files with 251 additions and 568 deletions

View File

@@ -3,18 +3,18 @@
![PHP](https://img.shields.io/badge/PHP-8.4.1%2B-777BB4?logo=php&logoColor=white) ![PHP](https://img.shields.io/badge/PHP-8.4.1%2B-777BB4?logo=php&logoColor=white)
![Slim](https://img.shields.io/badge/Slim-4-74a045) ![Slim](https://img.shields.io/badge/Slim-4-74a045)
![PHPStan](https://img.shields.io/badge/PHPStan-niveau%208-blue) ![PHPStan](https://img.shields.io/badge/PHPStan-niveau%208-blue)
![Tests](https://img.shields.io/badge/tests-355%20passing-brightgreen) ![Tests](https://img.shields.io/badge/tests-442%20passing-brightgreen)
![Licence](https://img.shields.io/badge/licence-MIT-green) ![Licence](https://img.shields.io/badge/licence-MIT-green)
Blog multi-utilisateurs modulaire développé avec Slim 4. Les domaines `Auth`, `Category`, `Media`, `User` Blog multi-utilisateurs modulaire développé avec Slim 4. Les domaines `Auth`, `Category`, `Media`, `User`
et `Shared` sont indépendants du domaine métier et réutilisables sans modification pour d'autres et `Shared` portent une architecture DDD légère, lisible et réutilisable pour d'autres
projets (boutique, portfolio…). projets (boutique, portfolio…).
## Fonctionnalités ## Fonctionnalités
- **Articles** — création, édition, suppression avec éditeur WYSIWYG, slugs stables - **Articles** — création, édition, suppression avec éditeur WYSIWYG, slugs stables
- **Catégories** — filtrage sur la page d'accueil et dans l'interface admin - **Catégories** — filtrage sur la page d'accueil et dans l'interface admin
- **Médias** — upload WebP avec déduplication SHA-256 - **Médias** — upload WebP avec déduplication SHA-256 par utilisateur
- **Recherche** — full-text FTS5 cumulable avec le filtre catégorie - **Recherche** — full-text FTS5 cumulable avec le filtre catégorie
- **Comptes** — trois rôles (`user`, `editor`, `admin`), réinitialisation de mot de passe par email - **Comptes** — trois rôles (`user`, `editor`, `admin`), réinitialisation de mot de passe par email
- **RSS** — flux 2.0 des 20 derniers articles (`/rss.xml`) - **RSS** — flux 2.0 des 20 derniers articles (`/rss.xml`)
@@ -50,6 +50,8 @@ php bin/provision.php
php -S localhost:8080 -t public php -S localhost:8080 -t public
``` ```
Le projet est encore en développement : l'historique des migrations a été simplifié en une baseline courte. Si vous aviez une ancienne base locale, supprimez `database/app.sqlite` puis reprovisionnez.
Pour surveiller les modifications SCSS et recompiler automatiquement en développement : Pour surveiller les modifications SCSS et recompiler automatiquement en développement :
```bash ```bash
@@ -177,11 +179,11 @@ Le contenu du blog (articles publiés) est soumis à [CC BY-SA 4.0](https://crea
## Provisioning ## Provisioning
Le provisionnement (migrations + seed admin) s'execute explicitement via `php bin/provision.php`. Le provisionnement (migrations + seed admin) s'exécute explicitement via `php bin/provision.php`.
- Developpement local : executer `php bin/provision.php` apres `cp .env.example .env` - Développement local : exécuter `php bin/provision.php` apres `cp .env.example .env`
- Docker / production : executer `docker compose exec app php bin/provision.php` apres le demarrage du conteneur - Docker / production : exécuter `docker compose exec app php bin/provision.php` apres le demarrage du conteneur
Le runtime HTTP ne provisionne plus automatiquement la base. Si le schema n'est pas present, l'application echoue avec un message explicite demandant d'executer la commande de provisionnement. Le runtime HTTP ne provisionne plus automatiquement la base. Si le schéma n'est pas présent, l'application echoue avec un message explicite demandant d'exécuter la commande de provisionnement.
Pour repartir d'un schema frais en developpement apres un nettoyage de l'historique des migrations, supprimez d'abord la base SQLite locale puis relancez le provisionnement : `rm -f database/app.sqlite` (ou votre fichier SQLite configure), puis `php bin/provision.php`. Pour repartir d'un schéma frais en développement apres un nettoyage de l'historique des migrations, supprimez d'abord la base SQLite locale puis relancez le provisionnement : `rm -f database/app.sqlite` (ou votre fichier SQLite configure), puis `php bin/provision.php`.

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Migration 001 — Création du schéma principal de l'application.
*
* Cette baseline remplace l'historique détaillé des migrations tant que
* le projet est encore en développement et qu'aucune donnée n'est à conserver.
*/
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
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL DEFAULT '',
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
url TEXT NOT NULL,
hash TEXT NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id);
CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id);
CREATE TABLE IF NOT EXISTS password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT NOT NULL PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT DEFAULT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
",
'down' => "
DROP TABLE IF EXISTS login_attempts;
DROP TABLE IF EXISTS password_resets;
DROP INDEX IF EXISTS idx_media_hash_user_id;
DROP INDEX IF EXISTS idx_media_user_id;
DROP TABLE IF EXISTS media;
DROP INDEX IF EXISTS idx_posts_author_id;
DROP TABLE IF EXISTS posts;
DROP TABLE IF EXISTS categories;
DROP TABLE IF EXISTS users;
",
];

View File

@@ -1,19 +0,0 @@
<?php
/**
* Migration 001 — Création de la table des utilisateurs.
*/
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',
];

View File

@@ -1,23 +0,0 @@
<?php
/**
* Migration 002 — Création de la table des catégories.
*
* category_id est une clé étrangère nullable vers category(id).
* SET NULL garantit que les articles sont conservés si une catégorie est supprimée.
*
* SQLite ne supportant pas l'ajout de contrainte FK via ALTER TABLE,
* la référence est déclarée inline dans le ADD COLUMN — SQLite l'enregistre
* dans le schéma sans l'appliquer strictement à moins que PRAGMA foreign_keys = ON.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL
);
",
'down' => 'DROP TABLE IF EXISTS categories',
];

View File

@@ -1,25 +1,9 @@
<?php <?php
declare(strict_types=1);
/** /**
* Migration 006 Création de l'index FTS5 pour la recherche plein texte. * Migration 002 Création de l'index de recherche FTS5 et de ses triggers.
*
* Crée une table virtuelle FTS5 `posts_fts` indexant le titre, le contenu
* et le nom de l'auteur de chaque article. Le rowid de la table FTS correspond
* à l'id de l'article dans `posts`.
*
* Trois triggers maintiennent la synchronisation automatique entre `posts`
* et `posts_fts` à chaque INSERT, UPDATE et DELETE.
*
* La colonne `author_username` est résolue par sous-requête lors de l'écriture
* dans le trigger, ce qui évite de stocker une valeur dénormalisée en dehors
* de l'index FTS (dont le seul rôle est la recherche).
*
* Le contenu HTML est passé par `strip_tags()` (fonction PHP enregistrée sur la
* connexion PDO via `sqliteCreateFunction`) avant indexation, afin d'éviter que
* les balises et attributs HTML ne polluent l'index et ne génèrent du bruit.
*
* Tokenizer utilisé : unicode61 (défaut FTS5) gère les diacritiques et les
* caractères non-ASCII, insensible à la casse.
*/ */
return [ return [
'up' => " 'up' => "
@@ -57,9 +41,24 @@ return [
AFTER DELETE ON posts BEGIN AFTER DELETE ON posts BEGIN
DELETE FROM posts_fts WHERE rowid = OLD.id; DELETE FROM posts_fts WHERE rowid = OLD.id;
END; END;
CREATE TRIGGER IF NOT EXISTS posts_fts_users_update
AFTER UPDATE OF username ON users BEGIN
DELETE FROM posts_fts
WHERE rowid IN (SELECT id FROM posts WHERE author_id = NEW.id);
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
NEW.username
FROM posts p
WHERE p.author_id = NEW.id;
END;
", ",
'down' => " 'down' => "
DROP TRIGGER IF EXISTS posts_fts_users_update;
DROP TRIGGER IF EXISTS posts_fts_delete; DROP TRIGGER IF EXISTS posts_fts_delete;
DROP TRIGGER IF EXISTS posts_fts_update; DROP TRIGGER IF EXISTS posts_fts_update;
DROP TRIGGER IF EXISTS posts_fts_insert; DROP TRIGGER IF EXISTS posts_fts_insert;

View File

@@ -1,31 +0,0 @@
<?php
/**
* Migration 003 — Création de la table des articles.
*
* author_id est une clé étrangère nullable vers users(id).
* SET NULL garantit que les articles sont conservés si l'auteur est supprimé.
*
* Index explicite sur author_id : SQLite n'indexe pas automatiquement les FK.
* Utilisé par findByUserId() (liste admin) et par les filtres de recherche FTS.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL DEFAULT '',
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_posts_author_id ON posts(author_id);
",
'down' => "
DROP INDEX IF EXISTS idx_posts_author_id;
DROP TABLE IF EXISTS posts;
",
];

View File

@@ -1,22 +0,0 @@
<?php
return [
'up' => "
CREATE TABLE IF NOT EXISTS media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
url TEXT NOT NULL,
hash TEXT NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_media_user_id ON media(user_id);
CREATE INDEX IF NOT EXISTS idx_media_hash_user_id ON media(hash, user_id);
",
'down' => "
DROP INDEX IF EXISTS idx_media_hash_user_id;
DROP INDEX IF EXISTS idx_media_user_id;
DROP TABLE IF EXISTS media;
",
];

View File

@@ -1,26 +0,0 @@
<?php
/**
* Migration 005 — Création de la table de réinitialisation de mot de passe.
*
* token_hash : hash SHA-256 du token envoyé par email — le token brut
* n'est jamais stocké en base pour limiter l'impact d'une fuite.
* expires_at : timestamp d'expiration (1 heure après création).
* used_at : timestamp de consommation — le token est invalidé après usage
* sans être supprimé immédiatement (traçabilité).
* user_id : CASCADE — supprime les tokens si l'utilisateur est supprimé.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
used_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
",
'down' => 'DROP TABLE IF EXISTS password_resets',
];

View File

@@ -1,27 +0,0 @@
<?php
/**
* Migration 007 — Table de protection contre le brute-force.
*
* Stocke les tentatives de connexion échouées par adresse IP.
* Le champ `locked_until` est NULL tant que le seuil n'est pas atteint,
* puis contient la date/heure ISO 8601 de fin de verrouillage.
*
* Le nettoyage des entrées expirées est effectué par LoginAttemptRepository
* à chaque tentative pour éviter l'accumulation de lignes obsolètes,
* sans nécessiter de tâche planifiée.
*/
return [
'up' => "
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT NOT NULL PRIMARY KEY,
attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT DEFAULT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
",
'down' => "
DROP TABLE IF EXISTS login_attempts;
",
];

View File

@@ -1,25 +0,0 @@
<?php
return [
'up' => "
DROP TRIGGER IF EXISTS posts_fts_users_delete;
DROP TRIGGER IF EXISTS posts_fts_users_update;
CREATE TRIGGER IF NOT EXISTS posts_fts_users_update
AFTER UPDATE OF username ON users BEGIN
DELETE FROM posts_fts
WHERE rowid IN (SELECT id FROM posts WHERE author_id = NEW.id);
INSERT INTO posts_fts(rowid, title, content, author_username)
SELECT p.id,
p.title,
COALESCE(strip_tags(p.content), ''),
NEW.username
FROM posts p
WHERE p.author_id = NEW.id;
END;
",
'down' => "
DROP TRIGGER IF EXISTS posts_fts_users_update;
",
];

View File

@@ -1,128 +1,86 @@
# Architecture # Architecture
> **Architecture cible après cleanup final** Slim Blog suit désormais une organisation verticale légère par domaine, avec quatre zones récurrentes :
>
> `Post/`, `Category/`, `User/`, `Media/` et `Auth/` suivent maintenant une organisation verticale
> `Application / Infrastructure / Http / Domain`. Les anciennes classes de transition
> ont été retirées : routes, conteneur DI et tests pointent directement vers les
> implémentations finales, ce qui réduit la dette de compatibilité tout en conservant
> les mêmes fonctionnalités.
## Domaines PHP - `Domain` : règles métier simples, politiques, objets de valeur utilitaires
- `Application` : orchestration des cas d'usage
- `Infrastructure` : PDO, filesystem local, services techniques concrets
- `Http` : contrôleurs et adaptation requête/réponse
Chaque domaine dans `src/` est autonome : modèle, interface de dépôt, implémentation du dépôt, Cette structure garde les mêmes fonctionnalités qu'au départ, mais réduit la dette de transition : routes, conteneur DI et tests pointent désormais directement vers les implémentations finales.
contrôleur. Les dépendances inter-domaines sont explicites, minimales et toujours unidirectionnelles
(voir §&nbsp;Dépendances inter-domaines ci-dessous).
| Domaine | Réutilisable | Notes | ## Vue d'ensemble
|-------------|:---:|------------------------------------------------------------------------------|
| `User/` | ✅ | Modèle utilisateur, persistance, création de comptes |
| `Auth/` | ✅ | Sessions, authentification, reset mot de passe — dépend de `User/` en lecture |
| `Category/` | ✅ | Générique — deux lignes à adapter si la table cible change |
| `Media/` | ✅ | Upload, déduplication, gestion des fichiers |
| `Shared/` | ✅ | Infrastructure complète — rien de métier |
| `Post/` | ➡ | Spécifique au blog — dépend de `Category/` en présentation. Remplacer par `Product/` pour une boutique |
### Dépendances inter-domaines | Domaine | Rôle principal | Dépendances métier |
|-------------|----------------|--------------------|
| `Auth/` | Connexion, sessions, réinitialisation de mot de passe | `User/` |
| `Category/` | Catégories éditoriales | — |
| `Media/` | Upload, stockage local, usage dans les articles | `Post/` pour le comptage d'usage |
| `Post/` | Articles, recherche, RSS | `Category/` en présentation |
| `User/` | Comptes, rôles, création/suppression | — |
| `Shared/` | Infrastructure transverse | — |
Le projet compte deux dépendances explicites entre domaines métier : ## Structure cible d'un domaine
**`Auth/ → User/`** — `AuthApplicationService` et `PasswordResetApplicationService` consomment `UserRepositoryInterface` ```text
pour lire les comptes lors de l'authentification et de la réinitialisation de mot de passe.
Unidirectionnelle : `User/` n'importe rien de `Auth/`.
**`Post/ → Category/`** — `PostController` injecte `CategoryServiceInterface` pour alimenter
la liste des catégories dans le formulaire de création/édition d'article. Dépendance de
présentation uniquement : `PostApplicationService` et `PdoPostRepository` ne connaissent pas `Category/`.
Unidirectionnelle : `Category/` n'importe rien de `Post/`.
### Structure d'un domaine
Ajouter un nouveau domaine suit toujours le même schéma :
```
src/MonDomaine/ src/MonDomaine/
├── Exception/ ← Exceptions métier spécifiques ├── Application/
│ └── MonErreurException.php │ └── MonDomaineApplicationService.php
├── MonEntite.php ← Modèle immuable ├── Domain/
├── MonEntiteRepositoryInterface.php ← Contrat de persistance │ └── RegleOuPolitique.php
├── MonEntiteRepository.php ← Implémentation PDO (implements l'interface) ├── Http/
── MonEntiteService.php ← Logique métier (dépend de l'interface) │ └── MonDomaineController.php
── MonEntiteController.php ← Actions HTTP (dépend du service) ── Infrastructure/
│ └── PdoMonDomaineRepository.php
├── MonEntite.php
├── MonDomaineRepositoryInterface.php
└── MonDomaineServiceInterface.php
``` ```
1. Créer les fichiers selon la structure ci-dessus Principes appliqués dans le projet :
2. Ajouter le binding interface → classe dans `config/container.php` :
`MonEntiteRepositoryInterface::class => autowire(MonEntiteRepository::class)`
(les services et contrôleurs dont toutes les dépendances sont typées sur des interfaces
sont résolus automatiquement par l'autowiring PHP-DI — aucune factory supplémentaire)
3. Déclarer les routes dans `Routes.php`
4. Ajouter la migration dans `database/migrations/`
### Interfaces de dépôts 1. Les contrôleurs HTTP dépendent d'interfaces de service (`*ServiceInterface`).
2. Les services applicatifs dépendent d'interfaces de repository et, si nécessaire, de ports techniques.
3. Les implémentations concrètes vivent en `Infrastructure/` et sont câblées dans `config/container.php`.
4. `Shared/` reste réservé au transverse : bootstrap, pagination, session, mail, sanitation HTML, utilitaires communs.
Chaque repository implémente son interface. Les services et contrôleurs dépendent uniquement de l'interface, jamais de la classe concrète : ## Dépendances inter-domaines
```php Les dépendances entre domaines métier restent limitées et unidirectionnelles :
// ✅ Correct — le service dépend de l'abstraction
final class PostService
{
public function __construct(
private readonly PostRepositoryInterface $postRepository,
) {}
}
// ❌ À éviter — couplage fort à l'implémentation - **`Auth/ → User/`** : `AuthApplicationService` et `PasswordResetApplicationService` lisent les comptes via `UserRepositoryInterface`.
final class PostService - **`Post/ → Category/`** : `Post\Http\PostController` injecte `CategoryServiceInterface` pour alimenter les formulaires et filtres.
{ - **`Media/ → Post/`** : `MediaApplicationService` utilise `PostRepositoryInterface` pour vérifier l'usage d'un média avant suppression.
public function __construct(
private readonly PostRepository $postRepository,
) {}
}
```
| Interface | Implémentation | Domaine | Aucun domaine ne dépend d'une implémentation concrète d'un autre domaine.
|------------------------------------|---------------------------|------------|
| `UserRepositoryInterface` | `PdoUserRepository` | `User/` |
| `UserServiceInterface` | `UserApplicationService` | `User/` |
| `LoginAttemptRepositoryInterface` | `PdoLoginAttemptRepository` | `Auth/` |
| `PasswordResetRepositoryInterface` | `PdoPasswordResetRepository` | `Auth/` |
| `PasswordResetServiceInterface` | `PasswordResetApplicationService` | `Auth/` |
| `AuthServiceInterface` | `AuthApplicationService` | `Auth/` |
| `PostRepositoryInterface` | `PdoPostRepository` | `Post/` |
| `PostServiceInterface` | `PostApplicationService` | `Post/` |
| `CategoryRepositoryInterface` | `PdoCategoryRepository` | `Category/`|
| `CategoryServiceInterface` | `CategoryApplicationService` | `Category/`|
| `MediaRepositoryInterface` | `PdoMediaRepository` | `Media/` |
| `MediaServiceInterface` | `MediaApplicationService` | `Media/` |
| `MediaStorageInterface` | `LocalMediaStorage` | `Media/` |
| `SessionManagerInterface` | `SessionManager` | `Shared/` |
| `MailServiceInterface` | `MailService` | `Shared/` |
| `FlashServiceInterface` | `FlashService` | `Shared/` |
### Exceptions métier ## Bindings DI principaux
Les erreurs métier levées intentionnellement utilisent des exceptions dédiées plutôt que des exceptions génériques, ce qui permet aux appelants de les distinguer sans analyser le message : | Interface | Implémentation |
|-----------|----------------|
| `AuthServiceInterface` | `Auth\Application\AuthApplicationService` |
| `PasswordResetServiceInterface` | `Auth\Application\PasswordResetApplicationService` |
| `LoginAttemptRepositoryInterface` | `Auth\Infrastructure\PdoLoginAttemptRepository` |
| `PasswordResetRepositoryInterface` | `Auth\Infrastructure\PdoPasswordResetRepository` |
| `CategoryServiceInterface` | `Category\Application\CategoryApplicationService` |
| `CategoryRepositoryInterface` | `Category\Infrastructure\PdoCategoryRepository` |
| `MediaServiceInterface` | `Media\Application\MediaApplicationService` |
| `MediaRepositoryInterface` | `Media\Infrastructure\PdoMediaRepository` |
| `MediaStorageInterface` | `Media\Infrastructure\LocalMediaStorage` |
| `PostServiceInterface` | `Post\Application\PostApplicationService` |
| `PostRepositoryInterface` | `Post\Infrastructure\PdoPostRepository` |
| `UserServiceInterface` | `User\Application\UserApplicationService` |
| `UserRepositoryInterface` | `User\Infrastructure\PdoUserRepository` |
| Exception | Namespace | Levée par | ## Schéma de base de données
|------------------------------|------------------------|--------------------------------------------------------|
| `DuplicateUsernameException` | `App\User\Exception` | `UserApplicationService::createUser()` |
| `DuplicateEmailException` | `App\User\Exception` | `UserApplicationService::createUser()` |
| `WeakPasswordException` | `App\User\Exception` | `UserApplicationService`, `AuthApplicationService`, `PasswordResetApplicationService` |
| `NotFoundException` | `App\Shared\Exception` | `PostApplicationService`, `AuthApplicationService` |
| `FileTooLargeException` | `App\Media\Exception` | `MediaApplicationService::store()` |
| `InvalidMimeTypeException` | `App\Media\Exception` | `MediaApplicationService::store()` |
| `StorageException` | `App\Media\Exception` | `MediaApplicationService::store()` |
## Base de données ```text
```
users users
├── id INTEGER PK AUTOINCREMENT ├── id INTEGER PK AUTOINCREMENT
├── username TEXT UNIQUE NOT NULL ├── username TEXT UNIQUE NOT NULL
├── email TEXT UNIQUE NOT NULL ├── email TEXT UNIQUE NOT NULL
├── password_hash TEXT NOT NULL ├── password_hash TEXT NOT NULL
├── role TEXT NOT NULL DEFAULT 'user' ← 'user' | 'editor' | 'admin' ├── role TEXT NOT NULL DEFAULT 'user'
└── created_at DATETIME └── created_at DATETIME
categories categories
@@ -144,307 +102,122 @@ media
├── id INTEGER PK AUTOINCREMENT ├── id INTEGER PK AUTOINCREMENT
├── filename TEXT NOT NULL ├── filename TEXT NOT NULL
├── url TEXT NOT NULL ├── url TEXT NOT NULL
├── hash TEXT UNIQUE NOT NULL ← SHA-256, détection des doublons ├── hash TEXT NOT NULL
├── user_id INTEGER → users(id) ON DELETE SET NULL ├── user_id INTEGER → users(id) ON DELETE SET NULL
└── created_at DATETIME └── created_at DATETIME
password_resets password_resets
├── id INTEGER PK AUTOINCREMENT ├── id INTEGER PK AUTOINCREMENT
├── user_id INTEGER → users(id) ON DELETE CASCADE ├── user_id INTEGER → users(id) ON DELETE CASCADE
├── token_hash TEXT UNIQUE NOT NULL ← SHA-256, token brut jamais stocké ├── token_hash TEXT UNIQUE NOT NULL
├── expires_at DATETIME NOT NULL ← 1 heure après création ├── expires_at DATETIME NOT NULL
├── used_at DATETIME ← NULL jusqu'à consommation ├── used_at DATETIME DEFAULT NULL
└── created_at DATETIME └── created_at DATETIME
posts_fts (table virtuelle FTS5, maintenue par triggers) login_attempts
├── title ├── ip TEXT PRIMARY KEY
├── content ← HTML strippé via strip_tags ├── attempts INTEGER NOT NULL DEFAULT 0
── author_username ── locked_until TEXT DEFAULT NULL
rowid = posts.id └── updated_at TEXT NOT NULL
login_attempts (protection brute-force — une ligne par IP)
├── ip TEXT PK ← adresse IP de l'appelant
├── attempts INTEGER NOT NULL ← compteur de tentatives échouées
├── locked_until TEXT DEFAULT NULL ← NULL tant que le seuil n'est pas atteint
└── updated_at TEXT NOT NULL ← mis à jour à chaque tentative
``` ```
**Index explicites** (intégrés dans les migrations 003 et 004) : ### Index utiles
| Index | Colonne | Justification | | Index | Colonne(s) | Usage |
|-------------------------|--------------------|----------------------------------------------------| |-------|------------|-------|
| `idx_posts_author_id` | `posts.author_id` | Filtre `findByUserId()` et recherche FTS par auteur | | `idx_posts_author_id` | `posts(author_id)` | listes admin / recherche auteur |
| `idx_media_user_id` | `media.user_id` | Filtre `findByUserId()` dans la galerie média | | `idx_media_user_id` | `media(user_id)` | galerie média par utilisateur |
| `idx_media_hash_user_id` | `media(hash, user_id)` | déduplication par utilisateur |
SQLite n'indexe pas automatiquement les colonnes de clé étrangère — seules `UNIQUE` et `PRIMARY KEY` le sont. Sans ces index, les requêtes filtrées par auteur/utilisateur effectuent un scan complet de la table. ## Flux principaux
**Configuration SQLite au démarrage** (dans `config/container.php`) : ### Création / mise à jour d'un article
``` 1. `Post\Http\PostController` lit la requête HTTP.
PRAGMA journal_mode = WAL → lectures non bloquées par les écritures 2. `PostApplicationService` valide, sanitise et génère un slug unique.
PRAGMA busy_timeout = 3000 → attend 3 s avant d'échouer sur contention 3. `PdoPostRepository` persiste en base.
PRAGMA synchronous = NORMAL → réduit les fsync en mode WAL (sûr) 4. Twig rend la réponse ou le contrôleur redirige avec un flash.
PRAGMA foreign_keys = ON → active l'application réelle des contraintes FK
```
> Sans `PRAGMA foreign_keys = ON`, les clauses `ON DELETE SET NULL / CASCADE` sont ### Upload d'un média
> enregistrées dans le schéma mais silencieusement ignorées par SQLite.
Les migrations s'exécutent automatiquement au démarrage (`Migrator::run()`) et ne sont jouées 1. `Media\Http\MediaController` reçoit le fichier et l'utilisateur courant.
qu'une fois (table `migrations` de suivi). `run()` appelle également `syncFtsIndex()` à chaque 2. `MediaApplicationService` valide le type et la taille, prépare l'upload.
démarrage : cette méthode insère dans `posts_fts` les articles dont le `rowid` est absent de 3. `LocalMediaStorage` convertit l'image en WebP et écrit le fichier.
l'index, sans toucher aux entrées existantes (idempotent). Elle corrige le cas où des articles 4. `PdoMediaRepository` persiste l'enregistrement.
auraient été insérés avant la création des triggers FTS5. Le provisionnement du compte 5. La déduplication se fait par couple `(hash, user_id)`.
administrateur est délégué à `Seeder::seed()` — appelé après `Migrator::run()` à chaque
démarrage (idempotent : sans effet si le compte existe déjà).
## Routes ### Réinitialisation de mot de passe
| Méthode | URL | Accès | Description | 1. `PasswordResetController` applique le rate limit par IP.
|---------|---------------------------------|---------------|------------------------------------------------| 2. `PasswordResetApplicationService` invalide les anciens tokens actifs, crée un nouveau token hashé et envoie l'e-mail.
| GET | `/` | Public | Accueil (`?categorie=`, `?q=`) | 3. La consommation du token est atomique via `PdoPasswordResetRepository`.
| GET | `/article/{slug}` | Public | Détail d'un article |
| GET | `/rss.xml` | Public | Flux RSS (20 derniers articles) |
| GET | `/auth/login` | Public | Formulaire de connexion |
| POST | `/auth/login` | Public | Traitement de la connexion |
| POST | `/auth/logout` | Public | Déconnexion |
| GET | `/password/forgot` | Public | Formulaire mot de passe oublié |
| POST | `/password/forgot` | Public | Envoi du lien de réinitialisation (rate-limited par IP) |
| GET | `/password/reset` | Public | Formulaire réinitialisation (token en query) |
| POST | `/password/reset` | Public | Traitement de la réinitialisation |
| GET | `/account/password` | Auth | Formulaire changement de mot de passe |
| POST | `/account/password` | Auth | Traitement du changement de mot de passe |
| GET | `/admin/posts` | Auth | Liste des articles |
| GET | `/admin/posts/edit/{id}` | Auth | Formulaire d'édition |
| POST | `/admin/posts/create` | Auth | Création d'un article |
| POST | `/admin/posts/edit/{id}` | Auth | Mise à jour d'un article |
| POST | `/admin/posts/delete/{id}` | Auth | Suppression d'un article |
| GET | `/admin/categories` | Editor+ | Gestion des catégories |
| POST | `/admin/categories/create` | Editor+ | Création d'une catégorie |
| POST | `/admin/categories/delete/{id}` | Editor+ | Suppression d'une catégorie |
| GET | `/admin/media` | Auth | Galerie de médias |
| POST | `/admin/media/upload` | Auth | Upload d'image (AJAX Trumbowyg) |
| POST | `/admin/media/delete/{id}` | Auth | Suppression d'un média |
| GET | `/admin/users` | Admin | Liste des utilisateurs |
| GET | `/admin/users/create` | Admin | Formulaire de création d'utilisateur |
| POST | `/admin/users/create` | Admin | Création d'un utilisateur |
| POST | `/admin/users/role/{id}` | Admin | Modification du rôle d'un utilisateur |
| POST | `/admin/users/delete/{id}` | Admin | Suppression d'un utilisateur |
## Arborescence ## Arborescence actuelle
``` ```text
src/ src/
├── Auth/ ├── Auth/
│ ├── Application/
│ ├── Domain/
│ ├── Http/
│ ├── Infrastructure/
│ ├── Middleware/ │ ├── Middleware/
│ │ ├── AdminMiddleware.php
│ │ ├── AuthMiddleware.php
│ │ └── EditorMiddleware.php
│ ├── AccountController.php
│ ├── AuthController.php
│ ├── AuthService.php
│ ├── AuthServiceInterface.php │ ├── AuthServiceInterface.php
│ ├── LoginAttemptRepository.php
│ ├── LoginAttemptRepositoryInterface.php │ ├── LoginAttemptRepositoryInterface.php
│ ├── PasswordResetController.php
│ ├── PasswordResetRepository.php
│ ├── PasswordResetRepositoryInterface.php │ ├── PasswordResetRepositoryInterface.php
│ ├── PasswordResetService.php
│ └── PasswordResetServiceInterface.php │ └── PasswordResetServiceInterface.php
├── Category/ ├── Category/
│ ├── Application/
│ ├── Domain/
│ ├── Http/
│ ├── Infrastructure/
│ ├── Category.php │ ├── Category.php
│ ├── CategoryController.php
│ ├── CategoryRepository.php
│ ├── CategoryRepositoryInterface.php │ ├── CategoryRepositoryInterface.php
│ ├── CategoryService.php
│ └── CategoryServiceInterface.php │ └── CategoryServiceInterface.php
├── Media/ ├── Media/
│ ├── Application/
│ ├── Domain/
│ ├── Exception/ │ ├── Exception/
│ ├── FileTooLargeException.php │ ├── Http/
│ ├── InvalidMimeTypeException.php │ ├── Infrastructure/
│ │ └── StorageException.php
│ ├── Media.php │ ├── Media.php
│ ├── MediaController.php
│ ├── MediaRepository.php
│ ├── MediaRepositoryInterface.php │ ├── MediaRepositoryInterface.php
│ └── MediaServiceInterface.php │ └── MediaServiceInterface.php
├── Post/ ├── Post/
│ ├── Post.php │ ├── Application/
│ ├── PostController.php │ ├── Domain/
│ ├── PostExtension.php │ ├── Http/
│ ├── PostRepository.php │ ├── Infrastructure/
│ ├── PostRepositoryInterface.php │ ├── Post.php
│ ├── PostService.php │ ├── PostRepositoryInterface.php
── PostServiceInterface.php ── PostServiceInterface.php
│ └── RssController.php ├── Shared/
├── Shared/ │ ├── Database/
│ ├── Database/Migrator.php │ ├── Exception/
│ ├── Database/Seeder.php │ ├── Extension/
│ ├── Exception/ │ ├── Html/
│ │ └── NotFoundException.php
│ ├── Extension/
│ │ ├── AppExtension.php
│ │ ├── CsrfExtension.php
│ │ └── SessionExtension.php
│ ├── Html/
│ │ ├── HtmlPurifierFactory.php
│ │ ├── HtmlSanitizer.php
│ │ └── HtmlSanitizerInterface.php
│ ├── Http/ │ ├── Http/
│ │ ├── FlashService.php
│ │ ├── FlashServiceInterface.php
│ │ ├── SessionManager.php
│ │ └── SessionManagerInterface.php
│ ├── Mail/ │ ├── Mail/
│ ├── MailService.php │ ├── Pagination/
│ │ └── MailServiceInterface.php
│ ├── Util/ │ ├── Util/
│ │ ├── DateParser.php ← Conversion DateTime depuis la base (Post, User, Media)
│ │ └── SlugHelper.php ← Génération de slug (Post, Category)
│ ├── Bootstrap.php │ ├── Bootstrap.php
│ ├── Config.php │ ├── Config.php
│ └── Routes.php │ └── Routes.php
├── User/
│ ├── Exception/
│ │ ├── DuplicateEmailException.php
│ │ ├── DuplicateUsernameException.php
│ │ └── WeakPasswordException.php
│ ├── User.php
│ ├── UserController.php
│ ├── UserRepository.php
│ ├── UserRepositoryInterface.php
│ ├── UserService.php
│ └── UserServiceInterface.php
config/
└── container.php ← Définitions PHP-DI (bindings + factories scalaires)
tests/
├── ControllerTestCase.php ← Classe de base abstraite (helpers PSR-7, assertions HTTP)
├── Auth/
│ ├── AccountControllerTest.php
│ ├── AuthControllerTest.php
│ ├── AuthServiceRateLimitTest.php
│ ├── AuthServiceTest.php
│ ├── LoginAttemptRepositoryTest.php
│ ├── PasswordResetControllerTest.php
│ ├── PasswordResetRepositoryTest.php
│ └── PasswordResetServiceTest.php
├── Category/
│ ├── CategoryControllerTest.php
│ ├── CategoryRepositoryTest.php
│ └── CategoryServiceTest.php
├── Media/
│ ├── MediaControllerTest.php
│ ├── MediaRepositoryTest.php
│ └── MediaServiceTest.php
├── Post/
│ ├── PostControllerTest.php
│ ├── PostRepositoryTest.php
│ ├── PostServiceTest.php
│ └── RssControllerTest.php
├── Shared/
│ ├── DateParserTest.php
│ ├── HtmlSanitizerTest.php
│ ├── SessionManagerTest.php
│ └── SlugHelperTest.php
└── User/ └── User/
├── UserControllerTest.php ├── Application/
├── UserRepositoryTest.php ├── Domain/
├── UserServiceTest.php ├── Exception/
── UserTest.php ── Http/
├── Infrastructure/
views/ ├── User.php
├── admin/ ├── UserRepositoryInterface.php
── categories/index.twig ── UserServiceInterface.php
│ ├── media/index.twig
│ ├── posts/{form,index}.twig
│ └── users/{form,index}.twig
├── emails/password-reset.twig
├── pages/
│ ├── account/password-change.twig
│ ├── auth/{login,password-forgot,password-reset}.twig
│ ├── post/detail.twig
│ ├── error.twig
│ └── home.twig
├── partials/
│ ├── _admin_nav.twig
│ ├── _header.twig
│ └── _footer.twig
└── layout.twig
public/
├── index.php # Point d'entrée HTTP
├── favicon.png
└── media/index.php # Renvoie 403
``` ```
## CSS ## Règles de maintenance
L'architecture **7-1/BEM** sépare trois niveaux de responsabilité : - Ajouter une nouvelle fonctionnalité dans le domaine concerné avant de créer un nouveau dossier partagé.
- Préférer une nouvelle classe d'application ciblée à une extension d'un contrôleur trop gros.
- **`abstracts/`** — design tokens centralisés. Modifier une variable propage le changement à l'ensemble du projet. Aucune valeur ne doit être codée en dur dans un composant. - Garder les interfaces aux frontières utiles : repository, session, mail, storage.
- **`components/`** — composants agnostiques du domaine, réutilisables sans modification dans n'importe quel contexte. - Éviter les wrappers de compatibilité temporaires une fois la migration terminée.
- **`pages/`** — surcharges contextuelles. Un composant peut s'afficher différemment selon la page sans être modifié. - Valider chaque lot avec PHPUnit et PHPStan avant de passer au suivant.
### Header
| Élément | Rôle |
|----------------------------|-------------------------------------------------------------|
| `.site-header` | Conteneur principal avec bordure basse |
| `.site-header__inner` | Flex row — logo à gauche, nav à droite |
| `.site-header__logo` | `<h1>` englobant le titre |
| `.site-header__logo-link` | Lien du titre — pas de soulignement, couleur héritée |
| `.site-header__nav` | Flex row des liens de navigation |
| `.site-header__user` | Nom de l'utilisateur connecté |
| `.site-header__action` | Élément d'action cliquable (lien ou bouton) |
### Boutons
| Classe | Rôle |
|-------------------|-----------------------------------------------------------|
| `.btn` | Base — padding, border-radius, display inline-block |
| `.btn--primary` | Fond bleu, texte blanc |
| `.btn--secondary` | Fond gris, texte blanc |
| `.btn--danger` | Fond rouge, texte blanc |
| `.btn--lg` | Padding agrandi — formulaires centrés |
| `.btn--full` | `width: 100%` |
| `.btn--sm` | Taille réduite — tableaux admin |
| `.btn-link` | Bloc autonome stylé comme un lien — déconnexion dans le header |
### Badges
| Classe | Usage |
|--------------------|------------------------------------------|
| `.badge--admin` | Rôle administrateur (fond jaune) |
| `.badge--editor` | Rôle éditeur (fond bleu clair) |
| `.badge--user` | Rôle utilisateur (texte gris) |
| `.badge--category` | Catégorie — cliquable, filtre la liste |
### Composant carte
`.card` structure visuellement n'importe quelle entité listable sans référence au domaine métier.
| Élément | Rôle |
|-------------------------|------------------------------------------------------|
| `.card-list` | Conteneur de liste |
| `.card-list--contained` | Fond grisé encadrant les cartes |
| `.card` | Carte — fond blanc, border-radius, box-shadow |
| `.card__thumb-link` | Lien englobant la vignette |
| `.card__thumb` | Vignette image |
| `.card__initials` | Vignette fallback (initiales) |
| `.card__content` | Wrapper flex colonne (corps + actions) |
| `.card__body` | Zone textuelle — contrainte en hauteur (vignette) |
| `.card__title` | Titre |
| `.card__title-link` | Lien du titre — pas de soulignement, underline au survol |
| `.card__meta` | Métadonnées (date, auteur…) |
| `.card__excerpt` | Texte court |
| `.card__actions` | Zone d'actions — toujours visible hors du clip |
### Autres composants notables
- `.category-filter` / `__item` / `__item--active` — barre de navigation par catégorie
- `.category-create` — bloc de création sur `/admin/categories`
- `.admin-table` — tableau de gestion (`__self` pour l'utilisateur courant, `__muted` pour les actions indisponibles, `__role-select` pour le sélecteur de rôle). En mobile, les lignes s'empilent en blocs via `data-label` sur chaque `<td>`.
- `.admin-actions` — cellule d'actions en flex row (desktop) / flex column (mobile)

View File

@@ -87,7 +87,7 @@ Le projet a un double objectif : fonctionner comme un vrai blog, et servir de ba
- Articles avec éditeur WYSIWYG, slugs stables et recherche full-text - Articles avec éditeur WYSIWYG, slugs stables et recherche full-text
- Catégories pour filtrer les articles - Catégories pour filtrer les articles
- Médias : téléversement d'images converties en WebP avec déduplication - Médias : téléversement d'images converties en WebP avec déduplication par utilisateur
- Comptes utilisateurs avec trois rôles : user, editor, admin - Comptes utilisateurs avec trois rôles : user, editor, admin
- Réinitialisation de mot de passe par e-mail - Réinitialisation de mot de passe par e-mail
- Flux RSS des 20 derniers articles - Flux RSS des 20 derniers articles
@@ -101,6 +101,9 @@ C'est précisément son intérêt. Le code ne se cache pas derrière des couches
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. 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.
> **Note de mise à jour architecture** — le code actuel utilise des classes concrètes nommées `*ApplicationService`, des contrôleurs sous `Http/` et des implémentations PDO sous `Infrastructure/`. Quelques exemples pédagogiques plus loin gardent volontairement les noms courts `PostService`, `UserService`, etc. pour alléger les explications ; ils correspondent respectivement à `PostApplicationService`, `UserApplicationService`, `MediaApplicationService`, `CategoryApplicationService`, `AuthApplicationService` et `PasswordResetApplicationService`.
**À 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. **À 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 ### 1.4 Interface de l'application
@@ -571,7 +574,7 @@ Contient les migrations SQL qui construisent le schéma de la base de données.
``` ```
database/ ← migrations SQL, une par table database/ ← migrations SQL, une par table
└── migrations/ ← exécutées dans l'ordre alphanumérique └── migrations/ ← exécutées dans l'ordre alphanumérique
├── 001_create_users.php ├── 001_create_schema.php
├── 002_create_categories.php ├── 002_create_categories.php
├── 003_create_posts.php ├── 003_create_posts.php
├── 004_create_media.php ├── 004_create_media.php
@@ -583,7 +586,7 @@ database/ ← migrations SQL, une par table
Chaque fichier retourne un tableau associatif avec les requêtes SQL. Voici la migration qui crée la table `users` : Chaque fichier retourne un tableau associatif avec les requêtes SQL. Voici la migration qui crée la table `users` :
```php ```php
// database/migrations/001_create_users.php // database/migrations/001_create_schema.php
return [ return [
'up' => " 'up' => "
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (

View File

@@ -7,8 +7,8 @@ namespace App\Category;
* Modèle représentant une catégorie d'articles. * Modèle représentant une catégorie d'articles.
* *
* Ce modèle est immuable après construction. * Ce modèle est immuable après construction.
* La génération de slug est déléguée à SlugHelper::generate() dans CategoryService, * La génération de slug est déléguée à CategorySlugGenerator dans
* avant la construction de l'objet. * CategoryApplicationService, avant la construction de l'objet.
*/ */
final class Category final class Category
{ {

View File

@@ -14,13 +14,13 @@ use DateTime;
* Ce modèle est immuable après construction. * Ce modèle est immuable après construction.
* Le nom d'auteur est dénormalisé (chargé par JOIN dans PostRepository) * Le nom d'auteur est dénormalisé (chargé par JOIN dans PostRepository)
* pour éviter des requêtes supplémentaires à l'affichage. * pour éviter des requêtes supplémentaires à l'affichage.
* La logique de présentation (excerpt, formatage) est déléguée à PostExtension. * La logique de présentation (excerpt, formatage) est déléguée à TwigPostExtension.
* *
* Distinction slug : * Distinction slug :
* - getStoredSlug() : slug lu depuis la base de données (canonique, peut comporter * - getStoredSlug() : slug lu depuis la base de données (canonique, peut comporter
* un suffixe numérique pour lever les collisions, ex: "mon-article-2") * un suffixe numérique pour lever les collisions, ex: "mon-article-2")
* - generateSlug() : slug calculé dynamiquement depuis le titre, utilisé uniquement * - generateSlug() : slug calculé dynamiquement depuis le titre, utilisé uniquement
* par PostService lors de la création/modification pour produire le slug à stocker * par PostApplicationService lors de la création/modification pour produire le slug à stocker
*/ */
final class Post final class Post
{ {
@@ -210,7 +210,7 @@ final class Post
/** /**
* Génère un slug URL-friendly calculé à partir du titre courant. * Génère un slug URL-friendly calculé à partir du titre courant.
* *
* Cette méthode est réservée à PostService pour produire le slug à stocker * Cette méthode est réservée à PostApplicationService pour produire le slug à stocker
* lors de la création ou de la modification d'un article. * lors de la création ou de la modification d'un article.
* Pour construire une URL publique, utiliser getStoredSlug() qui retourne * Pour construire une URL publique, utiliser getStoredSlug() qui retourne
* le slug canonique tel qu'il est enregistré en base de données. * le slug canonique tel qu'il est enregistré en base de données.

View File

@@ -14,7 +14,7 @@ use PDO;
* *
* Convention des fichiers de migration : * Convention des fichiers de migration :
* - Placés dans database/migrations/ * - Placés dans database/migrations/
* - Nommés NNN_description.php (ex: 001_create_users.php) * - Nommés NNN_description.php (ex: 001_create_schema.php)
* - Retournent un tableau ['up' => 'SQL...', 'down' => 'SQL...'] * - Retournent un tableau ['up' => 'SQL...', 'down' => 'SQL...']
* - Triés et exécutés par ordre alphanumérique croissant * - Triés et exécutés par ordre alphanumérique croissant
* *

View File

@@ -104,7 +104,7 @@ final class MigratorTest extends TestCase
Migrator::run($this->db); Migrator::run($this->db);
$count = $this->countMigrations(); $count = $this->countMigrations();
// Le projet a au moins une migration (001_create_users) // Le projet a au moins une migration de baseline (001_create_schema)
$this->assertGreaterThan(0, $count, 'Au moins une migration doit être enregistrée'); $this->assertGreaterThan(0, $count, 'Au moins une migration doit être enregistrée');
} }