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 : trois entités du projet (Post, Page, Category) partagent leur ossature (titre, slug, statut, planification, SEO bases). 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é, gérer proprement les champs propres à une entité (Post a category et proficiencyLevel ; les deux autres non), et choisir le bon helper de visibilité par contexte (onlyOnForms, onlyOnIndex, hideOnIndex, hideOnForm, hideWhenCreating).
Le problème : trois CRUDs, une même ossature
Sur mon site, trois entités de contenu vivent côte à côte : Post, Page, Category. Elles ne sont pas équivalentes : un Post a une catégorie et un niveau technique ; une Page est statique ; une Category est un nœud de taxonomie. 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, mots-clés)
Trois tabs identiques sur trois 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 trois 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 trois tabs (Général, Éditeur, Méta-données), 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/Concerns/ContentFieldsTrait.php :
trait ContentFieldsTrait
{
/** @return iterable<FieldInterface> */
protected function getGeneralFields(): iterable { /* ... */ }
/** Hook : champs « Général » propres à un type de contenu (vide par défaut). */
protected function getEntitySpecificGeneralFields(): iterable { return []; }
/** @return iterable<FieldInterface> */
protected function getEditorFields(): iterable { /* ... */ }
/** @return iterable<FieldInterface> */
protected function getMetadataFields(): 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) {
// ... colonnes spécifiques liste (title, status, createdAt) ...
return;
}
yield from $this->getGeneralFields();
yield from $this->getEditorFields();
yield from $this->getMetadataFields();
}
}Le jour où on ajoute un champ SEO dans getMetadataFields(), il apparaît dans les trois CRUDs au prochain reload, sans toucher à PageCrudController ni CategoryCrudController.
Champs propres à une entité : le hook template-method
Tous les contenus partagent l'ossature SEO. Mais Post a des champs propres : proficiencyLevel, category. Une 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. - Prévoir un hook template-method dans le trait : une méthode vide par défaut, surchargée par le CRUD qui a des champs propres, injectée au bon endroit de l'onglet Général.
La troisième option est celle du projet. Dans le trait, le hook est déclaré vide et appelé entre le statut et la planification ; PostCrudController le surcharge :
// Dans ContentFieldsTrait, entre le statut et la planification :
yield from $this->getEntitySpecificGeneralFields();
// Dans PostCrudController :
protected function getEntitySpecificGeneralFields(): iterable
{
yield ChoiceField::new('proficiencyLevel', 'Niveau technique')
->setChoices([
'Débutant' => ProficiencyLevel::BEGINNER,
'Intermédiaire' => ProficiencyLevel::INTERMEDIATE,
'Expert' => ProficiencyLevel::EXPERT,
])
->onlyOnForms()
;
yield AssociationField::new('category', 'Catégorie')
->setRequired(true)
;
}Le hook 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 trait n'a pas à connaître les FQCN concrets, chaque CRUD concerné surcharge la méthode, et l'ordre d'apparition reste piloté par le yield from. Un trait, c'est un contrat entre trois fichiers : un hook vide dedans est une prise posée d'avance, deux traits redondants sont une fracture.
AssociationField : la relation Doctrine déguisée en select
Voici deux cas typiques :
Cas 1, relation obligatoire, peu d'éléments cibles. Post → Category (le projet a seize 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. Le blog a eu ce cas avec Post → Series, du temps où les billets pouvaient appartenir à une série. 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. À l'époque, c'était branché sur un Stimulus controller episode-position qui auto-remplissait la position de l'épisode au choix de la série. La fonctionnalité Séries a depuis été retirée du blog ; le pattern, lui, se rejoue tel quel pour n'importe quelle relation pilotée par du JS.
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' => ContentStatus::DRAFT,
'Planifié' => ContentStatus::SCHEDULED,
'En ligne' => ContentStatus::ONLINE,
])
->onlyOnForms();Cas 2, mapping dynamique, quand l'enum porte lui-même son libellé via une méthode (pattern courant : getLabel()) :
$choices = [];
foreach (MyLabelledEnum::cases() as $case) {
$choices[$case->getLabel()] = $case;
}
yield ChoiceField::new('myField', 'Mon champ')
->setChoices($choices)
->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 ont été des collections : les questions/réponses du Schema FAQ et les extraits de code du Schema CodeSnippet (retirées depuis, cf. le NB plus bas). EasyAdmin propose CollectionField qui rend un sous-formulaire répété. Code d'époque :
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ée 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 (badge, compteur) |
onlyOnDetail() |
❌ | ❌ | ❌ | ✅ | Métadonnées affichées uniquement en page de détail |
hideOnIndex() |
❌ | ✅ | ✅ | ✅ | Champ saisissable trop encombrant pour la liste |
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 dix fois : les champs SEO et planification ne servent qu'aux formulaires. Les autres helpers vivent côté CRUDs : hideOnIndex() cache par exemple la référence interne d'une Page hors de la liste, hideOnForm() protège les compteurs alimentés par le code (hitCount de RedirectRule), et hideWhenCreating() couvre les champs qui ne prennent leur valeur qu'au premier save.
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/Service/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
Trois CRUDs, quelques lignes de configureFields() chacun, un trait de 188 lignes qui les nourrit, zéro duplication entre les fichiers, un hook 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 voit aussi le tab Méta-données et peut réécrire le titre SEO ou la description, 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 du prochain billet.
Une dernière remarque sur ce que ce trait est, plus que sur ce qu'il fait. Mettre plusieurs entités sous un même fichier n'est pas une décision d'organisation, c'est un constat de parenté. « Post, Page et Category sont la même chose vue sous trois 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.
Une coquille, une erreur dans ce billet ? Signale-la-moi.