# Architecture > **Refactor DDD légère — lots 1 à 3** > > `Post/`, `Category/`, `User/` et `Media/` introduisent maintenant une organisation verticale > `Application / Infrastructure / Http / Domain` pour 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) ``` 1. Créer les fichiers selon la structure ci-dessus 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 Chaque repository implémente son interface. Les services et contrôleurs dépendent uniquement de l'interface, jamais de la classe concrète : ```php // ✅ 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` | `LoginAttemptRepository` | `Auth/` | | `PasswordResetRepositoryInterface` | `PasswordResetRepository` | `Auth/` | | `PasswordResetServiceInterface` | `PasswordResetService` | `Auth/` | | `AuthServiceInterface` | `AuthService` | `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 clauses `ON DELETE SET NULL / CASCADE` sont > 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` | `

` 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 ``. - `.admin-actions` — cellule d'actions en flex row (desktop) / flex column (mobile)