Refatoring : Working state
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user