On audite un MessageController Symfony. Le CRUD a l'air classique —
create, update, delete, list. On commence à relire le code, à noter les problèmes
de sécurité, les fat controllers, les validations manquantes. Et puis on tombe sur
l'entité Message.
Le champ broadcast est un string avec les valeurs
"Y" et "N". Le champ type est un
int sans enum, avec des magic numbers dispersés dans le controller.
Le champ title dans le code PHP s'appelle titre dans
la base. Et là, la vraie question se pose : est-ce qu'on audite le controller
ou est-ce qu'on audite l'entité ?
Si on corrige le controller maintenant, on va écrire du code qui manipule un
broadcast === "Y". Quand on corrigera l'entité plus tard pour
passer en bool, il faudra revenir toucher ce controller. Le même
code, audité deux fois, modifié deux fois. C'est exactement ce qu'on veut éviter.
Les trois ordres possibles
Face à un projet Symfony legacy avec 15 entités, 20 controllers, une poignée de services et de listeners, il y a trois approches pour organiser l'audit. Aucune n'est universellement mauvaise — mais une seule évite le piège du retravail systématique.
Top-down : controllers d'abord
On commence par les controllers. C'est l'approche intuitive — on suit le flux
HTTP, on voit ce que l'utilisateur voit. On découvre les problèmes de sécurité,
les @IsGranted manquants, les fat controllers qui font 300 lignes
avec du métier dedans.
L'avantage : c'est concret. On repère vite quelles entités sont sous-utilisées,
quels services sont surdimensionnés. L'inconvénient : chaque controller qu'on
audite nous ramène aux entités. On tombe sur un "Y"/"N" ici, un
type int sans signification là, un nullable abusif ailleurs. On
passe notre temps à noter "à corriger dans l'entité" sans jamais corriger.
// Ce qu'on trouve dans le controller
if ($message->getBroadcast() === 'Y') {
$this->notificationService->sendToAll($message);
}
// Ce qu'on voudrait écrire
if ($message->isBroadcast()) {
$this->notificationService->sendToAll($message);
}
Mais on ne peut pas écrire la deuxième version tant que l'entité n'a pas changé. Donc on laisse la première. Et quand on corrigera l'entité, on devra revenir toucher ce controller.
Vertical slice : par feature de bout en bout
On prend une feature — disons la gestion des messages — et on audite tout en
même temps : l'entité Message, le MessageController,
le MessageType, le template Twig. Feature complète, livrée, on
passe à la suivante.
C'est l'approche qui ressemble le plus à une vraie PR de review. Chaque slice est un livrable cohérent. Sur un projet mature avec des conventions déjà posées, c'est excellent.
Sur un projet legacy où les conventions n'existent pas encore, c'est
un piège. Parce que les règles transversales — "toutes les entités cliniques
doivent avoir SoftDeleteable", "les Y/N deviennent des
bool", "chaque entité auditable a Loggable +
Versioned" — ces règles-là, on les découvre au fil de l'eau. Et
chaque slice devient une discussion : "Est-ce qu'on met Loggable ici aussi ?
Ah oui, comme sur l'entité d'avant." Multiplié par 15 entités, ça fatigue.
Bottom-up : entités d'abord
On commence par le modèle de données. On parcourt les 15 entités, on aligne
tout : types, conventions de nommage, traits Doctrine (Loggable,
SoftDeleteable, Timestampable), assertions de
validation, enums. On stabilise les fondations. Ensuite seulement, on remonte
vers les services, les listeners, les controllers.
C'est l'approche la moins sexy. Aucun livrable visible pendant la première phase. On passe du temps "dans le noir", à corriger des types et ajouter des traits sur des entités. Personne ne voit la différence côté utilisateur.
Mais quand on arrive aux controllers, tout est propre en dessous. Un
broadcast est un bool. Un type est un
enum. Un title s'appelle title partout.
On audite le contrôle de flux, la sécurité, le découpage — pas des mismatches
d'impédance entre le controller et l'entité.
Pourquoi le bottom-up gagne sur du legacy
Le raisonnement tient en une phrase : le modèle de données dicte tout le
reste. Les validations Symfony (@Assert) vivent sur
l'entité. Le typage des form types dépend du typage de l'entité. Les conditions
dans les controllers reflètent les états possibles de l'entité. Si le modèle est
bancal, tout ce qui est au-dessus est bancal.
Sur un projet legacy, les entités accumulent de la dette technique silencieuse.
C'est rarement spectaculaire — pas de bug visible, pas de crash. C'est juste
que le champ status est un int au lieu d'un enum, que
deletedAt n'existe pas parce que les suppressions sont en dur, que
la moitié des champs sont nullable sans raison.
Et cette dette se propage. Le controller qui teste $status === 3
au lieu de $status === Status::ARCHIVED n'est pas un problème de
controller — c'est un problème d'entité qui a contaminé le controller.
L'argument des migrations
"Mais modifier les entités, ça veut dire des migrations Doctrine." Oui. Et c'est plus facile qu'on ne le croit, à condition de s'y prendre tôt.
Si le projet est en phase de refacto (pas encore en production, ou avec une base dev qu'on peut reconstruire), les migrations sont gratuites. Si le projet est en production, les migrations sont nécessaires de toute façon — la question n'est pas "est-ce qu'on migre", c'est "est-ce qu'on migre maintenant de manière contrôlée ou plus tard en urgence quand un bug de type nous explose à la figure".
Le piège classique du top-down
Voici le scénario typique. On audite PatientController. On trouve
des problèmes de sécurité — @IsGranted manquant, pas de
vérification que le patient appartient au bon cabinet. On corrige. Bien.
Puis on tombe sur l'entité Patient. Pas de
SoftDeleteable — les patients sont supprimés en dur, ce qui est
illégal dans un contexte médical. Le champ gender est un
string libre au lieu d'un enum. Les dates de naissance sont des
string au format "DD/MM/YYYY".
// Entité Patient — avant audit
#[ORM\Column(type: 'string', length: 10)]
private ?string $gender = null;
#[ORM\Column(type: 'string', length: 10)]
private ?string $birthDate = null;
// Entité Patient — après audit
#[ORM\Column(type: 'string', enumType: Gender::class)]
private Gender $gender;
#[ORM\Column(type: 'date')]
private \DateTimeInterface $birthDate;
Ce changement d'entité casse le controller qu'on vient d'auditer. Les
comparaisons $patient->getGender() === 'M' ne compilent plus.
Les formatages de date dans le template ne fonctionnent plus. Il faut y retourner.
Le bottom-up évite ce scénario : quand on arrive au controller, l'entité est déjà propre.
La méthode en deux temps
Le bottom-up pur a un défaut : si on audite les entités sans aucune idée de
comment elles sont utilisées, on risque de sur-ingénierer. Ajouter
Loggable sur une entité de configuration technique qui change une
fois par an, c'est du bruit.
La solution, c'est un pré-audit rapide. Pas un audit complet — un inventaire.
Phase 0 : inventaire des entités (30 minutes)
On parcourt src/Entity/. Pour chaque entité, une ligne :
ce qui manque, ce qui est incohérent, quel est le contexte métier.
| Entité | Problèmes structurels | Priorité |
|---|---|---|
Patient |
Pas SoftDelete, gender string, birthDate string | Haute |
Message |
broadcast Y/N, type int sans enum, title/titre | Moyenne |
User |
Manque Loggable, roles en JSON non typé | Haute |
Config |
Entité technique — OK en l'état | Basse |
Study |
Pas Versioned, status int, nullable abusif | Haute |
Cet inventaire prend 30 minutes et change tout. On sait quelles entités sont critiques, lesquelles peuvent attendre, et surtout — on détecte les patterns transversaux avant d'écrire la moindre ligne de code.
Phase 1 : audit des entités
On attaque les entités une par une, par ordre de priorité. Pour chaque entité, le checklist est le même :
- Types :
string→enum,int→enum,stringdate →DateTimeInterface,"Y"/"N"→bool - Traits Doctrine :
Loggable+Versionedsur les entités métier,SoftDeleteablesur les entités cliniques,Timestampablepartout - Nullabilité : un champ
nullabledoit l'être pour une raison métier, pas par défaut - Assertions :
@Assert\NotBlank,@Assert\Length,@Assert\Choice— la validation vit sur l'entité, pas dans le controller - Nommage : cohérence entre le nom PHP et le nom de colonne
// Avant : l'entité accumule de la dette silencieuse
#[ORM\Entity]
class Message
{
#[ORM\Column(type: 'string', length: 1)]
private ?string $broadcast = 'N';
#[ORM\Column(type: 'integer')]
private ?int $type = null;
#[ORM\Column(name: 'titre', type: 'string', length: 255)]
private ?string $title = null;
}
// Après : le modèle exprime le métier
#[ORM\Entity]
#[Gedmo\Loggable]
#[Gedmo\SoftDeleteable(fieldName: 'deletedAt')]
class Message
{
#[ORM\Column]
private bool $broadcast = false;
#[ORM\Column(type: 'string', enumType: MessageType::class)]
private MessageType $type;
#[ORM\Column(length: 255)]
#[Assert\NotBlank]
private string $title;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
}
Phases 2, 3, 4 : services → controllers → templates
Une fois les entités stabilisées, on remonte couche par couche. Les services
et listeners d'abord — AccessControlListener,
PatientDataHistoryListener — parce qu'ils dépendent directement
des entités. Puis les controllers, qui maintenant manipulent des types propres.
Enfin les templates, avec des données correctement typées.
À chaque couche, l'audit se concentre sur les vrais problèmes de cette couche : sécurité et découpage pour les controllers, logique métier pour les services, affichage et UX pour les templates. On ne débat plus du typage — c'est réglé.
Quand le vertical slice est le bon choix
Le bottom-up n'est pas toujours la réponse. Si le projet a déjà des conventions solides — types propres, traits Doctrine en place, validation cohérente — alors le vertical slice est supérieur. On clôture une feature de bout en bout, on livre, on passe à la suivante. C'est le workflow naturel d'une équipe qui fait de la review PR.
La question à se poser : est-ce que les conventions existent ou est-ce qu'on est en train de les poser ? Si les conventions existent, vertical slice. Si on les découvre au fur et à mesure de l'audit, bottom-up. Poser les règles transversales une seule fois, proprement, sur toutes les entités — puis laisser les controllers s'y conformer mécaniquement.
Conclusion
Le réflexe naturel sur un audit legacy, c'est de commencer par le visible : le controller, la route HTTP, ce que l'utilisateur voit. C'est satisfaisant mais c'est un piège. On finit par auditer des façades posées sur des fondations qu'on va modifier.
Sur un projet Symfony legacy, les entités Doctrine sont les fondations.
Les stabiliser en premier, c'est se donner le droit d'auditer le reste une seule
fois. C'est moins gratifiant au début — personne n'applaudit un trait
Loggable ajouté sur 10 entités — mais c'est la seule approche où
le travail ne se refait pas.
L'inventaire de 30 minutes avant de commencer, c'est ce qui transforme un audit sans fin en un plan d'exécution. On sait ce qu'on va toucher, dans quel ordre, et pourquoi.