Files
slim-blog/docs/ARCHITECTURE.md
2026-03-16 15:16:56 +01:00

23 KiB

Architecture

Architecture cible après cleanup final

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

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/AuthApplicationService et PasswordResetApplicationService 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 : 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/
├── 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 :

// ✅ 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 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

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 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

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
│   └── 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 (__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)