Épisode 4/6
EasyAdmin
- 01 C'est parti pour installer EasyAdmin
- 02 EasyAdmin : construire un menu admin qui tient la route
- 03 EasyAdmin : son premier CrudController
- 04 EasyAdmin : quand un seul trait configure quatre CRUDs
- 05 EasyAdmin : sécuriser un admin sans toucher à setPermission() Programmé
- 06 EasyAdmin : écrire un filtre custom du FilterInterface au FilterType Programmé
Nous avons déjà vu l'installation, le menu, et le premier CrudController sur une entité simple — RedirectRule, sept champs scalaires, zéro relation. On sait écrire un CRUD honnête sur une entité plate. Ce qui change maintenant : quatre entités du projet (Post, Page, Category, Series) partagent leur ossature — titre, slug, statut, planification, SEO bases, Schema.org. Sans précaution, ce sont des lignes de configureFields() dupliquées par CRUD et un coût de divergence qui finit toujours par taper.
À la fin du billet, on saura organiser un configureFields() long sans qu'il devienne un mur de code. Au programme : structurer en tabs et fieldsets via FormField, extraire les sections récurrentes dans un trait partagé, assumer une branche conditionnelle quand une entité a des champs propres (Post a category, series, episodePosition ; les trois autres non), et choisir le bon helper de visibilité par contexte (onlyOnForms, onlyOnIndex, hideOnIndex, hideOnForm, hideWhenCreating).
Le problème : quatre CRUDs, une même ossature
Sur mon site, quatre entités de contenu vivent côte à côte : Post, Page, Category, Series. Elles ne sont pas équivalentes — un Post a une catégorie, une série, un niveau technique ; une Page est statique ; une Category est un nœud de taxonomie ; une Series est un container d'épisodes. Mais elles partagent un ADN éditorial :
- un statut (
DRAFT/SCHEDULED/ONLINE) géré par le même enum - un slug auto-généré depuis le titre, modifiable avec création de redirection 301 en option
- une planification (
scheduledPublishAt,scheduledUnpublishAt) - des SEO bases identiques (titre, description, URL canonique, mots-clés, image OG)
- une configuration Schema.org (type, JSON-LD custom, blocs code snippets)
Quatre tabs identiques sur quatre CRUDs. Si on les écrit séparément, on fait beaucoup de lignes de copier-coller. La prochaine modification — un setHelp qui se précise, un libellé qu'on harmonise — divergera silencieusement sur un des quatre fichiers. J'ai voulu éviter ce piège avec un trait : ContentFieldsTrait.
FormField::addTab et FormField::addFieldset : structurer les champs sans noyer le lecteur
Un configureFields() qui retourne beaucoup de champs à plat est illisible à la fois pour le rédacteur en face du formulaire que pour le développeur qui maintient le code.
EasyAdmin fournit deux primitives : FormField::addTab() crée un onglet, FormField::addFieldset() crée un groupe encadré à l'intérieur. La structure est plate : tab → fieldset → field. Pas de fieldset imbriqué, pas de tab dans un fieldset :
yield FormField::addTab('Général')->setIcon('fa fa-cogs');
yield FormField::addFieldset('Informations de base')->setIcon('fa fa-info-circle');
yield TextField::new('title', 'Titre')
->onlyOnForms()
->setHelp('Le slug sera généré automatiquement depuis le titre');
yield TextField::new('slug', 'Slug')
->onlyOnForms()
->setRequired(false);
// ... champs ...
yield FormField::addFieldset('Planification')->setIcon('fa fa-calendar');
yield DateTimeField::new('scheduledPublishAt', 'Publier le')
->onlyOnForms()
->setRequired(false);EasyAdmin lit la séquence et fabrique le DOM autour. Sur le CRUD de Post, on a quatre tabs — Général, Éditeur, Méta-données, Schema — chacun contenant un à trois fieldsets.
Le coût : deux yield de plus par groupe. Le bénéfice : un formulaire scannable, et un code source dont la structure visuelle reflète celle du formulaire — diff git lisible, fusion sans surprise.
ContentFieldsTrait : organiser par section de formulaire, pas par type de champ
Le trait du projet vit dans src/Controller/Admin/Crud/Trait/ContentFieldsTrait.php :
trait ContentFieldsTrait
{
protected function aiCacheStateListField(): FieldInterface { /* ... */ }
/** @return iterable<FieldInterface> */
protected function getGeneralFields(): iterable { /* ... */ }
/** @return iterable<FieldInterface> */
protected function getEditorFields(): iterable { /* ... */ }
/** @return iterable<FieldInterface> */
protected function getMetadataFields(): iterable { /* ... */ }
/** @return iterable<FieldInterface> */
protected function getSchemaFields(): iterable { /* ... */ }
}Chaque méthode est un bloc de formulaire cohérent, pas un type de champ. getGeneralFields() retourne tout ce qui va dans l'onglet Général — les FormField::addTab/addFieldset et les champs scalaires de base. getMetadataFields() retourne tout ce qui va dans Méta-données.
Dans un CrudController qui consomme le trait, configureFields() devient un assemblage de générateurs :
final class PageCrudController extends AbstractContentCrudController
{
use ContentFieldsTrait;
public static function getEntityFqcn(): string
{
return Page::class;
}
public function configureFields(string $pageName): iterable
{
if (Crud::PAGE_INDEX === $pageName) {
yield $this->aiCacheStateListField();
// ... colonnes spécifiques liste ...
return;
}
yield from $this->getGeneralFields();
yield from $this->getEditorFields();
yield from $this->getMetadataFields();
yield from $this->getSchemaFields();
}
}Le jour où on ajoute un champ SEO dans getMetadataFields(), il apparaît dans les quatre CRUDs au prochain reload — sans toucher à PageCrudController, CategoryCrudController, ni SeriesCrudController.
Branche conditionnelle par FQCN : le if qu'on assume
Tous les contenus partagent l'ossature SEO. Mais Post a des champs propres : proficiencyLevel, category, series, episodePosition. Un Page n'en a aucun. Trois choix possibles :
- Créer un
PostFieldsTraitséparé qui ajoute ces champs. Inconvénient : il partagera 80% de sa surface avecContentFieldsTrait, ou il faudra l'utiliser en plus surPostCrudController— et l'ordre d'apparition des champs dépendra de l'ordre desyield from. - Surcharger
configureFields()dansPostCrudControllerpour injecter les champs au bon endroit. Inconvénient : on perd la lecture séquentielle du trait, on a unyield from $this->getGeneralFields()qui retourne des champs incomplets pourPost. - Mettre un
ifdans le trait : « si l'entité estPost, j'ajoute ces quatre champs ici, sinon je passe ».
J'ai option pour la troisième option :
if (static::getEntityFqcn() === Post::class) {
yield ChoiceField::new('proficiencyLevel', 'Niveau technique')
->setChoices([
'Débutant' => ProficiencyLevelEnum::BEGINNER,
'Intermédiaire' => ProficiencyLevelEnum::INTERMEDIATE,
'Expert' => ProficiencyLevelEnum::EXPERT,
])
->onlyOnForms();
yield AssociationField::new('category', 'Catégorie')
->setRequired(true);
yield AssociationField::new('series', 'Série')
->setRequired(false)
->setHelp('Optionnel — laisse vide si ce billet n\'est pas un épisode.')
->autocomplete()
->hideOnIndex();
yield IntegerField::new('episodePosition', 'Position dans la série')
->setRequired(false)
->setHelp('1-based. Auto-rempli au focus si vide ; obligatoire dès qu\'une série est choisie.')
->hideOnIndex();
}Le if est assumé. Un PostFieldsTrait séparé aurait partagé 80 % de sa surface avec ContentFieldsTrait — on aurait juste déplacé la duplication d'un cran. Le coût des cinq lignes conditionnelles reste inférieur à celui d'un second trait à maintenir en parallèle. Un trait, c'est un contrat entre quatre fichiers : un if dedans est une ride, deux traits redondants sont une fracture.
AssociationField : la relation Doctrine déguisée en select
Voici deux cas cas typiques sur les contenus de mon site :
Cas 1 — relation obligatoire, peu d'éléments cibles. Post → Category (le projet a 8 catégories). Pas besoin de autocomplete(), le <select> natif suffit :
yield AssociationField::new('category', 'Catégorie')
->setRequired(true);Cas 2 — relation optionnelle, dizaines d'éléments cibles, branchée à un comportement JS. Post → Series. Là on active autocomplete() explicitement et on cache le champ en index :
yield AssociationField::new('series', 'Série')
->setRequired(false)
->autocomplete()
->hideOnIndex()
->setFormTypeOption('attr', [
'data-episode-position-target' => 'series',
]);Le setFormTypeOption('attr', …) injecte un data-* sur le <input> final. Côté projet, c'est branché sur un Stimulus controller episode-position qui auto-remplit le champ episodePosition au moment où on choisit une série.
ChoiceField avec PHP enum natif
Depuis PHP 8.1 et le support natif des enums dans Doctrine, on n'a plus à passer par des constantes globales (STATUS_DRAFT = 'draft') ni par des classes-énumérations maison. EasyAdmin gère les enums via ChoiceField::setChoices().
Cas 1 — mapping littéral, quand le libellé humain n'est pas dans l'enum :
yield ChoiceField::new('status', 'Statut')
->setChoices([
'Brouillon' => ContentStatusEnum::DRAFT,
'Planifié' => ContentStatusEnum::SCHEDULED,
'En ligne' => ContentStatusEnum::ONLINE,
])
->onlyOnForms();Cas 2 — mapping dynamique, quand l'enum porte lui-même son libellé via une méthode (pattern courant : getLabel()) :
$schemaTypeChoices = [];
foreach (SchemaTypeEnum::cases() as $case) {
$schemaTypeChoices[$case->getLabel()] = $case;
}
yield ChoiceField::new('seo.schemaType', 'Type de Schema')
->setChoices($schemaTypeChoices)
->onlyOnForms();Le cas 2 est ce qui scale : quand l'enum gagne un nouveau case (Event, Recipe, …), il apparaît dans le <select> sans toucher au CrudController. Le cas 1 reste utile quand le libellé doit varier entre l'enum technique et l'UI — typiquement pour une traduction qui ne vit pas dans l'enum.
CollectionField pour les entrées répétées
Deux sections du form sont des collections : les questions/réponses du Schema FAQ, et les extraits de code du Schema CodeSnippet. EasyAdmin propose CollectionField qui rend un sous-formulaire répété.
Code observé :
yield CollectionField::new('seo.faqItems', 'Questions/Réponses')
->setEntryType(FaqItemType::class)
->setEntryIsComplex(true)
->setFormTypeOptions([
'by_reference' => false,
])
->onlyOnForms()
->setHelp('Ajoutez des questions/réponses pour activer le schema FAQPage dans Google.')
->allowAdd()
->allowDelete()
->setRequired(false);NB: je laisse le code à titre d'exemple mais la FAQ dans le schéma n'est plus utilisé par Google depuis le 7 mai 2026 — le code a donc été retiré.
Trois détails non négociables :
setEntryType(FaqItemType::class)— pointe sur un FormType Symfony classique. C'est ce FormType qui décrit les champs d'une entrée (question+answer, dans le cas FAQ).setEntryIsComplex(true)— dit à EasyAdmin de rendre chaque entrée comme un sous-formulaire encadré (UI claire pour l'éditeur), et pas comme une ligne plate.setFormTypeOptions(['by_reference' => false])— le détail qui fait perdre deux heures. Sur une relationOneToManycôté inverse, Doctrine doit voir un nouvel objet collection pour persister les changements. Sansby_reference => false, ajouter uneFaqItemen form ne déclenche rien à la sauvegarde, et il n'y a aucun message d'erreur — l'entrée disparaît silencieusement.
allowAdd() et allowDelete() activent les boutons « + » et « × » côté UI. Sans eux, la collection est figée à son contenu actuel.
Helpers de visibilité : la matrice par contexte
EasyAdmin appelle configureFields() à chaque rendu avec une $pageName : Crud::PAGE_INDEX, PAGE_NEW, PAGE_EDIT, PAGE_DETAIL. On peut conditionner manuellement (if ($pageName === Crud::PAGE_INDEX)), ou chaîner un helper sur chaque champ. Cinq helpers couvrent les besoins courants :
| Helper | Index | New | Edit | Detail | Usage typique |
|---|---|---|---|---|---|
onlyOnForms() |
❌ | ✅ | ✅ | ❌ | Champ de saisie : title, slug, status, scheduledPublishAt, seo.title, … |
onlyOnIndex() |
✅ | ❌ | ❌ | ❌ | Colonne calculée pour la liste seule : aiCacheHash (badge pré-warm) |
onlyOnDetail() |
❌ | ❌ | ❌ | ✅ | Métadonnées affichées uniquement en page de détail |
hideOnIndex() |
❌ | ✅ | ✅ | ✅ | Champ saisissable trop long pour la liste : series, episodePosition |
hideOnForm() |
✅ | ❌ | ❌ | ✅ | Champ alimenté par le système : hitCount sur RedirectRule, createdAt |
hideWhenCreating() |
✅ | ❌ | ✅ | ✅ | Champ alimenté au premier save : à la création il n'a pas encore de valeur |
Dans ContentFieldsTrait, onlyOnForms() revient seize fois — les champs SEO et planification ne servent qu'aux formulaires. onlyOnIndex() est réservé à aiCacheStateListField() (badge Twig component). hideOnIndex() cible les champs trop longs pour la liste (series, episodePosition). hideWhenCreating() couvre les champs qui ne prennent leur valeur qu'au premier save — un slug qu'on laisse régénérer, par exemple.
Le piège : ne jamais taper les chaînes 'index', 'edit' à la main quand on conditionne — utiliser les constantes Crud::PAGE_INDEX, Crud::PAGE_EDIT. Le jour où EasyAdmin renomme une page, le code se casse silencieusement avec les littéraux.
Quand le trait ne suffit plus : extraire un service
Le trait marche tant que les champs n'ont pas besoin de dépendances injectées (un encoder, un provider, une config). Dès qu'il en faut, on bascule sur un service.
Le projet a un cas net : les champs User du UserCrudController. Le champ password utilise un RepeatedType (double saisie + confirmation), avec un encoder injecté. Le champ roles liste les rôles disponibles et les rend comme badges colorés selon la sévérité. Tout ça ne tient pas dans un trait.
Le code vit dans src/Admin/User/UserFieldConfigurationService.php. C'est un final readonly class (convention du blog — tous les services sont déclarés ainsi, l'autowiring de Symfony injecte les dépendances au constructeur). Le service expose deux méthodes publiques, getIndexFields() et getFormFields(). Le UserCrudController consomme :
public function configureFields(string $pageName): iterable
{
return Crud::PAGE_INDEX === $pageName
? $this->fieldConfig->getIndexFields()
: $this->fieldConfig->getFormFields($pageName);
}Le bénéfice du service sur le trait : on peut le tester en isolation (un test unitaire avec des doubles, qui vérifie les configurations retournées), et la dépendance vers le password encoder reste explicite et injectée — pas cachée dans un parent:: ou un static::. Le coût : un fichier de plus, un configureFields() qui n'est plus auto-lisible.
La règle qui se dégage : trait quand les sections du formulaire sont des données (libellés, helpers, choix d'enum), service quand les sections sont des comportements (chaînage avec un FormType custom, dépendances métier, validation conditionnelle).
Le mot de la fin
Quatre CRUDs, quatre lignes de configureFields() chacun, un trait de 285 lignes qui les nourrit, zéro duplication entre les fichiers, un if assumé pour les champs propres à Post.
Reste un problème net : le trait organise mais ne sécurise pas. Un éditeur qui peut écrire des billets peut aussi voir le tab Schema et coller du JSON-LD custom dans schemaCustomJson — alors que seule la personne en charge du SEO devrait y toucher. setPermission() et IsGranted côté EasyAdmin répondent à ça. C'est le sujet de l'épisode suivant.
Une dernière remarque sur ce que ce trait est, plus que sur ce qu'il fait. Mettre quatre entités sous un même fichier n'est pas une décision d'organisation — c'est un constat de parenté. « Post, Page, Category, Series sont la même chose vue sous quatre angles, et on accepte de l'écrire. » Le jour où une de ces entités diverge vraiment (un workflow propre, une persistance différente, un cycle de vie qui se sépare), le trait se brise. C'est le signal que le constat n'est plus vrai. Un trait qui résiste à sa propre obsolescence est plus coûteux qu'un trait qu'on a accepté de jeter à temps.