Aller au contenu principal

EasyAdmin : quand un seul trait configure quatre CRUDs.

Structurer les champs de quatre entités partagées avec EasyAdmin. Quatre CRUDs pour Post, Page, Category et Series. Mutualisation en trait PHP pour éviter le copier-coller. FormField, tabs et fieldsets au service de la maintenabilité.

11 min de lecture
Sommaire · 10
Épisode 4/6
EasyAdmin
  1. 01 C'est parti pour installer EasyAdmin
  2. 02 EasyAdmin : construire un menu admin qui tient la route
  3. 03 EasyAdmin : son premier CrudController
  4. 04 EasyAdmin : quand un seul trait configure quatre CRUDs
  5. 05 EasyAdmin : sécuriser un admin sans toucher à setPermission() Programmé
  6. 06 EasyAdmin : écrire un filtre custom du FilterInterface au FilterType Programmé
Voir toute la série

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 :

PHP
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 :

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 :

PHP
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 :

  1. Créer un PostFieldsTrait séparé qui ajoute ces champs. Inconvénient : il partagera 80% de sa surface avec ContentFieldsTrait, ou il faudra l'utiliser en plus sur PostCrudController — et l'ordre d'apparition des champs dépendra de l'ordre des yield from.
  2. Surcharger configureFields() dans PostCrudController pour injecter les champs au bon endroit. Inconvénient : on perd la lecture séquentielle du trait, on a un yield from $this->getGeneralFields() qui retourne des champs incomplets pour Post.
  3. Mettre un if dans le trait : « si l'entité est Post, j'ajoute ces quatre champs ici, sinon je passe ».

J'ai option pour la troisième option :

PHP
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 :

PHP
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 :

PHP
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 :

PHP
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()) :

PHP
$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é :

PHP
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 relation OneToMany côté inverse, Doctrine doit voir un nouvel objet collection pour persister les changements. Sans by_reference => false, ajouter une FaqItem en 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 :

PHP
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.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.