Aller au contenu principal

EasyAdmin : construire un menu admin qui tient la route.

Créez un menu admin EasyAdmin robuste avec Symfony. ConfigureMenuItems(), linkTo: bonnes pratiques pour une navigation back-office efficace.

Pierre 12 min de lecture
Sommaire · 9
Épisode 2/10
EasyAdmin
  1. 01 C'est parti pour installer EasyAdmin
  2. 02 EasyAdmin : construire un menu admin qui tient la route
Voir toute la série

J'ai refait le menu admin de ce blog quatre fois en deux ans. Pas par perfectionnisme : à chaque fois parce que le menu en place me coûtait une seconde de fouille à chaque édition d'article, et que cette seconde, multipliée par chaque jour, devient une dette d'usage qu'on ne mesure jamais. Le menu d'un back-office est l'objet qu'on regarde le moins, et qu'on consulte le plus.

Ce billet est le deuxième de la série EasyAdmin. Le premier traitait de l'installation et des décisions à prendre dans la première heure. Celui-ci décortique configureMenuItems() à partir du menu actuel — sept sections, treize items, deux ans d'usage quotidien. L'objectif n'est pas de cataloguer toutes les méthodes de MenuItem — la doc le fait — mais de pointer les choix qui paient et ceux qui se retournent au bout de six mois.

On va voir trois choses : structurer un menu qui résiste à la croissance, distinguer les trois familles de linkTo, conditionner l'affichage par rôle. Et — accessoirement — éviter le piège de la sur-organisation qui transforme une admin en arborescence à trois niveaux qu'il faut déplier pour chaque action.

La méthode pivot : configureMenuItems()

Tout part d'une méthode unique, déclarée dans votre DashboardController, qui retourne un iterable<MenuItemInterface>. EasyAdmin lit ce yield, le rend en sidebar, et c'est tout. Aucune configuration YAML, aucun fichier annexe, aucun service à enregistrer.

PHP
use EasyCorp\Bundle\EasyAdminBundle\Config\MenuItem;
use EasyCorp\Bundle\EasyAdminBundle\Contracts\Menu\MenuItemInterface;

#[\Override]
public function configureMenuItems(): iterable
{
    yield MenuItem::linktoDashboard('Dashboard', 'fa fa-home');
    yield MenuItem::linkTo(PostCrudController::class, 'Billets', 'fas fa-newspaper');
    yield MenuItem::linkToUrl('Voir le site', 'fas fa-eye', '/')->setLinkTarget('_blank');
}

Trois lignes, trois familles. Le retour iterable est important : il vous laisse libre d'utiliser yield (c'est la convention) ou un array, et il vous laisse mélanger des branches conditionnelles sans dégrader la lisibilité. On y reviendra à la section sur les rôles.

Notez la signature MenuItem::linktoDashboard() — avec un t minuscule au milieu, là où les autres méthodes du SDK utilisent linkTo. C'est une bizarrerie historique de la v3 conservée pour la rétro-compatibilité. Aucune importance fonctionnelle, mais ça surprend la première fois que votre IDE refuse l'autocomplétion sur linkToDashboard.

Les trois familles de linkTo

Le bundle propose plusieurs constructeurs de MenuItem. Trois suffisent à 95 % des cas, et confondre l'un pour l'autre est l'erreur la plus fréquente.

MenuItem::linkTo(CrudController::class, 'Label', 'icon') est le constructeur de base pour pointer vers un CRUD. EasyAdmin résout l'URL en interne, choisit la page index par défaut, et tracke l'item actif quand vous êtes sur une page de ce CRUD. C'est le bon outil pour 80 % de votre menu.

MenuItem::linkToUrl('Label', 'icon', $url) sert quand la cible n'est pas un CrudController : une page custom, une URL externe, une action AJAX, le site public lui-même. Dans le menu de ce blog, deux usages :

PHP
yield MenuItem::linkToUrl(
    'Médiathèque',
    'fas fa-photo-film',
    $this->generateUrl(MediaLibraryAction::class)
);

yield MenuItem::linkToUrl('Voir le site', 'fas fa-eye', '/')
    ->setLinkTarget('_blank');

Le premier pointe vers une action custom (MediaLibraryAction est un controller ADR classique annoté #[Route], pas un CrudController). Le second ouvre le site public dans un nouvel onglet — détail qui change tout en pratique, parce que l'auteur veut comparer admin et rendu, pas perdre sa session de rédaction.

MenuItem::linktoDashboard('Label', 'icon') ramène à la racine /admin. Toujours en première position du menu, par convention. C'est le « accueil » de l'admin.

Trois autres constructeurs existent (linkToRoute, linkToCrud avec page spécifique, linkToLogout) mais ils sont marginaux — utiles quand vous voulez pointer vers une route Symfony nommée précise sans passer par generateUrl(), ou directement vers la page new d'un CRUD.

Sectionner pour ne pas se noyer

Une liste de 15 items à plat est illisible au bout de la cinquième ligne. EasyAdmin propose MenuItem::section('Label') pour créer un séparateur titré dans la sidebar. Pas de pliage, pas d'enfants techniquement attachés — juste un titre qui regroupe visuellement les items qui suivent.

C'est l'outil structurant numéro un. Le menu de ce blog en utilise sept :

PHP
yield MenuItem::linktoDashboard('Dashboard', 'fa fa-home');

yield MenuItem::section('Blog');
yield MenuItem::linkTo(PostCrudController::class, 'Billets', 'fas fa-newspaper');

yield MenuItem::section('Site');
yield MenuItem::linkTo(PageCrudController::class, 'Pages', 'fas fa-file');
yield MenuItem::linkTo(CategoryCrudController::class, 'Catégories', 'fas fa-folder');

yield MenuItem::section('Médias');
yield MenuItem::linkToUrl('Médiathèque', 'fas fa-photo-film', $this->generateUrl(MediaLibraryAction::class));

yield MenuItem::section('Administration');
yield MenuItem::linkTo(UserCrudController::class, 'Utilisateurs', 'fas fa-users');

yield MenuItem::section('Routing');
yield MenuItem::linkTo(RedirectRuleCrudController::class, 'Redirections', 'fas fa-random');

yield MenuItem::section('SEO');
yield MenuItem::linkToUrl('Santé du contenu', 'fas fa-heartbeat', $this->generateUrl(ContentHealthDashboardAction::class));

yield MenuItem::section('Autres');
yield MenuItem::linkToUrl('Voir le site', 'fas fa-eye', '/')->setLinkTarget('_blank');

Trois principes guident ce découpage. D'abord, un item par section minimum — une section vide n'apporte rien sauf de la friction visuelle. Deuxièmement, les sections suivent la fréquence d'usage : Blog en haut parce qu'on y va vingt fois par jour, Autres en bas parce qu'on y va une fois par semaine. Enfin, la section nomme un domaine, pas une fonction — « Routing » plutôt que « Gestion des redirections », parce que le label de l'item suivant porte déjà le verbe.

Ce qui ne paye pas, à l'inverse, c'est de créer une section par CRUD. Si vous avez quatre sections d'un item chacune, vos sections sont des labels redondants ; supprimez-les et laissez les items respirer.

Choisir des icônes qui se lisent en 200 ms

EasyAdmin accepte n'importe quelle classe CSS comme icône. La convention par défaut est FontAwesome, mais rien n'empêche d'utiliser autre chose. Trois règles internes au projet, validées par usage quotidien :

  1. Une icône par concept, jamais réutilisée. Si fa fa-folder désigne « Catégories », il ne désigne rien d'autre dans le menu. La reconnaissance visuelle n'a de sens que si l'œil peut sauter directement à la bonne ligne sans relire chaque label.
  2. Préférer les icônes pleines (fas) aux icônes filaires (far) en sidebar. Le contraste est meilleur sur fond sombre. Les filaires se lisent mal à 16px.
  3. Aligner sur une seule famille FontAwesome quand c'est possible. Le menu de ce blog mélange fa (style ancien équivalent à fas) et fas — c'est un héritage v3 que je n'ai jamais nettoyé, et qui ne se voit pas à l'œil nu mais qui choque à la relecture du code. Si vous démarrez en 2026, choisissez fas partout dès le départ.

À noter : le reste de l'application utilise Symfony UX Icons (<twig:ux:icon>) pour ne pas dupliquer FontAwesome côté front public. Le menu admin reste sur FontAwesome parce qu'EasyAdmin le charge déjà pour ses propres icônes internes — installer Symfony UX Icons en plus serait du poids inutile sur un asset qui n'est servi qu'à un utilisateur (vous).

Conditionner l'affichage par rôle

Le menu actuel de ce blog ne fait pas de distinction par rôle — un seul utilisateur, un seul rôle (ROLE_ADMIN). Mais c'est typiquement la première chose qui change quand un projet grossit. Voici le pattern propre.

PHP
#[\Override]
public function configureMenuItems(): iterable
{
    yield MenuItem::linktoDashboard('Dashboard', 'fa fa-home');

    yield MenuItem::section('Blog');
    yield MenuItem::linkTo(PostCrudController::class, 'Billets', 'fas fa-newspaper');

    if ($this->isGranted('ROLE_ADMIN')) {
        yield MenuItem::section('Administration');
        yield MenuItem::linkTo(UserCrudController::class, 'Utilisateurs', 'fas fa-users');
    }

    if ($this->isGranted('ROLE_SUPER_ADMIN')) {
        yield MenuItem::section('Système');
        yield MenuItem::linkTo(RedirectRuleCrudController::class, 'Redirections', 'fas fa-random');
    }
}

AbstractDashboardController étend le AbstractController Symfony, donc $this->isGranted() est disponible directement. Pas de service à injecter, pas de Voter à brancher pour ce cas simple.

Trois pièges à connaître. La sécurité ne se joue pas dans le menu : cacher un item ne protège pas le CRUD, qui doit être sécurisé par #[IsGranted] au niveau classe ou par access_control dans security.yaml. Le menu est un confort visuel, pas une barrière. Deuxièmement, n'utilisez pas $this->getUser()->getRoles() pour vérifier les rôles : la hiérarchie de rôles Symfony (ROLE_SUPER_ADMIN implique ROLE_ADMIN) n'est appliquée que par isGranted(). Enfin, évitez les imbrications profondes dans configureMenuItems() : si vous avez besoin d'une matrice rôle × item, sortez la logique dans un service MenuBuilder dédié.

La méthode setPermission() qu'on n'a jamais besoin d'utiliser

EasyAdmin propose une méthode MenuItem::setPermission('ROLE_X') qui, à première vue, semble plus déclarative que le if de la section précédente :

PHP
yield MenuItem::linkTo(UserCrudController::class, 'Utilisateurs', 'fas fa-users')
    ->setPermission('ROLE_ADMIN');

Fonctionnellement, c'est équivalent. Le bundle évalue la permission au rendu et masque l'item s'il n'est pas accordé. Mais en pratique, je recommande d'éviter cette méthode pour deux raisons.

D'abord, la lisibilité du regroupement : si trois items consécutifs ont setPermission('ROLE_ADMIN'), c'est plus clair de les emballer dans un seul if (...) que de répéter trois fois la chaîne. Le if permet aussi de masquer la section parente, alors que setPermission() ne s'applique qu'à un item.

Ensuite, la chaîne de caractères pour le rôle. setPermission('ROLE_ADMIN') introduit un identifiant de rôle dans une string, sans typage. Si vous renommez le rôle, l'IDE ne vous le signalera pas. Le projet utilise un enum RoleEnum pour ses rôles ; passer par $this->isGranted(RoleEnum::ADMIN->value) garde la cohérence avec le reste du code.

setPermission() reste utile pour les cas marginaux où un seul item dans une section doit être restreint — c'est le seul scénario où elle bat le if.

Un dashboard, plusieurs menus : multi-dashboards

Mention rapide pour un cas d'usage qui change le design global : EasyAdmin permet plusieurs DashboardController cohabitant dans la même application, chacun avec son propre configureMenuItems(). Utile quand vous avez deux populations très distinctes (admin technique vs rédacteurs) et que mélanger leurs menus ferait perdre les deux.

PHP
#[AdminDashboard(routePath: '/admin/editorial', routeName: 'editorial_admin')]
final class EditorialDashboardController extends AbstractDashboardController
{
    #[\Override]
    public function configureMenuItems(): iterable
    {
        yield MenuItem::linktoDashboard('Tableau de bord', 'fa fa-pen');
        yield MenuItem::section('Contenus');
        yield MenuItem::linkTo(PostCrudController::class, 'Billets', 'fas fa-newspaper');
        yield MenuItem::linkTo(PageCrudController::class, 'Pages', 'fas fa-file');
    }
}

Le coût : chaque dashboard a son entrée dans le routing, sa propre URL, son propre cycle de vie. Ne basculez pas en multi-dashboard pour gérer trois rôles — c'est exactement ce que la section précédente règle en quinze lignes. Réservez ça aux cas où les deux populations ne partagent presque rien.

Les pièges du menu

  • Items orphelins sous une section vide. Vous supprimez un CRUD, vous oubliez la section associée, et il vous reste un titre sans rien dessous. C'est inesthétique mais surtout déroutant — un utilisateur clique sur la section pensant qu'elle se déplie. Faites un grep régulier sur vos sections.
  • Doublons d'icône. Deux items avec la même icône sapent la reconnaissance visuelle. Tenez une liste interne — ou mieux, un test PHPUnit qui itère sur le menu et lève une erreur si une icône est utilisée deux fois.
  • Labels génériques. « Liste », « Gestion », « Données » ne disent rien. Préférez le nom métier de l'entité au pluriel : « Billets », « Catégories », « Redirections ». Le menu est lu en diagonale, pas en lecture suivie.
  • Cacher un item sans sécuriser le CRUD derrière. Voir section 5. La règle absolue : le menu est cosmétique, jamais sécuritaire.
  • Surcharger l'icône d'un CRUD via linkTo plutôt que dans le Crud::configureCrud() du controller. Si vous changez d'icône une fois, faites-le aux deux endroits, ou conservez la cohérence en n'en utilisant qu'un. EasyAdmin n'arbitre pas — c'est l'icône passée à MenuItem::linkTo qui prime au menu, mais celle du CRUD qui prime ailleurs (breadcrumb, header). La divergence se voit vite.
  • URLs hardcodées dans linkToUrl. '/admin/santé-du-contenu' est une bombe à retardement. Toujours passer par $this->generateUrl(SomeAction::class) quand l'URL pointe vers une route Symfony nommée. EasyAdmin reconnaît même la classe d'action ADR comme nom de route si vous utilisez la convention name: self::class dans #[Route].
  • Le menu qui grossit jusqu'à dépasser la fenêtre. À 20+ items, la sidebar scroll, la mémoire visuelle s'effondre. C'est le signal qu'il faut soit déléguer à un sous-dashboard, soit grouper plusieurs CRUD dans une seule page index personnalisée.

Le mot de la fin

Un menu admin n'est pas un objet qu'on conçoit. C'est un comportement qu'on observe. Toute la classe d'outils admin (EasyAdmin, Sonata, Filament côté Laravel) partage le même piège : elle propose tellement de structuration qu'elle invite à la sur-architecture. La discipline est inverse — laisser émerger, déplacer quand l'usage le demande.

Ces quatre ajustements n'auraient pas pu être anticipés à l'install. Tous se sont faits en deux minutes parce que configureMenuItems() est un endroit unique, lisible, sans configuration distribuée. C'est précisément la qualité qu'il faut préserver : ne sortir la logique dans un service que quand l'imbrication des conditions devient illisible — pas avant.

Le billet suivant — semaine prochaine — quitte le dashboard pour entrer dans le vif du sujet : construire un premier CrudController, à partir de l'exemple RedirectRule qui apparaît plusieurs fois dans ce billet sans qu'on ait encore vu son code. On verra configureCrud(), les search fields, le sort par défaut, et surtout les choix qu'on prend pour ne pas se peindre dans un coin.

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.