Aller au contenu principal

Comment j'ai transformé EasyAdmin en tableur intelligent.

Découvrez comment optimiser votre back-office Symfony avec EasyAdmin. Réduisez les frictions et gagnez en productivité grâce à six décisions simples.

MAJ 12 min de lecture Ressorti
Sommaire · 9

Des frictions silencieuses qu'on accepte parce qu'elles ont toujours été là, on en a tous. Le node_modules qui prend 800 Mo pour servir trois imports. Le watch Webpack qui rallume le fan du laptop pour modifier une virgule. Le formulaire EasyAdmin qu'on charge en entier pour changer un mot dans un titre. La résignation par habitude — celle qui ne se voit dans aucune métrique parce qu'elle ne coûte qu'une demi-seconde à la fois — finit toujours par coûter beaucoup plus que ce qu'on imagine, multipliée par les milliers de fois où elle se rejoue.

Pour modifier le titre d'un billet sur ce blog, c'était jusqu'ici trois clics : un sur la ligne, un sur le champ, un sur Valider. Deux navigations complètes. Un formulaire monolithique chargé avec ses douze champs pour qu'on en touche un seul. EasyAdmin n'y est pour rien — c'est un excellent outil. C'est juste qu'à côté d'un Notion ou d'un Linear, un back-office Symfony qui n'a rien fait pour s'en distinguer prend un sacré coup de vieux.

Ce billet fait deux choses, et deux seulement : montrer qu'on peut sortir de cette friction-là sans réécrire EasyAdmin ni installer un éditeur tiers, et passer en revue les six décisions qui séparent une feature qui marche d'une feature qu'on garde. Ce qui suit, ce sont les choix — pas la doc.

Supprimer les boutons valider et annuler

L'angle naïf, c'est deux boutons ✓ et ✗ qui apparaissent dès qu'on clique sur une cellule. Ça marche. C'est mou. Et surtout c'est EasyAdmin avec deux clics au lieu d'un — ce n'est pas un autre logiciel, c'est le même en moins pire.

Le pattern qui rend Notion et Linear addictifs n'a pas de bouton. On tape, le serveur enregistre dans le dos, un check vert fade. Toute l'orchestration tient sur six méthodes Stimulus :

JavaScript
export default class extends Controller {
    static targets = ["input", "status"];
    static values = { url: String, debounce: { type: Number, default: 600 } };

    #initialValue = null;
    #suppressNextBlur = false;

    async edit(event)        { /* fetch ?mode=edit, swap, focus input */ }
    scheduleSave()           { /* debounce 600 ms → #saveBackground */ }
    saveImmediate()          { /* select/datetime : pas de debounce, change stable */ }
    async enter(event)       { /* flush + sortie vers le mode view */ }
    async cancelKey(event)   { /* revert valeur initiale, sortie sans save */ }
    async blur()             { /* flush + sortie (sauf garde anti-double) */ }

    async #saveBackground()  { /* POST JSON, statut spinner → check → erreur */ }
    async #exitToView()      { /* fetch ?mode=view, swap */ }
}

Le <input> et le <select> n'ont pas la même politique. Sur du texte, on debounce — pas en dessous de 200 ms (flood), pas au-dessus de 800 ms (ça réagit pas). Sur un change de <select> ou de <input type="datetime-local">, la valeur est stable dès le clic, on flush direct.

Le retour utilisateur devient binaire : un spinner pendant le POST, un check vert qui fade, un message d'erreur rouge qui persiste tant qu'on ne retape pas. Plus jamais de « j'ai cliqué sur Valider ou pas ? ». Et accessoirement, plus jamais non plus de « j'ai oublié de valider sur la ligne 3 » — c'est l'autre coût des boutons qu'on oublie une fois sur six.

Le combat contre ea-clickable-row, et la cale silencieuse

EasyAdmin attache un click sur chaque <tr class="ea-clickable-row"> qui te téléporte vers la page d'édition. Pratique pour la navigation normale, hostile pour l'édition inline : un clic sur une cellule lance simultanément ton fetch d'édition ET la navigation EasyAdmin. La navigation gagne, parce qu'elle s'exécute en premier dans le bubble.

Première parade : event.stopPropagation() dans chaque méthode Stimulus. Insuffisant. Tous les clics qui tombent à côté du contenu — dans le padding du <td>, entre le texte et le bord de la cellule — déclenchent la navigation sans même atteindre le code Stimulus. Le stopPropagation ne couvre que les clics qui passent par le frame ; il ne dit rien de ce qui se passe à dix pixels au-dessus.

Deuxième parade, six lignes de CSS qui font tout le boulot :

CSS
td:has(> .inline-edit-frame) {
    pointer-events: none;
}
.inline-edit-frame {
    display: block;
    pointer-events: auto;
}

C'est une cale silencieuse. Le <td> parent ne reçoit plus aucun pointer event. Le frame réactive sa propre boîte, et seulement la sienne. Le clic d'EasyAdmin n'a plus de cible. Le clic sur le frame, lui, atteint Stimulus normalement.

Cette cale ne s'écrit pas — on l'oublie. Pas dans le repo, où elle est dans le CSS ; dans la doc qu'on rédige plus tard pour expliquer la feature. On la zappe parce qu'elle fait deux centimètres. On la zappe surtout parce qu'à la relecture, elle ne « ressemble pas » à de la logique métier — c'est juste du style. Et c'est précisément ce qui en fait la pièce centrale : six lignes qui résolvent deux bugs simultanés sans qu'aucun JavaScript ne sache qu'elles existent.

Décapiter la frappe en plein milieu

Le swap DOM pendant la frappe est un piège qu'on ne voit qu'une fois qu'on s'est cogné dedans.

L'idée naïve : le POST renvoie le fragment HTML re-rendu, le controller fait un swap, l'UI est à jour. Sauf que pendant que tu tapes, le serveur répond, le swap a lieu, et l'input que tu étais en train d'éditer disparaît au milieu d'un mot. Focus perdu, curseur perdu, deux caractères perdus. Sur un titre qu'on tape vite, le user croit que le clavier déconne. C'est un bug qui n'a pas de nom dans aucune issue.

Une seule route, deux contrats de réponse différenciés par le header Accept :

PHP
#[Route('/field/{entityType}/{id}/inline-edit/{field}', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_ADMIN')]
#[Template(template: self::class . '.html.twig')]
final class InlineEditAction extends AbstractController
{
    public function __invoke(string $entityType, string $id, string $field, Request $request): array|JsonResponse
    {
        // … validation entityType + field whitelist + entity exists

        if ($request->isMethod('GET')) {
            return [/* tableau pour le template — mode view ou edit */];
        }

        if (!$this->isCsrfTokenValid('admin_ajax', $request->headers->get('X-CSRF-Token'))) {
            return new JsonResponse(['error' => 'Token CSRF invalide.'], 403);
        }

        $result = $this->inlineEditService->updateField($entity, $field, $value);

        // Save background : JSON, le DOM ne bouge pas, le focus tient.
        if (str_contains((string) $request->headers->get('Accept'), 'application/json')) {
            return $result->hasErrors()
                ? new JsonResponse(['errors' => $result->getErrors()], 422)
                : new JsonResponse(['status' => 'ok']);
        }

        // Transitions view ↔ edit : HTML, on swap volontairement.
        return [/* tableau pour le template */];
    }
}

Le Stimulus controller envoie Accept: application/json sur ses POST de background, Accept: text/html sur les transitions de mode. Le serveur lit l'en-tête et choisit la forme. C'est du REST par la petite porte — un seul controller ADR qui sert deux clients qui ne veulent pas la même réponse.

Le pattern est rejouable pour toute feature qui mêle transitions visuelles et opérations silencieuses : autocomplete avec preview, formulaire en plusieurs étapes, drag-and-drop avec persistance. Cherche inline-edit dans le repo, copie le squelette, oublie EasyAdmin.

PHP 8.4 et les propriétés en lecture publique, écriture privée

PHP 8.4 a inventé une chose qu'on attendait sans le savoir :

PHP
public \DateTimeImmutable $createdAt { private(set); }

Lecture publique, écriture privée. Pratique pour les timestamps Doctrine auto-gérés — $post->createdAt se lit partout, mais seule la classe peut l'écrire. Hostile à un service générique qui assigne un champ par son nom : $entity->createdAt = $value lève une erreur, et PropertyAccessor aussi.

La parade tient en six lignes :

PHP
private function setFieldValue(object $entity, string $field, mixed $value): void
{
    $setter = 'set' . ucfirst($field);
    method_exists($entity, $setter)
        ? $entity->{$setter}($value)
        : $entity->{$field} = $value;
}

Pas de réflexion lourde. Pas de configuration. Un method_exists qui couvre private(set) et les setters historiques avec la même règle, dans cet ordre. Le jour où l'entité expose un setter parce qu'on en a besoin pour autre chose, le service le voit et l'utilise. Le jour où on retire le setter, le service retombe sur l'assignation directe et continue de marcher si la propriété est encore publique. C'est de la dégradation gracieuse en six lignes — l'équivalent du progressive enhancement qu'on faisait en HTML, appliqué à la réflexion PHP.

Le pipeline de conversion qui transforme la string POSTée en type PHP correct mérite aussi son match — c'est lui qui rend le service générique sans le rendre fragile :

PHP
private function convertValue(string $field, string $value): mixed
{
    $type = self::FIELD_TYPES[$field] ?? null;

    return match (true) {
        $type === null                            => $value,
        str_starts_with($type, 'enum:')           => $this->convertEnum($field, $value),
        $type === 'datetime'                      => $this->convertDateTime($value),
        str_starts_with($type, 'association:')    => $this->convertAssociation($field, $value),
        default                                   => $value,
    };
}

Chaque branche retourne soit la valeur convertie, soit un InlineEditResult(false, [erreur]) qui remonte en 422 côté client. La validation Symfony classique passe ensuite — $validator->validate($entity) décide en dernier ressort, filtré sur le propertyPath du champ édité. C'est elle qui rattrape les contraintes métier qu'on a déjà écrites pour le formulaire complet : si le titre doit faire au moins trois caractères, l'inline edit en hérite gratis.

La garde anti-double-flush

Détail qu'on n'identifie qu'en lisant les logs : Entrée et Esc déclenchent un swap DOM qui détruit l'input. La destruction déclenche un blur natif. Le blur est câblé à un re-flush. Résultat : deux POST pour une seule action — l'un utile, l'autre qui réécrit la valeur qu'on vient juste d'écrire.

Un flag privé, deux sites où il s'arme et se désarme :

JavaScript
async enter(event) {
    if (event.shiftKey) return;
    event.preventDefault();
    this.#suppressNextBlur = true;
    if (await this.#saveBackground()) await this.#exitToView();
}

async cancelKey(event) {
    event.preventDefault();
    this.#suppressNextBlur = true;
    this.inputTarget.value = this.#initialValue;
    await this.#exitToView();
}

async blur() {
    if (this.#suppressNextBlur) { this.#suppressNextBlur = false; return; }
    if (this.inputTarget.value === this.#initialValue) {
        await this.#exitToView();
        return;
    }
    if (await this.#saveBackground()) await this.#exitToView();
}

Bonus dans le blur : un short-circuit qui évite le POST quand la valeur n'a pas changé. Un user qui clique sur une cellule par erreur puis ailleurs ne déclenche aucune requête — c'est le genre de détail qu'on n'écrit pas en première passe et qu'on regrette de pas avoir écrit dès qu'on regarde les logs.

La whitelist, et le coût marginal d'une ligne

Le service expose une route générique : /field/{entityType}/{id}/inline-edit/{field}. Sans verrou, un user authentifié peut POSTer ?field=password ou ?field=internalNote et écrire ce qu'il veut où il veut. C'est le même type de problème qu'IDOR — pas dans la stack, dans l'absence d'une étape.

Le verrou tient dans trois constantes du service :

PHP
final readonly class InlineEditService
{
    private const array ENTITY_MAP = [
        'post' => Post::class,
        'page' => Page::class,
        'category' => Category::class,
        'series' => Series::class,
    ];

    private const array EDITABLE_FIELDS = [
        'post' => ['title', 'status', 'createdAt', 'proficiencyLevel', 'category'],
        'page' => ['title', 'status', 'createdAt'],
        'category' => ['title', 'status', 'createdAt'],
        'series' => ['title', 'status'],
    ];

    private const array FIELD_TYPES = [
        'status' => 'enum:status',
        'proficiencyLevel' => 'enum:proficiencyLevel',
        'createdAt' => 'datetime',
        'category' => 'association:category',
    ];
    // …
}

Tout ce qui n'est pas explicitement listé est rejeté en 400. Brancher une nouvelle entité ? Deux lignes : ENTITY_MAP + EDITABLE_FIELDS. Nouveau champ ? Une ligne. Nouveau type spécial (enum, datetime, association) ? Une ligne dans FIELD_TYPES et un case dans le match de conversion.

Le coût marginal d'ajouter une cellule éditable, en code, est inférieur à celui d'ouvrir l'IDE. C'est ce qu'on cherche à atteindre quand on monte une feature comme ça : que le deuxième usage soit gratuit. Sinon on a juste déplacé le boilerplate, on ne l'a pas enlevé.

Asset Mapper, parce que sinon ça ne valait pas la peine

Tout ce qu'on vient de monter — un .js, un .css, des templates Twig, un service PHP — tourne sans Node, sans Webpack Encore, sans node_modules à 800 Mo qui se synchronisent à chaque git pull. Asset Mapper sert les modules ES nativement, avec cache-busting via le hash du contenu et import map pour les dépendances.

En 2026, on continue de monter Webpack pour servir huit fichiers parce qu'on l'a toujours fait comme ça et qu'on n'a jamais essayé autre chose. C'est exactement le type de friction qu'on accepte par habitude — la même catégorie que les trois clics par modification du début. Si la stack frontend imposait un build à chaque modif du Stimulus controller, la friction inline edit ne disparaîtrait pas. Elle se déplacerait du back-office vers le dev environment, et on n'aurait rien gagné — on aurait juste déplacé l'inconfort dans une zone qui ne se voit pas dans les démos.

Ce qui reste à creuser

Trois chantiers ouverts pour qui veut pousser plus loin :

  • L'édition en masse. EasyAdmin a déjà ses batch actions. Connecter l'auto-save inline à une sélection multiple, c'est l'étape logique suivante.
  • Le drag-and-drop pour réordonner. Stimulus + SortableJS + une route /field/.../reorder. Même architecture, sujet différent.
  • L'historique par champ. Sur ce blog, chaque save de Post snapshot via un Doctrine subscriber. L'inline edit en bénéficie gratis. À tester : ce qui se passe quand un user modifie le même champ deux fois en moins d'une seconde — la dedup par hash devrait fusionner.

Le mot de la fin

EasyAdmin par défaut, c'est suffisant. Trois clics par modification, c'est suffisant. La question n'est jamais « est-ce que c'est suffisant » — c'est « qu'est-ce qu'on accepte sans le voir ». Le back-office de 2026 n'a pas à ressembler au back-office de 2014 simplement parce qu'on l'a toujours fait comme ça, et parce qu'EasyAdmin ne propose pas autre chose en sortie de boîte. Les frictions silencieuses sont les plus chères à long terme, précisément parce qu'elles ne se voient nulle part — pas dans les tickets, pas dans les métriques d'usage, pas dans les bug reports.

Si après ce billet vous ouvrez le back-office de votre projet et que vous comptez le nombre de clics pour modifier un titre, il aura servi à quelque chose. Si vous regardez le setTemplatePath(...) de votre PostCrudController et que vous vous demandez combien de cellules éditables se cachent derrière, il aura servi à autre chose encore.

Une coquille, une erreur dans ce billet ? Signale-la-moi.

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 via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.