23 KiB
Architecture
Refactor DDD légère — lots 1 à 4
Post/,Category/,User/,Media/etAuth/introduisent maintenant une organisation verticaleApplication / Infrastructure / Http / Domainpour alléger la lecture et préparer un découpage plus fin par cas d'usage. Les classes historiques à la racine du domaine sont conservées comme ponts de compatibilité afin de préserver les routes, le conteneur DI et la suite de tests pendant la transition.
Domaines PHP
Chaque domaine dans src/ est autonome : modèle, interface de dépôt, implémentation du dépôt,
contrôleur. Les dépendances inter-domaines sont explicites, minimales et toujours unidirectionnelles
(voir § Dépendances inter-domaines ci-dessous).
| Domaine | Réutilisable | Notes |
|---|---|---|
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
Le projet compte deux dépendances explicites entre domaines métier :
Auth/ → User/ — AuthService et PasswordResetService consomment UserRepositoryInterface
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 : PostService et PostRepository 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/
├── Exception/ ← Exceptions métier spécifiques
│ └── MonErreurException.php
├── MonEntite.php ← Modèle immuable
├── MonEntiteRepositoryInterface.php ← Contrat de persistance
├── MonEntiteRepository.php ← Implémentation PDO (implements l'interface)
├── MonEntiteService.php ← Logique métier (dépend de l'interface)
└── MonEntiteController.php ← Actions HTTP (dépend du service)
- Créer les fichiers selon la structure ci-dessus
- 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) - Déclarer les routes dans
Routes.php - Ajouter la migration dans
database/migrations/
Interfaces de dépôts
Chaque repository implémente son interface. Les services et contrôleurs dépendent uniquement de l'interface, jamais de la classe concrète :
// ✅ Correct — le service dépend de l'abstraction
final class PostService
{
public function __construct(
private readonly PostRepositoryInterface $postRepository,
) {}
}
// ❌ À éviter — couplage fort à l'implémentation
final class PostService
{
public function __construct(
private readonly PostRepository $postRepository,
) {}
}
| Interface | Implémentation | Domaine |
|---|---|---|
UserRepositoryInterface |
PdoUserRepository |
User/ |
UserServiceInterface |
UserApplicationService |
User/ |
LoginAttemptRepositoryInterface |
PdoLoginAttemptRepository |
Auth/ |
PasswordResetRepositoryInterface |
PdoPasswordResetRepository |
Auth/ |
PasswordResetServiceInterface |
PasswordResetApplicationService |
Auth/ |
AuthServiceInterface |
AuthApplicationService |
Auth/ |
PostRepositoryInterface |
PostRepository |
Post/ |
PostServiceInterface |
PostService |
Post/ |
CategoryRepositoryInterface |
CategoryRepository |
Category/ |
CategoryServiceInterface |
CategoryService |
Category/ |
MediaRepositoryInterface |
PdoMediaRepository |
Media/ |
MediaServiceInterface |
MediaApplicationService |
Media/ |
MediaStorageInterface |
LocalMediaStorage |
Media/ |
SessionManagerInterface |
SessionManager |
Shared/ |
MailServiceInterface |
MailService |
Shared/ |
FlashServiceInterface |
FlashService |
Shared/ |
Exceptions métier
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 :
| Exception | Namespace | Levée par |
|---|---|---|
DuplicateUsernameException |
App\User\Exception |
UserService::createUser() |
DuplicateEmailException |
App\User\Exception |
UserService::createUser() |
WeakPasswordException |
App\User\Exception |
UserService, AuthService, PasswordResetService |
NotFoundException |
App\Shared\Exception |
PostService, AuthService |
FileTooLargeException |
App\Media\Exception |
MediaService::store() |
InvalidMimeTypeException |
App\Media\Exception |
MediaService::store() |
StorageException |
App\Media\Exception |
MediaService::store() |
Base de données
users
├── id INTEGER PK AUTOINCREMENT
├── username TEXT UNIQUE NOT NULL
├── email TEXT UNIQUE NOT NULL
├── password_hash TEXT NOT NULL
├── role TEXT NOT NULL DEFAULT 'user' ← 'user' | 'editor' | 'admin'
└── created_at DATETIME
categories
├── id INTEGER PK AUTOINCREMENT
├── name TEXT UNIQUE NOT NULL
└── slug TEXT UNIQUE NOT NULL
posts
├── id INTEGER PK AUTOINCREMENT
├── title TEXT NOT NULL
├── content TEXT NOT NULL
├── slug TEXT UNIQUE NOT NULL
├── author_id INTEGER → users(id) ON DELETE SET NULL
├── category_id INTEGER → categories(id) ON DELETE SET NULL
├── created_at DATETIME
└── updated_at DATETIME
media
├── id INTEGER PK AUTOINCREMENT
├── filename TEXT NOT NULL
├── url TEXT NOT NULL
├── hash TEXT UNIQUE NOT NULL ← SHA-256, détection des doublons
├── user_id INTEGER → users(id) ON DELETE SET NULL
└── created_at DATETIME
password_resets
├── id INTEGER PK AUTOINCREMENT
├── user_id INTEGER → users(id) ON DELETE CASCADE
├── token_hash TEXT UNIQUE NOT NULL ← SHA-256, token brut jamais stocké
├── expires_at DATETIME NOT NULL ← 1 heure après création
├── used_at DATETIME ← NULL jusqu'à consommation
└── created_at DATETIME
posts_fts (table virtuelle FTS5, maintenue par triggers)
├── title
├── content ← HTML strippé via strip_tags
└── author_username
rowid = posts.id
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 | Colonne | Justification |
|---|---|---|
idx_posts_author_id |
posts.author_id |
Filtre findByUserId() et recherche FTS par auteur |
idx_media_user_id |
media.user_id |
Filtre findByUserId() dans la galerie média |
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.
Configuration SQLite au démarrage (dans config/container.php) :
PRAGMA journal_mode = WAL → lectures non bloquées par les écritures
PRAGMA busy_timeout = 3000 → attend 3 s avant d'échouer sur contention
PRAGMA synchronous = NORMAL → réduit les fsync en mode WAL (sûr)
PRAGMA foreign_keys = ON → active l'application réelle des contraintes FK
Sans
PRAGMA foreign_keys = ON, les clausesON DELETE SET NULL / CASCADEsont 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
qu'une fois (table migrations de suivi). run() appelle également syncFtsIndex() à chaque
démarrage : cette méthode insère dans posts_fts les articles dont le rowid est absent de
l'index, sans toucher aux entrées existantes (idempotent). Elle corrige le cas où des articles
auraient été insérés avant la création des triggers FTS5. Le provisionnement du compte
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
| Méthode | URL | Accès | Description |
|---|---|---|---|
| GET | / |
Public | Accueil (?categorie=, ?q=) |
| 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
src/
├── Auth/
│ ├── Middleware/
│ │ ├── AdminMiddleware.php
│ │ ├── AuthMiddleware.php
│ │ └── EditorMiddleware.php
│ ├── AccountController.php
│ ├── AuthController.php
│ ├── AuthService.php
│ ├── AuthServiceInterface.php
│ ├── LoginAttemptRepository.php
│ ├── LoginAttemptRepositoryInterface.php
│ ├── PasswordResetController.php
│ ├── PasswordResetRepository.php
│ ├── PasswordResetRepositoryInterface.php
│ ├── PasswordResetService.php
│ └── PasswordResetServiceInterface.php
├── Category/
│ ├── Category.php
│ ├── CategoryController.php
│ ├── CategoryRepository.php
│ ├── CategoryRepositoryInterface.php
│ ├── CategoryService.php
│ └── CategoryServiceInterface.php
├── Media/
│ ├── Exception/
│ │ ├── FileTooLargeException.php
│ │ ├── InvalidMimeTypeException.php
│ │ └── StorageException.php
│ ├── Media.php
│ ├── MediaController.php
│ ├── MediaRepository.php
│ ├── MediaRepositoryInterface.php
│ ├── MediaService.php
│ └── MediaServiceInterface.php
├── Post/
│ ├── Post.php
│ ├── PostController.php
│ ├── PostExtension.php
│ ├── PostRepository.php
│ ├── PostRepositoryInterface.php
│ ├── PostService.php
│ ├── PostServiceInterface.php
│ └── RssController.php
├── Shared/
│ ├── Database/Migrator.php
│ ├── Database/Seeder.php
│ ├── Exception/
│ │ └── NotFoundException.php
│ ├── Extension/
│ │ ├── AppExtension.php
│ │ ├── CsrfExtension.php
│ │ └── SessionExtension.php
│ ├── Html/
│ │ ├── HtmlPurifierFactory.php
│ │ ├── HtmlSanitizer.php
│ │ └── HtmlSanitizerInterface.php
│ ├── Http/
│ │ ├── FlashService.php
│ │ ├── FlashServiceInterface.php
│ │ ├── SessionManager.php
│ │ └── SessionManagerInterface.php
│ ├── Mail/
│ │ ├── MailService.php
│ │ └── MailServiceInterface.php
│ ├── Util/
│ │ ├── DateParser.php ← Conversion DateTime depuis la base (Post, User, Media)
│ │ └── SlugHelper.php ← Génération de slug (Post, Category)
│ ├── Bootstrap.php
│ ├── Config.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/
├── UserControllerTest.php
├── UserRepositoryTest.php
├── UserServiceTest.php
└── UserTest.php
views/
├── admin/
│ ├── categories/index.twig
│ ├── 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
L'architecture 7-1/BEM sépare trois niveaux de responsabilité :
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.components/— composants agnostiques du domaine, réutilisables sans modification dans n'importe quel contexte.pages/— surcharges contextuelles. Un composant peut s'afficher différemment selon la page sans être modifié.
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 (__selfpour l'utilisateur courant,__mutedpour les actions indisponibles,__role-selectpour le sélecteur de rôle). En mobile, les lignes s'empilent en blocs viadata-labelsur chaque<td>..admin-actions— cellule d'actions en flex row (desktop) / flex column (mobile)