Épisode 3/10
EasyAdmin
- 01 C'est parti pour installer EasyAdmin
- 02 EasyAdmin : construire un menu admin qui tient la route
- 03 EasyAdmin : son premier CrudController
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 :
#[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 :
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 :
- Le FQCN de l'entité à administrer — on tape
App\Entity\Routing\RedirectRule. L'autocomplétion fonctionne, on peut aussi taper justeRedirectRulesi le nom est non ambigu. - 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().
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 :
#[\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 :
setEntityLabelInSingular/setEntityLabelInPluralne 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 —RedirectRulepartout, mauvaise expérience.setSearchFields(['source', 'target'])active la barre de recherche en haut de l'index. EasyAdmin construit unLIKE %term%sur les champs déclarés. Trois conseils : ne mettre que des champs textuels (unLIKEsur unINTEGERne 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.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. PourRedirectRule, trier parhitCountdescendant 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.- 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 :
#[\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
yieldempilé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. Poursource, 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.hitCountetlastAccessedAtsont 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,BooleanFieldrend 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).
#[\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:crudla 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 dansformatValue()(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 rechercheLIKEdeviendra lente. Ajouter l'index dans l'entité (#[ORM\Index]) et migrer la base. PostgreSQL avecpg_trgmpermet aussi des recherchesILIKEperformantes — sujet du billet sur la recherche FTS, pas de cette série.final classoublié. 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 unWebTestCaseclassique 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.