Refatoring : Working state

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

View File

@@ -1,128 +1,86 @@
# 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.
Slim Blog suit désormais une organisation verticale légère par domaine, avec quatre zones récurrentes :
## 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,
contrôleur. Les dépendances inter-domaines sont explicites, minimales et toujours unidirectionnelles
(voir § Dépendances inter-domaines ci-dessous).
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.
| 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 |
## Vue d'ensemble
### 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`
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 :
```
```text
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)
├── Application/
│ └── MonDomaineApplicationService.php
├── Domain/
│ └── RegleOuPolitique.php
├── Http/
│ └── MonDomaineController.php
── Infrastructure/
│ └── PdoMonDomaineRepository.php
├── MonEntite.php
├── MonDomaineRepositoryInterface.php
└── MonDomaineServiceInterface.php
```
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/`
Principes appliqués dans le projet :
### 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
// ✅ Correct — le service dépend de l'abstraction
final class PostService
{
public function __construct(
private readonly PostRepositoryInterface $postRepository,
) {}
}
Les dépendances entre domaines métier restent limitées et unidirectionnelles :
// ❌ À éviter — couplage fort à l'implémentation
final class PostService
{
public function __construct(
private readonly PostRepository $postRepository,
) {}
}
```
- **`Auth/ → User/`** : `AuthApplicationService` et `PasswordResetApplicationService` lisent les comptes via `UserRepositoryInterface`.
- **`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.
| 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/` |
Aucun domaine ne dépend d'une implémentation concrète d'un autre domaine.
### 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 |
|------------------------------|------------------------|--------------------------------------------------------|
| `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()` |
## Schéma de base de données
## Base de données
```
```text
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'
├── role TEXT NOT NULL DEFAULT 'user'
└── created_at DATETIME
categories
@@ -144,307 +102,122 @@ media
├── id INTEGER PK AUTOINCREMENT
├── filename 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
└── 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
├── token_hash TEXT UNIQUE NOT NULL
├── expires_at DATETIME NOT NULL
├── used_at DATETIME DEFAULT NULL
└── 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
login_attempts
├── ip TEXT PRIMARY KEY
├── attempts INTEGER NOT NULL DEFAULT 0
── locked_until TEXT DEFAULT NULL
└── updated_at TEXT NOT NULL
```
**Index explicites** (intégrés dans les migrations 003 et 004) :
### Index utiles
| 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 |
| Index | Colonne(s) | Usage |
|-------|------------|-------|
| `idx_posts_author_id` | `posts(author_id)` | listes admin / recherche auteur |
| `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
```
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
```
1. `Post\Http\PostController` lit la requête HTTP.
2. `PostApplicationService` valide, sanitise et génère un slug unique.
3. `PdoPostRepository` persiste en base.
4. Twig rend la réponse ou le contrôleur redirige avec un flash.
> 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.
### Upload d'un média
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à).
1. `Media\Http\MediaController` reçoit le fichier et l'utilisateur courant.
2. `MediaApplicationService` valide le type et la taille, prépare l'upload.
3. `LocalMediaStorage` convertit l'image en WebP et écrit le fichier.
4. `PdoMediaRepository` persiste l'enregistrement.
5. La déduplication se fait par couple `(hash, user_id)`.
## Routes
### Réinitialisation de mot de passe
| 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 |
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.
3. La consommation du token est atomique via `PdoPasswordResetRepository`.
## Arborescence
## Arborescence actuelle
```
```text
src/
├── Auth/
│ ├── Application/
│ ├── Domain/
│ ├── Http/
│ ├── Infrastructure/
│ ├── 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/
│ ├── Application/
│ ├── Domain/
│ ├── Http/
│ ├── Infrastructure/
│ ├── Category.php
│ ├── CategoryController.php
│ ├── CategoryRepository.php
│ ├── CategoryRepositoryInterface.php
│ ├── CategoryService.php
│ └── CategoryServiceInterface.php
├── Media/
│ ├── Application/
│ ├── Domain/
│ ├── Exception/
│ ├── FileTooLargeException.php
│ ├── InvalidMimeTypeException.php
│ │ └── StorageException.php
│ ├── Http/
│ ├── Infrastructure/
│ ├── 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
│ ├── Application/
│ ├── Domain/
│ ├── Http/
│ ├── Infrastructure/
│ ├── Post.php
│ ├── PostRepositoryInterface.php
── PostServiceInterface.php
├── Shared/
│ ├── Database/
│ ├── Exception/
│ ├── Extension/
│ ├── Html/
│ ├── Http/
│ │ ├── FlashService.php
│ │ ├── FlashServiceInterface.php
│ │ ├── SessionManager.php
│ │ └── SessionManagerInterface.php
│ ├── Mail/
│ ├── MailService.php
│ │ └── MailServiceInterface.php
│ ├── Pagination/
│ ├── 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
├── Application/
├── Domain/
├── Exception/
── Http/
├── Infrastructure/
├── User.php
├── UserRepositoryInterface.php
── UserServiceInterface.php
```
## CSS
## Règles de maintenance
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)
- 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.
- Garder les interfaces aux frontières utiles : repository, session, mail, storage.
- Éviter les wrappers de compatibilité temporaires une fois la migration terminée.
- Valider chaque lot avec PHPUnit et PHPStan avant de passer au suivant.

View File

@@ -87,7 +87,7 @@ Le projet a un double objectif : fonctionner comme un vrai blog, et servir de ba
- Articles avec éditeur WYSIWYG, slugs stables et recherche full-text
- 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
- Réinitialisation de mot de passe par e-mail
- 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.
> **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.
### 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
└── migrations/ ← exécutées dans l'ordre alphanumérique
├── 001_create_users.php
├── 001_create_schema.php
├── 002_create_categories.php
├── 003_create_posts.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` :
```php
// database/migrations/001_create_users.php
// database/migrations/001_create_schema.php
return [
'up' => "
CREATE TABLE IF NOT EXISTS users (