Refatoring : Working state
This commit is contained in:
18
README.md
18
README.md
@@ -3,18 +3,18 @@
|
|||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
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`.
|
||||||
|
|||||||
79
database/migrations/001_create_schema.php
Normal file
79
database/migrations/001_create_schema.php
Normal 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;
|
||||||
|
",
|
||||||
|
];
|
||||||
@@ -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',
|
|
||||||
];
|
|
||||||
@@ -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',
|
|
||||||
];
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
",
|
|
||||||
];
|
|
||||||
@@ -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;
|
|
||||||
",
|
|
||||||
];
|
|
||||||
@@ -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',
|
|
||||||
];
|
|
||||||
@@ -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;
|
|
||||||
",
|
|
||||||
];
|
|
||||||
@@ -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;
|
|
||||||
",
|
|
||||||
];
|
|
||||||
@@ -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 § 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)
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user