Aller au contenu principal

EasyAdmin : son premier CrudController.

Découvrez comment bien utiliser le CrudController d'EasyAdmin pour gérer vos entités Symfony de manière propre et efficace. Apprenez à générer un CrudController adapté à votre entité RedirectRule, tout en évitant les pièges liés au mélange de configuration et de logique métier.

12 min de lecture
Sommaire · 9
Épisode 3/10
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
Voir toute la série

Ce billet est le troisième de la série EasyAdmin. Les deux premiers ont posé l'installation et le menu. À partir d'ici, on entre dans le code qui fait vivre un admin. L'objectif n'est pas d'écrire le CrudController parfait — il n'existe pas — mais de prendre les bonnes habitudes dès le premier, parce qu'elles définissent toute la suite.

À la fin du billet, on saura écrire un CrudController propre sur une entité simple (RedirectRule), on comprendra les trois méthodes pivots (getEntityFqcn, configureCrud, configureFields), et on aura tenu la règle de layering du projet — Controller → Service → Repository, vérifiée par deptrac en CI — qui veut qu'un CrudController orchestre sans jamais appeler un repository ni écrire de logique métier.

Choisir la bonne première entité

Pour commencer, autant prendre une entité avec peu de propriétés pour ne pas se perdre et si possible dans relation avec une autre entité.

Pour parler donc des CrudController, je vais prendre mon entité RedirectRule. Elle gère les redirections HTTP 301/302 : un chemin source, un chemin cible, un code de statut, un compteur de hits, un flag « regex ou pas », une priorité. Sept champs métier, zéro relation, et un usage quotidien — chaque article renommé crée une règle, chaque ancien lien bookmarké la sollicite. (Le trait TimestampableTrait y ajoute en plus createdAt/updatedAt, mais ce n'est pas le sujet ici.) Voici son squelette :

PHP
#[ORM\Entity(repositoryClass: RedirectRuleRepository::class)]
#[ORM\Table(name: 'redirect_rule')]
final class RedirectRule
{
    use TimestampableTrait;

    public string $source = '';
    public string $target = '';
    public int $statusCode = 301;
    public ?\DateTimeImmutable $lastAccessedAt = null;
    public int $hitCount = 0;
    public bool $isRegex = false;
    public int $priority = 0;
    // …
}

Générer le squelette avec make:admin:crud

EasyAdmin fournit un Maker qui génère le fichier de base. Sur ce projet, on ne lance jamais bin/console directement — tout passe par le Makefile :

Terminal
make console CMD="make:admin:crud"

make ici, c'est le make du projet (le Makefile) ; make:admin:crud est la commande Symfony Maker fournie par EasyAdmin. Les deux ont juste le même mot, c'est une coïncidence qui surprend les premières fois.

Le maker demande deux choses interactivement :

  1. Le FQCN de l'entité à administrer — on tape App\Entity\Routing\RedirectRule. L'autocomplétion fonctionne, on peut aussi taper juste RedirectRule si le nom est non ambigu.
  2. Le namespace du CrudController — par convention sur ce projet, c'est App\Controller\Admin\Crud. Tous les CrudControllers vivent là, séparés des controllers ADR « classiques » par sous-domaine (Controller/{Content,Feed,Security,Seo}/).

Une fois le maker passé, on obtient un fichier src/Controller/Admin/Crud/RedirectRuleCrudController.php avec exactement deux méthodes : getEntityFqcn() qui retourne le FQCN, et configureFields() quasi vide qui retourne Field::new('id') et Field::new('source') auto-détectés.

Ce que le maker ne génère pas : configureCrud(), configureActions(), configureFilters(), configureAssets(). Tout ce qui pose la personnalité du CRUD reste à écrire à la main. C'est volontaire — le maker pose le squelette, on pose l'intention.

Une fois le fichier généré, il reste à le déclarer dans le DashboardController (la classe qui implémente Dashboard et liste les entrées de menu) : c'est le sujet du deuxième billet.

Le squelette imposé : trois méthodes pivots

Tout CrudController étend AbstractCrudController et implémente trois méthodes. Si on n'en écrit qu'une, c'est getEntityFqcn(). Si on en écrit deux, c'est configureCrud(). Si on en écrit trois, c'est configureFields().

PHP
namespace App\Controller\Admin\Crud;

use App\Entity\Routing\RedirectRule;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;

/**
 * @extends AbstractCrudController<RedirectRule>
 */
final class RedirectRuleCrudController extends AbstractCrudController
{
    #[\Override]
    public static function getEntityFqcn(): string
    {
        return RedirectRule::class;
    }

    #[\Override]
    public function configureCrud(Crud $crud): Crud
    {
        return $crud
            ->setEntityLabelInSingular('Règle de redirection')
            ->setEntityLabelInPlural('Règles de redirection');
    }

    #[\Override]
    public function configureFields(string $pageName): iterable
    {
        return [];
    }
}

Avec ce code minimal, EasyAdmin sait afficher un index, une page d'édition, une page de création, et une page de détail. Les champs sont auto-découverts via les métadonnées Doctrine — pas idéal pour une UI propre, mais ça marche. C'est la base sur laquelle on va construire.

Trois détails sur le squelette. final class : par convention sur ce blog, tous les CrudControllers sont final. EasyAdmin n'attend pas qu'on l'étende, et la finalité ferme la porte aux héritages improvisés (le billet 7 montrera l'exception : un parent abstrait délibéré). @extends AbstractCrudController<RedirectRule> : annotation PHPDoc générique qui indique à PHPStan le type d'entité géré. Le bundle a embrassé les génériques depuis la 4.x, autant en profiter. #[\Override] : PHP 8.3 vérifie à la compilation que la méthode existe bien dans le parent. Ce n'est pas obligatoire, mais c'est gratuit et ça évite les renommages silencieux.

configureCrud() : poser les libellés et les défauts

configureCrud() est le réglage global du CRUD. Tout ce qui n'est pas spécifique à un champ se passe ici : libellés, pagination, tri par défaut, champs cherchables, templates surchargés, formats de date.

Sur RedirectRule, voici ce qu'on met :

PHP
#[\Override]
public function configureCrud(Crud $crud): Crud
{
    return $crud
        ->setEntityLabelInSingular('Règle de redirection')
        ->setEntityLabelInPlural('Règles de redirection')
        ->setSearchFields(['source', 'target'])
        ->setDefaultSort(['hitCount' => 'DESC'])
    ;
}

Quatre lignes, quatre décisions structurantes :

  1. setEntityLabelInSingular / setEntityLabelInPlural ne sont pas cosmétiques. Elles alimentent les titres de page (« Modifier la règle de redirection »), les boutons d'action (« Ajouter une règle de redirection »), et les confirmations de suppression. Sans elles, EasyAdmin tombe en défaut sur le nom de la classe — RedirectRule partout, mauvaise expérience.
  2. setSearchFields(['source', 'target']) active la barre de recherche en haut de l'index. EasyAdmin construit un LIKE %term% sur les champs déclarés. Trois conseils : ne mettre que des champs textuels (un LIKE sur un INTEGER ne sert à rien), ne pas inclure les champs sensibles (mot de passe, hash), et ne pas oublier l'index Doctrine sur ces champs si la table a plus de quelques milliers de lignes — sans index, la recherche scanne toute la table.
  3. setDefaultSort(['hitCount' => 'DESC']) est le choix qui paye. La règle par défaut d'EasyAdmin est de trier par ID, ce qui n'a aucun sens fonctionnel. Pour RedirectRule, trier par hitCount descendant met en haut les redirections les plus utilisées — exactement ce qu'on veut voir en premier quand on ouvre l'admin pour faire le ménage.
  4. Trois autres méthodes utiles à connaître mais pas indispensables ici : setPaginatorPageSize(20) (la valeur par défaut convient), setHelp('index', 'Texte d\'aide') (utile sur les CRUDs complexes), setFormThemes(['…']) (pour surcharger le rendu Bootstrap, sujet du billet 9).

configureFields() : le cœur de la configuration

C'est ici que vit l'essentiel du code. La méthode reçoit un string $pageName (Crud::PAGE_INDEX, Crud::PAGE_EDIT, Crud::PAGE_NEW, Crud::PAGE_DETAIL) et retourne un iterable<FieldInterface>. On peut retourner un array ou faire du yield — choix de style, sans impact fonctionnel.

Voici la version complète pour RedirectRule :

PHP
#[\Override]
public function configureFields(string $pageName): iterable
{
    $source = TextField::new('source', 'Source')
        ->setHelp('Chemin exact (ex: /ancien-chemin) ou pattern regex (ex: /blog/(\\d+))');

    $target = TextField::new('target', 'Cible')
        ->setHelp('Chemin de destination (ex: /nouveau-chemin). Avec regex, utilisez $1, $2 pour les groupes capturés');

    $isRegex = BooleanField::new('isRegex', 'Regex')
        ->setHelp('Cochez pour utiliser la source comme pattern regex')
        ->renderAsSwitch(false);

    $priority = IntegerField::new('priority', 'Priorité')
        ->setHelp('Plus la valeur est élevée, plus la règle est prioritaire (0 = priorité normale)')
        ->setFormTypeOption('attr', ['min' => 0, 'max' => 9999]);

    $statusCode = ChoiceField::new('statusCode', 'Type de redirection')
        ->setChoices([
            '301 - Permanente' => 301,
            '302 - Temporaire' => 302,
        ]);

    $hitCount = IntegerField::new('hitCount', 'Hits')
        ->setSortable(true)
        ->hideOnForm();

    $lastAccessedAt = DateTimeField::new('lastAccessedAt', 'Dernier accès')
        ->hideOnForm()
        ->setSortable(true);

    return [$source, $target, $isRegex, $priority, $statusCode, $hitCount, $lastAccessedAt];
}

Plusieurs choses à noter dans ce code :

  • Variables intermédiaires plutôt que yield empilés. Le code est plus long, mais on voit immédiatement où chaque champ est défini, et un diff git ne mélange pas les configurations. Sur les CRUDs complexes (10+ champs), c'est ce qui fait la différence entre un fichier maintenable et un mur.
  • setHelp() partout. L'aide contextuelle sous chaque champ est ce qui transforme un CRUD générique en outil utilisable. Pour source, l'aide précise la syntaxe regex — sans ça, on ne sait pas s'il faut échapper les slashs, mettre des délimiteurs, etc. Le coût d'écriture est nul, le bénéfice quotidien est réel.
  • hideOnForm() sur les champs en lecture seule. hitCount et lastAccessedAt sont alimentés par le code (un service incrémente le compteur à chaque match d'URL). Les exposer en formulaire serait un trou de sécurité — un admin pourrait truquer son propre rapport. hideOnForm() les fait apparaître à l'index et au détail, mais pas à l'édition.
  • setSortable(true) explicite. Par défaut, les colonnes sont triables si EasyAdmin le devine. Le déclarer explicitement évite les surprises.
  • renderAsSwitch(false). Par défaut, BooleanField rend une bascule iOS-like. Pour un champ « est-ce que c'est une regex », un checkbox classique est plus lisible et moins ambigu — un switch suggère un état actif/inactif.

Conditionner par page

Le paramètre $pageName permet de servir des champs différents selon le contexte. C'est un pattern très utile pour ne pas afficher la même chose en index (compact, scannable) qu'en édition (détaillé, avec helpers).

PHP
#[\Override]
public function configureFields(string $pageName): iterable
{
    if ($pageName === Crud::PAGE_INDEX) {
        // Champs compacts pour la liste
        yield TextField::new('source', 'Source');
        yield TextField::new('target', 'Cible');
        yield IntegerField::new('hitCount', 'Hits');
        return;
    }

    // Tous les autres pages (NEW, EDIT, DETAIL)
    yield TextField::new('source', 'Source')->setHelp('…');
    yield TextField::new('target', 'Cible')->setHelp('…');
    yield BooleanField::new('isRegex', 'Regex')->setHelp('…');
    // …
}

Sur RedirectRule, pas besoin de conditionner — la liste de champs est suffisamment courte pour tenir telle quelle en index.

La règle de layering : un CrudController ne touche pas au repository

C'est la convention la plus structurante du projet, et celle qu'EasyAdmin n'impose pas, il faut se la donner.

Un CrudController n'est pas l'endroit où on écrit de la logique métier. Il configure une UI, point. Pour enrichir le contexte (calculer un score, agréger des stats, déclencher un workflow), on le fait dans un service dédié qu'on injecte au constructeur.

Concrètement, le pattern tient en deux lignes : un formatValue(fn ($value, $entity) => $this->calculator->compute($entity)) dans le configureFields(), et un final readonly class côté service qui contient la vraie logique — testable en isolation, partageable entre l'admin, un sitemap, un dashboard de santé.

deptrac.yaml vérifie en CI que la couche Controller ne tape jamais dans Repository — un appel direct y fait échouer le build. C'est ce filet de sécurité qui transforme la convention en discipline, et qui permet de la tenir sans y penser au quotidien.

Les pièges du premier CrudController

  • Oublier getEntityFqcn(). Sans cette méthode, EasyAdmin lève une exception au démarrage du CRUD. C'est un oubli qui coûte cinq secondes mais qui surprend — make:admin:crud la génère automatiquement, c'est uniquement quand on copie-colle un autre CrudController qu'on l'oublie.
  • Mettre de la logique dans configureFields(). La méthode est appelée à chaque rendu (index, edit, new, detail). Un calcul lourd dedans s'exécute à chaque ouverture de page. Si on a besoin d'enrichir, on met ça dans formatValue() (qui s'applique à chaque ligne, donc encore pire si naïf) ou on pré-calcule dans un service avec cache.
  • Confondre Crud::PAGE_* avec les chaînes littérales. Ne jamais taper 'index' à la main, utiliser les constantes. Si EasyAdmin renomme ses pages dans une version future, le code se cassera silencieusement.
  • Champs en double dans configureFields(). Si on déclare deux fois le même nom de champ, EasyAdmin ne lève pas d'erreur — il garde la dernière déclaration. C'est un piège classique sur les longs formulaires copiés-collés.
  • setSearchFields() sans index Doctrine. Sur une table de quelques milliers de lignes, la recherche LIKE deviendra lente. Ajouter l'index dans l'entité (#[ORM\Index]) et migrer la base. PostgreSQL avec pg_trgm permet aussi des recherches ILIKE performantes — sujet du billet sur la recherche FTS, pas de cette série.
  • final class oublié. EasyAdmin ne contraint pas, mais sur un projet propre, c'est la règle. Le pattern Abstract du billet 7 sera l'unique exception.
  • Penser que le CrudController est un point d'entrée HTTP. Il l'est techniquement, mais ce n'est pas un controller ADR au sens strict — il n'a pas d'__invoke(). EasyAdmin gère le routing via son propre mécanisme. Il ne faut pas s'attendre à pouvoir le tester avec un WebTestCase classique sans connaître la structure d'URL.

Le mot de la fin

Un premier CrudController bien posé n'est pas celui qui couvre tous les cas. C'est celui qui pose les conventions du projet : nommage des classes, structure des champs, frontière avec les services, choix d'icônes, choix d'aide contextuelle. Une fois ces conventions actées, on écrit le suivant en quinze minutes au lieu d'une heure.

Le piège récurrent est de vouloir tout configurer la première fois — pagination personnalisée, templates surchargés, actions custom, filtres avancés. C'est l'instant où le CrudController devient une décharge. Mieux vaut rester minimaliste : trois méthodes, des libellés, des aides, un tri par défaut. Tout le reste vient quand le besoin est concret.

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.