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 remonte la feature dans l'ordre où on la construit : le décor EasyAdmin, l'endpoint, le service et sa whitelist, le contrôleur Stimulus ligne à ligne, la cale CSS, puis les deux pièges qui ne préviennent pas. Tout le code est celui du blog, tel qu'il tourne en production : chaque fichier se copie, se télécharge, et le projet complet part en zip en fin de billet. Ce qui suit, c'est le montage ET les choix, pas la doc.
Le plan de montage
Neuf fichiers, sept pièces, pas une de plus :
- une config EasyAdmin qui délègue le rendu des cellules (
setTemplatePath) ; - un endpoint ADR unique qui sert le HTML des transitions ET le JSON de l'auto-save ;
- un service générique verrouillé par whitelist (conversion, validation, flush) ;
- un contrôleur Stimulus de six méthodes publiques (debounce, garde anti-double-flush, statut) ;
- quelques lignes de CSS qui neutralisent la navigation EasyAdmin ;
- un helper CSRF stateless partagé avec les autres écritures AJAX de l'admin ;
- deux fragments Twig : la cellule de l'index et le fragment view/edit.
On les monte dans cet ordre. Chaque fenêtre de code ci-dessous porte le fichier entier ; l'explorateur de fin de billet les récapitule tous.
Étape 1 : poser le décor EasyAdmin
Tout part d'un renoncement : on ne touche pas au thème EasyAdmin, on ne fork rien, on ne surcharge aucun CRUD. Le seul point d'entrée utilisé, c'est setTemplatePath() : chaque champ de l'index qu'on veut rendre éditable délègue son rendu à un template maison. Voici l'index des billets, cinq champs éditables :
if (Crud::PAGE_INDEX === $pageName) {
yield TextField::new('id', 'ID');
yield TextField::new('title', 'Titre')
->setTemplatePath('admin/field/inline_editable.html.twig')
->renderAsHtml()
;
yield ChoiceField::new('status', 'Statut')
->setTemplatePath('admin/field/inline_editable_select.html.twig')
->formatValue($this->formatStatusValue(...))
;
yield ChoiceField::new('proficiencyLevel', 'Niveau')
->setTemplatePath('admin/field/inline_editable_select.html.twig')
->formatValue(static function (mixed $value): string {
if ($value instanceof ProficiencyLevel) {
return \sprintf('<span class="badge bg-%s">%s</span>', $value->getColor(), $value->getLabel());
}
return '';
})
;
yield AssociationField::new('category', 'Catégorie')
->setTemplatePath('admin/field/inline_editable_association.html.twig')
;
yield DateTimeField::new('createdAt', 'Date de création')
->setTemplatePath('admin/field/inline_editable_datetime.html.twig')
->setFormat('dd/MM/yyyy HH:mm')
;
// Affiche la date de début de la plage Featured. Si la cellule est
// vide, le billet n'est pas (ou plus) à la une — règle simple à
// scanner visuellement sur la liste. Le calendrier éditorial
// (CAL2) offrira la vue panoramique, ici c'est juste un repère.
yield DateTimeField::new('featuredAt', 'À la une depuis')
->setFormat('dd/MM/yyyy')
;
yield BooleanField::new('isRevival', 'Ressorti')
->renderAsSwitch(false)
;
return;
}Côté template, la cellule rend un frame minimal : la valeur cliquable, l'URL de l'endpoint en data-attribute, le contrôleur Stimulus accroché. Le mode édition n'existe pas encore ici, il arrivera par fetch :
{# Unified inline editable field template.
Supports all field types via optional variables:
- empty_label: text shown when value is empty (default: none)
- raw_value: set to true to render value as raw HTML (e.g., badges)
#}
{% set field_value = field.formattedValue %}
{% set entity_id = entity.primaryKeyValue %}
{% set field_name = field.property %}
{% set entity_type = entity.instance|entity_type %}
{% set edit_url = path('App\\Controller\\Admin\\Action\\Field\\InlineEditAction', { entityType: entity_type, id: entity_id, field: field_name }) %}
<div
id="{{ entity_type }}-{{ entity_id }}-{{ field_name }}"
data-controller="inline-edit"
data-inline-edit-url-value="{{ edit_url }}"
data-action="click->inline-edit#stopBubble"
class="inline-edit-frame"
>
{# Initial view mode - clickable text #}
<span
class="inline-edit-value inline-edit-clickable"
data-action="click->inline-edit#edit"
title="Cliquez pour éditer"
>
{% if raw_value is defined and raw_value %}
{{ field_value|raw }}
{% elseif empty_label is defined %}
{{ field_value ?: empty_label }}
{% else %}
{{ field_value }}
{% endif %}
</span>
</div>Étape 2 : 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. Le fichier complet cette fois, pas le squelette : 124 lignes, les imports repliés, la plage 62-96 mise en avant. C'est elle qui décide qui reçoit du HTML et qui reçoit du JSON :
<?php
declare(strict_types=1);
/*
* This file is part of the Lecodeestdanslepre package.
*
* (c) 2025 Lecodeestdanslepre <https://lecodeestdanslepre.fr>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller\Admin\Action\Field;
use App\Controller\Admin\Action\Concerns\ValidatesAdminAjaxCsrfTrait;
use App\Service\Admin\Field\InlineEditService;
use Symfony\Bridge\Twig\Attribute\Template;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Generic inline edit action for entity fields in EasyAdmin index view.
*
* @return array{entity: object, entityType: string, field: string, editMode: bool, errors?: list<string>}
*/
#[Route('/field/{entityType}/{id}/inline-edit/{field}', name: self::class, methods: ['GET', 'POST'])]
#[IsGranted('ROLE_ADMIN')]
#[Template(template: self::class . '.html.twig')]
final class InlineEditAction extends AbstractController
{
use ValidatesAdminAjaxCsrfTrait;
public function __construct(
private readonly InlineEditService $inlineEditService,
) {}
/**
* @return array{entity: object, entityType: string, field: string, editMode: bool, errors?: list<string>}|JsonResponse
*/
public function __invoke(string $entityType, string $id, string $field, Request $request): array|JsonResponse
{
if (!$this->inlineEditService->isEntityTypeSupported($entityType)) {
throw new NotFoundHttpException(\sprintf('Entity type "%s" is not supported.', $entityType));
}
if (!$this->inlineEditService->isFieldEditable($entityType, $field)) {
throw new BadRequestHttpException(\sprintf('Field "%s" is not editable inline for entity type "%s".', $field, $entityType));
}
$entity = $this->inlineEditService->findEntity($entityType, $id);
if ($entity === null) {
throw new NotFoundHttpException(\sprintf('Entity "%s" with id "%s" not found.', $entityType, $id));
}
if ($request->isMethod('GET')) {
$mode = $request->query->get('mode', 'view');
return [
'entity' => $entity,
'entityType' => $entityType,
'field' => $field,
'editMode' => $mode === 'edit',
];
}
if (($csrfError = $this->validateCsrf($request)) instanceof JsonResponse) {
return $csrfError;
}
$value = $request->request->get('value');
if (!\is_string($value)) {
throw new BadRequestHttpException('Value must be a string.');
}
$inlineEditResult = $this->inlineEditService->updateField($entityType, $entity, $field, $value);
// Auto-save « background » via le controller Stimulus : l'UI ne veut pas
// re-render le fragment HTML (ça détruirait l'input et son focus pendant
// la frappe). On répond JSON, l'UI gère le statut côté client.
if ($this->wantsJsonResponse($request)) {
if ($inlineEditResult->hasErrors()) {
return new JsonResponse(
['errors' => $inlineEditResult->getErrors()],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
return new JsonResponse(['status' => 'ok']);
}
if ($inlineEditResult->hasErrors()) {
return [
'entity' => $entity,
'entityType' => $entityType,
'field' => $field,
'errors' => $inlineEditResult->getErrors(),
'editMode' => true,
];
}
return [
'entity' => $entity,
'entityType' => $entityType,
'field' => $field,
'editMode' => false,
];
}
private function wantsJsonResponse(Request $request): bool
{
$accept = $request->headers->get('Accept', '');
// L'auto-save background envoie explicitement Accept: application/json.
// Les transitions HTML (fetch ?mode=view|edit) envoient Accept: text/html.
return str_contains((string) $accept, 'application/json');
}
}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.
La validation du token CSRF, elle, n'est pas dans le controller. Elle vit dans un trait, ValidatesAdminAjaxCsrfTrait, appelé ici par un simple $this->validateCsrf($request) et partagé par toutes les écritures AJAX de l'admin (la génération SEO, l'upload média, les suggestions IA) : partout le même contrôle stateless, header X-CSRF-Token côté requête, helper csrfHeaders() côté JS. C'est exactement la promesse qu'on cherche à tenir plus bas : le deuxième usage gratuit.
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.
Étape 3 : 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 :
final readonly class InlineEditService
{
private const array ENTITY_MAP = [
'post' => Post::class,
'page' => Page::class,
'category' => Category::class,
'contact_message' => ContactMessage::class,
];
private const array EDITABLE_FIELDS = [
'post' => ['title', 'status', 'createdAt', 'proficiencyLevel', 'category'],
'page' => ['title', 'status', 'createdAt'],
'category' => ['title', 'status', 'createdAt'],
'contact_message' => ['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é. 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 une garde dans la 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é.
Étape 4 : PHP et les propriétés en lecture publique, écriture privée
PHP 8.4 a introduit une chose qu'on attendait sans le savoir (le blog tourne en 8.5 aujourd'hui, mais c'est bien la 8.4 qui a apporté la visibilité asymétrique) :
public private(set) \DateTimeImmutable $createdAt;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 est courte : un setter s'il existe, l'assignation directe sinon, et une garde pour ne jamais écrire sur une propriété qui n'existe pas.
private function setFieldValue(object $entity, string $field, mixed $value): ?InlineEditResult
{
$setter = 'set' . ucfirst($field);
if (method_exists($entity, $setter)) {
$entity->{$setter}($value);
return null;
}
if (!property_exists($entity, $field)) {
return new InlineEditResult(false, [sprintf('Property "%s" does not exist.', $field)]);
}
$entity->{$field} = $value;
return null;
}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, 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 route par type, une garde par famille de champ : c'est lui qui rend le service générique sans le rendre fragile.
private function convertValue(string $entityType, string $field, string $value): mixed
{
$type = $this->getFieldType($field);
if ($type === null) {
return $value; // champ string simple
}
if (str_starts_with($type, 'enum:')) {
return $this->convertEnumValue($entityType, $field, $value);
}
if ($type === 'datetime') {
return $this->convertDateTimeValue($value);
}
if (str_starts_with($type, 'association:')) {
return $this->convertAssociationValue($field, $value);
}
return $value;
}Chaque branche retourne soit la valeur convertie, soit un InlineEditResult(false, [erreur]) qui remonte en 422 côté client. Le $entityType traîne dans la signature pour une raison précise : le champ status d'un contact_message et celui d'un post sont deux enums différents, et sans le type d'entité on ne sait pas lequel désérialiser.
Étape 5 : le contrôleur Stimulus, ligne à ligne
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. Le fichier fait 282 lignes ; il reste sous tes yeux pendant que les étapes défilent :
Étape 1/7
Le snapshot et les réglages
inputTargetConnected capture la valeur initiale à chaque reconnexion du target : le snapshot survit aux swaps DOM, et c'est lui qui permet le revert par Échap.Étape 2/7
Entrer en édition
?mode=edit, un swap innerHTML, le focus posé après coup. Stimulus reconnecte l'input tout seul via le callback de target.Étape 3/7
Deux politiques de save
scheduleSave. Select et datetime : la valeur est stable dès le change, saveImmediate flush direct.Étape 4/7
Entrée et Échap arment la garde
#suppressNextBlur avant le swap : la destruction de l'input va déclencher un blur natif qu'il faudra ignorer.Étape 5/7
Le blur consomme la garde
Étape 6/7
Le POST silencieux
csrfHeaders() et Accept: application/json : le DOM ne bouge pas, le focus tient. Un 422 affiche les erreurs du Validator, un succès rafraîchit le snapshot.Étape 7/7
Le statut qui parle
aria-live.import { Controller } from "@hotwired/stimulus";
import { csrfHeaders } from "../../utils/csrf.js";
/**
* Inline edit controller pour les champs EasyAdmin (mode liste).
*
* Pattern : auto-save optimiste (style Notion / Linear).
*
* - Click sur la cellule → fetch ?mode=edit → l'input prend le focus.
* - Frappe (input event) → debounce 600ms → POST background JSON, le mode
* édition reste actif et le focus est préservé.
* - Entrée → flush immédiat + sortie vers le mode view.
* - Esc → revert à la valeur initiale + sortie sans save.
* - Blur (Tab, clic ailleurs) → flush immédiat + sortie vers view.
* Une garde anti-double-flush évite que le blur déclenché par Entrée/Esc
* re-flush.
*
* Indicateur de statut (target "status") :
* pendant la frappe = vide (pas de flicker d'erreurs Symfony Validator
* sur les saisies intermédiaires) ; pendant le POST = spinner ; après
* succès = check vert qui fade ; en cas d'erreur 422 = message inline.
*/
export default class extends Controller {
static targets = ["input", "status"];
static values = {
url: String,
debounce: { type: Number, default: 600 },
fadeMs: { type: Number, default: 1500 },
};
#initialValue = null;
#debounceTimer = null;
#fadeTimer = null;
#suppressNextBlur = false;
inputTargetConnected(input) {
// Snapshot pour le revert Esc. Capture la valeur DOM courante (peut
// venir d'un round-trip server, donc fiable).
this.#initialValue = input.value;
}
disconnect() {
this.#clearTimers();
}
/**
* Absorbe les clics qui bubble depuis les enfants du frame (input en mode
* édition, badge enum en mode view, etc.) — empêche EasyAdmin de naviguer
* vers la page d'édition complète via `tr.ea-clickable-row`.
*
* NB : le span .inline-edit-clickable a son propre `click->edit` qui fait
* déjà stopPropagation. Ce handler couvre les cas où le clic arrive sur
* un autre enfant du frame (input, select, span de statut).
*/
stopBubble(event) {
event.stopPropagation();
}
/**
* Clic sur le mode "view" → bascule en mode édition.
* (Inchangé fonctionnellement par rapport à la version précédente.)
*/
async edit(event) {
event.preventDefault();
event.stopPropagation();
try {
const response = await fetch(`${this.urlValue}?mode=edit`, {
headers: { Accept: "text/html" },
});
if (!response.ok) {
throw new Error("Failed to fetch edit form");
}
this.element.innerHTML = await response.text();
// Stimulus reconnecte le target input via inputTargetConnected ;
// on lui donne juste le focus + sélection après le swap DOM.
setTimeout(() => {
if (!this.hasInputTarget) return;
const input = this.inputTarget;
input.focus();
if (input.tagName === "INPUT" && input.type === "text") {
input.select();
}
}, 50);
} catch (error) {
console.error("Error switching to edit mode:", error);
}
}
/**
* Frappe sur input texte → planifie un save background dans 600ms.
*/
scheduleSave() {
this.#clearStatus();
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
this.#debounceTimer = setTimeout(() => this.#saveBackground(), this.debounceValue);
}
/**
* Change sur select / datetime → save immédiat (pas besoin de debounce,
* la valeur est stable dès le change).
*/
saveImmediate() {
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
this.#saveBackground();
}
/**
* Entrée → flush + sortie vers le mode view (si save OK).
*/
async enter(event) {
if (event.shiftKey) return;
event.preventDefault();
this.#suppressNextBlur = true;
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
const ok = await this.#saveBackground();
if (ok) {
await this.#exitToView();
}
// Si KO, on reste en mode édition pour permettre la correction.
// L'erreur est déjà affichée par #saveBackground.
}
/**
* Esc → revert à la valeur initiale + sortie sans save.
*/
async cancelKey(event) {
event.preventDefault();
this.#suppressNextBlur = true;
this.#clearTimers();
if (this.hasInputTarget && this.#initialValue !== null) {
this.inputTarget.value = this.#initialValue;
}
await this.#exitToView();
}
/**
* Blur → flush + sortie. Garde anti-double-flush si déjà déclenché par
* Entrée ou Esc (qui appellent input.blur() implicitement via le swap).
*/
async blur() {
if (this.#suppressNextBlur) {
this.#suppressNextBlur = false;
return;
}
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
// Si la valeur n'a pas changé, on sort direct sans POST inutile.
if (this.hasInputTarget && this.inputTarget.value === this.#initialValue) {
await this.#exitToView();
return;
}
const ok = await this.#saveBackground();
if (ok) {
await this.#exitToView();
}
}
/**
* POST silencieux : ne touche pas au DOM, met à jour le statut.
*
* @returns {Promise<boolean>} true si save OK, false sinon.
*/
async #saveBackground() {
if (!this.hasInputTarget) return false;
this.#setStatusSaving();
const formData = new FormData();
formData.append("value", this.inputTarget.value);
try {
const response = await fetch(this.urlValue, {
method: "POST",
body: formData,
headers: csrfHeaders({ Accept: "application/json" }),
});
if (response.ok) {
this.#setStatusSaved();
// Refresh de la valeur "initiale" : ce qui est en base maintenant.
this.#initialValue = this.inputTarget.value;
return true;
}
if (response.status === 422) {
const data = await response.json().catch(() => ({}));
const errors =
Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : ["Validation a échoué."];
this.#setStatusError(errors);
return false;
}
this.#setStatusError(["Erreur réseau."]);
return false;
} catch (error) {
console.error("Inline edit save failed:", error);
this.#setStatusError(["Erreur réseau."]);
return false;
}
}
/**
* Bascule l'élément en mode "view" via re-fetch HTML.
*/
async #exitToView() {
try {
const response = await fetch(`${this.urlValue}?mode=view`, {
headers: { Accept: "text/html" },
});
if (!response.ok) throw new Error("Failed to fetch view");
this.element.innerHTML = await response.text();
} catch (error) {
console.error("Error returning to view mode:", error);
}
}
#setStatusSaving() {
if (!this.hasStatusTarget) return;
if (this.#fadeTimer) clearTimeout(this.#fadeTimer);
this.statusTarget.className = "inline-edit-status inline-edit-status--saving";
this.statusTarget.innerHTML =
'<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>' +
'<span class="visually-hidden">Enregistrement…</span>';
}
#setStatusSaved() {
if (!this.hasStatusTarget) return;
if (this.#fadeTimer) clearTimeout(this.#fadeTimer);
this.statusTarget.className = "inline-edit-status inline-edit-status--saved";
this.statusTarget.innerHTML =
'<i class="fas fa-check" aria-hidden="true"></i>' + '<span class="visually-hidden">Enregistré</span>';
this.#fadeTimer = setTimeout(() => {
if (this.hasStatusTarget && this.statusTarget.classList.contains("inline-edit-status--saved")) {
this.statusTarget.classList.add("inline-edit-status--fading");
}
}, this.fadeMsValue);
}
#setStatusError(messages) {
if (!this.hasStatusTarget) return;
if (this.#fadeTimer) clearTimeout(this.#fadeTimer);
this.statusTarget.className = "inline-edit-status inline-edit-status--error";
this.statusTarget.innerHTML = messages
.map((msg) => `<span class="inline-edit-error">${this.#escape(msg)}</span>`)
.join(" ");
}
#clearStatus() {
if (!this.hasStatusTarget) return;
if (this.#fadeTimer) clearTimeout(this.#fadeTimer);
// On ne clear l'erreur QUE si l'utilisateur tape de nouveau — donne
// le temps de lire le message avant qu'il disparaisse.
const hadError = this.statusTarget.classList.contains("inline-edit-status--error");
if (hadError) {
this.statusTarget.className = "inline-edit-status";
this.statusTarget.innerHTML = "";
}
}
#clearTimers() {
if (this.#debounceTimer) {
clearTimeout(this.#debounceTimer);
this.#debounceTimer = null;
}
if (this.#fadeTimer) {
clearTimeout(this.#fadeTimer);
this.#fadeTimer = null;
}
}
#escape(str) {
const div = document.createElement("div");
div.textContent = String(str);
return div.innerHTML;
}
}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 deux.
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. D'où la garde des étapes 4 et 5 ci-dessus.
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.
Étape 6 : 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, quelques lignes de CSS qui font tout le boulot :
td:has(> .inline-edit-frame) {
pointer-events: none;
}
.inline-edit-frame {
display: block;
min-width: 0;
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 : quelques lignes qui résolvent deux bugs simultanés sans qu'aucun JavaScript ne sache qu'elles existent.
Étape 7 : le 403 du token stateless
Le helper csrfHeaders() génère un token aléatoire côté client et le pose en double : un cookie SameSite strict et un header X-CSRF-Token. Côté Symfony, isCsrfTokenValid('admin_ajax', …) compare les deux. Monté tel quel, le pattern a l'air complet. Il répond pourtant 403 sur chaque POST.
La parade tient en une ligne de configuration : un token stateless n'existe pour Symfony que s'il est déclaré dans stateless_token_ids. Sans cette déclaration, le framework retombe sur le gestionnaire de tokens de session, qui n'a jamais vu ce token généré côté client, et rejette. La ligne se lit mieux en diff qu'en prose, bascule sur « Fichier complet » pour la config résultante :
<?php
declare(strict_types=1);
/*
* This file is part of the Lecodeestdanslepre package.
*
* (c) 2025 Lecodeestdanslepre <https://lecodeestdanslepre.fr>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'form' => [
'csrf_protection' => [
'token_id' => 'submit',
],
],
'csrf_protection' => [
supprimé : − 'stateless_token_ids' => ['submit', 'authenticate', 'logout'],
ajouté : + 'stateless_token_ids' => ['submit', 'authenticate', 'logout', 'admin_ajax'],
],
]);
};Une ligne. C'est le coût réel d'un pattern stateless : il traverse trois couches (le JS qui génère, le cookie qui double, la config framework qui déclare), et la couche config est celle qu'on oublie parce qu'elle ne se voit dans aucun diff de code applicatif.
Étape 8 : le flush qui ne devait pas avoir lieu
La validation Symfony classique passe ensuite : $validator->validate($entity) décide en dernier ressort. Le piège est là : ne garder que les violations dont le propertyPath matche le champ édité, et flusher quand même quand aucune ne matche. Le titre passe, mais l'entité repart cassée sur un autre champ. La règle correcte : toute violation bloque le flush ; on remonte les erreurs du champ édité si elles existent, sinon la liste complète. C'est elle qui rattrape les contraintes métier déjà écrites pour le formulaire complet : si le titre doit faire au moins trois caractères, l'inline edit en hérite gratis.
Le piège et sa parade, sur la méthode updateField() :
public function updateField(string $entityType, object $entity, string $field, string $value): InlineEditResult
{
// Convert value based on field type
$convertedValue = $this->convertValue($entityType, $field, $value);
if ($convertedValue instanceof InlineEditResult) {
return $convertedValue;
}
// Update the field value using appropriate method
$updateResult = $this->setFieldValue($entity, $field, $convertedValue);
if ($updateResult instanceof InlineEditResult) {
return $updateResult;
}
supprimé : − // Validate the entity
ajouté : + // Validate the entity. ANY violation blocks the flush — the previous
ajouté : + // code only collected violations whose path matched the edited field
ajouté : + // and, when none did, fell through and flushed an entity that was
ajouté : + // still invalid on another field (#1392). Surface the edited field's
ajouté : + // errors when present, otherwise the full list, but never persist an
ajouté : + // invalid entity.
$constraintViolationList = $this->validator->validate($entity);
if (\count($constraintViolationList) > 0) {
supprimé : − $errors = [];
ajouté : + $allErrors = [];
ajouté : + $fieldErrors = [];
foreach ($constraintViolationList as $violation) {
supprimé : − $propertyPath = $violation->getPropertyPath();
supprimé : − if (str_contains($propertyPath, $field)) {
supprimé : − $errors[] = (string) $violation->getMessage();
ajouté : + $message = (string) $violation->getMessage();
ajouté : + $allErrors[] = $message;
ajouté : + if (str_contains($violation->getPropertyPath(), $field)) {
ajouté : + $fieldErrors[] = $message;
}
}
supprimé : − if ($errors !== []) {
supprimé : − return new InlineEditResult(false, $errors);
supprimé : − }
ajouté : + return new InlineEditResult(false, $fieldErrors !== [] ? $fieldErrors : $allErrors);
}
// Persist changes (cache is automatically invalidated by ContentChangeDoctrineSubscriber)
$this->entityManager->flush();
return new InlineEditResult(true);
}Le test unitaire qui verrouille la règle dit exactement ça : un mock d'EntityManager qui attend zéro appel à flush() quand la violation porte sur un autre champ que celui qu'on édite.
Le projet complet, à emporter
Neuf fichiers, c'est tout ce que pèse la feature. L'explorateur ci-dessous les contient en entier : chaque fichier se copie, se télécharge, et « Tout ·ZIP » assemble l'archive directement dans le navigateur.
assets/admin/controllers/inline_edit_controller.js
import { Controller } from "@hotwired/stimulus";
import { csrfHeaders } from "../../utils/csrf.js";
/**
* Inline edit controller pour les champs EasyAdmin (mode liste).
*
* Pattern : auto-save optimiste (style Notion / Linear).
*
* - Click sur la cellule → fetch ?mode=edit → l'input prend le focus.
* - Frappe (input event) → debounce 600ms → POST background JSON, le mode
* édition reste actif et le focus est préservé.
* - Entrée → flush immédiat + sortie vers le mode view.
* - Esc → revert à la valeur initiale + sortie sans save.
* - Blur (Tab, clic ailleurs) → flush immédiat + sortie vers view.
* Une garde anti-double-flush évite que le blur déclenché par Entrée/Esc
* re-flush.
*
* Indicateur de statut (target "status") :
* pendant la frappe = vide (pas de flicker d'erreurs Symfony Validator
* sur les saisies intermédiaires) ; pendant le POST = spinner ; après
* succès = check vert qui fade ; en cas d'erreur 422 = message inline.
*/
export default class extends Controller {
static targets = ["input", "status"];
static values = {
url: String,
debounce: { type: Number, default: 600 },
fadeMs: { type: Number, default: 1500 },
};
#initialValue = null;
#debounceTimer = null;
#fadeTimer = null;
#suppressNextBlur = false;
inputTargetConnected(input) {
// Snapshot pour le revert Esc. Capture la valeur DOM courante (peut
// venir d'un round-trip server, donc fiable).
this.#initialValue = input.value;
}
disconnect() {
this.#clearTimers();
}
/**
* Absorbe les clics qui bubble depuis les enfants du frame (input en mode
* édition, badge enum en mode view, etc.) — empêche EasyAdmin de naviguer
* vers la page d'édition complète via `tr.ea-clickable-row`.
*
* NB : le span .inline-edit-clickable a son propre `click->edit` qui fait
* déjà stopPropagation. Ce handler couvre les cas où le clic arrive sur
* un autre enfant du frame (input, select, span de statut).
*/
stopBubble(event) {
event.stopPropagation();
}
/**
* Clic sur le mode "view" → bascule en mode édition.
* (Inchangé fonctionnellement par rapport à la version précédente.)
*/
async edit(event) {
event.preventDefault();
event.stopPropagation();
try {
const response = await fetch(`${this.urlValue}?mode=edit`, {
headers: { Accept: "text/html" },
});
if (!response.ok) {
throw new Error("Failed to fetch edit form");
}
this.element.innerHTML = await response.text();
// Stimulus reconnecte le target input via inputTargetConnected ;
// on lui donne juste le focus + sélection après le swap DOM.
setTimeout(() => {
if (!this.hasInputTarget) return;
const input = this.inputTarget;
input.focus();
if (input.tagName === "INPUT" && input.type === "text") {
input.select();
}
}, 50);
} catch (error) {
console.error("Error switching to edit mode:", error);
}
}
/**
* Frappe sur input texte → planifie un save background dans 600ms.
*/
scheduleSave() {
this.#clearStatus();
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
this.#debounceTimer = setTimeout(() => this.#saveBackground(), this.debounceValue);
}
/**
* Change sur select / datetime → save immédiat (pas besoin de debounce,
* la valeur est stable dès le change).
*/
saveImmediate() {
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
this.#saveBackground();
}
/**
* Entrée → flush + sortie vers le mode view (si save OK).
*/
async enter(event) {
if (event.shiftKey) return;
event.preventDefault();
this.#suppressNextBlur = true;
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
const ok = await this.#saveBackground();
if (ok) {
await this.#exitToView();
}
// Si KO, on reste en mode édition pour permettre la correction.
// L'erreur est déjà affichée par #saveBackground.
}
/**
* Esc → revert à la valeur initiale + sortie sans save.
*/
async cancelKey(event) {
event.preventDefault();
this.#suppressNextBlur = true;
this.#clearTimers();
if (this.hasInputTarget && this.#initialValue !== null) {
this.inputTarget.value = this.#initialValue;
}
await this.#exitToView();
}
/**
* Blur → flush + sortie. Garde anti-double-flush si déjà déclenché par
* Entrée ou Esc (qui appellent input.blur() implicitement via le swap).
*/
async blur() {
if (this.#suppressNextBlur) {
this.#suppressNextBlur = false;
return;
}
if (this.#debounceTimer) clearTimeout(this.#debounceTimer);
// Si la valeur n'a pas changé, on sort direct sans POST inutile.
if (this.hasInputTarget && this.inputTarget.value === this.#initialValue) {
await this.#exitToView();
return;
}
const ok = await this.#saveBackground();
if (ok) {
await this.#exitToView();
}
}
/**
* POST silencieux : ne touche pas au DOM, met à jour le statut.
*
* @returns {Promise<boolean>} true si save OK, false sinon.
*/
async #saveBackground() {
if (!this.hasInputTarget) return false;
this.#setStatusSaving();
const formData = new FormData();
formData.append("value", this.inputTarget.value);
try {
const response = await fetch(this.urlValue, {
method: "POST",
body: formData,
headers: csrfHeaders({ Accept: "application/json" }),
});
if (response.ok) {
this.#setStatusSaved();
// Refresh de la valeur "initiale" : ce qui est en base maintenant.
this.#initialValue = this.inputTarget.value;
return true;
}
if (response.status === 422) {
const data = await response.json().catch(() => ({}));
const errors =
Array.isArray(data.errors) && data.errors.length > 0 ? data.errors : ["Validation a échoué."];
this.#setStatusError(errors);
return false;
}
this.#setStatusError(["Erreur réseau."]);
return false;
} catch (error) {
console.error("Inline edit save failed:", error);
this.#setStatusError(["Erreur réseau."]);
return false;
}
}
/**
* Bascule l'élément en mode "view" via re-fetch HTML.
*/
async #exitToView() {
try {
const response = await fetch(`${this.urlValue}?mode=view`, {
headers: { Accept: "text/html" },
});
if (!response.ok) throw new Error("Failed to fetch view");
this.element.innerHTML = await response.text();
} catch (error) {
console.error("Error returning to view mode:", error);
}
}
#setStatusSaving() {
if (!this.hasStatusTarget) return;
if (this.#fadeTimer) clearTimeout(this.#fadeTimer);
this.statusTarget.className = "inline-edit-status inline-edit-status--saving";
this.statusTarget.innerHTML =
'<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>' +
'<span class="visually-hidden">Enregistrement…</span>';
}
#setStatusSaved() {
if (!this.hasStatusTarget) return;
if (this.#fadeTimer) clearTimeout(this.#fadeTimer);
this.statusTarget.className = "inline-edit-status inline-edit-status--saved";
this.statusTarget.innerHTML =
'<i class="fas fa-check" aria-hidden="true"></i>' + '<span class="visually-hidden">Enregistré</span>';
this.#fadeTimer = setTimeout(() => {
if (this.hasStatusTarget && this.statusTarget.classList.contains("inline-edit-status--saved")) {
this.statusTarget.classList.add("inline-edit-status--fading");
}
}, this.fadeMsValue);
}
#setStatusError(messages) {
if (!this.hasStatusTarget) return;
if (this.#fadeTimer) clearTimeout(this.#fadeTimer);
this.statusTarget.className = "inline-edit-status inline-edit-status--error";
this.statusTarget.innerHTML = messages
.map((msg) => `<span class="inline-edit-error">${this.#escape(msg)}</span>`)
.join(" ");
}
#clearStatus() {
if (!this.hasStatusTarget) return;
if (this.#fadeTimer) clearTimeout(this.#fadeTimer);
// On ne clear l'erreur QUE si l'utilisateur tape de nouveau — donne
// le temps de lire le message avant qu'il disparaisse.
const hadError = this.statusTarget.classList.contains("inline-edit-status--error");
if (hadError) {
this.statusTarget.className = "inline-edit-status";
this.statusTarget.innerHTML = "";
}
}
#clearTimers() {
if (this.#debounceTimer) {
clearTimeout(this.#debounceTimer);
this.#debounceTimer = null;
}
if (this.#fadeTimer) {
clearTimeout(this.#fadeTimer);
this.#fadeTimer = null;
}
}
#escape(str) {
const div = document.createElement("div");
div.textContent = String(str);
return div.innerHTML;
}
}assets/utils/csrf.js
export const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
export const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
/**
* Generate CSRF headers for admin AJAX requests (SameOrigin double-submit cookie pattern).
*
* Sets a SameSite=Strict cookie and returns a headers object containing both
* the csrf-token and X-CSRF-Token headers for Symfony's SameOriginCsrfTokenManager.
*
* @param {object} [extra={}] - Additional headers to merge (e.g. Content-Type)
* @returns {object} Headers object with CSRF token headers + any extras
*/
export function csrfHeaders(extra = {}) {
const tokenId = "csrf-token";
const token = btoa(String.fromCharCode.apply(null, crypto.getRandomValues(new Uint8Array(18))));
if (nameCheck.test(tokenId) && tokenCheck.test(token)) {
const cookie = `${tokenId}_${token}=${tokenId}; path=/; samesite=strict`;
// biome-ignore lint/suspicious/noDocumentCookie: CSRF double-submit cookie pattern required by Symfony's SameOriginCsrfTokenManager
document.cookie = window.location.protocol === "https:" ? `__Host-${cookie}; secure` : cookie;
}
// Spread `extra` EN PREMIER pour que les headers CSRF gagnent en cas de
// conflit. Sinon un caller qui passe accidentellement (ou via un path
// user-influencé) `{ "csrf-token": "..." }` clobberait le token généré
// — défense en profondeur, coût zéro pour les usages légitimes
// (Content-Type, Accept, etc., ne collisionnent jamais avec CSRF).
return {
...extra,
[tokenId]: token,
"X-CSRF-Token": token,
};
}assets/admin/inline-edit.css
/**
* Styles for inline editing in EasyAdmin
*/
/* La cellule TD parente est désactivée pour le pointer (zone padding inerte),
le frame récupère la cliquabilité. Combiné au stopBubble du frame, ça
neutralise la nav par défaut d'EasyAdmin (`tr.ea-clickable-row`) sans
casser le hover ailleurs sur la ligne. */
td:has(> .inline-edit-frame) {
pointer-events: none;
}
.inline-edit-frame {
display: block;
min-width: 0;
pointer-events: auto;
}
.inline-edit-clickable {
border-radius: 0.25rem;
cursor: pointer;
display: block;
padding: 0.25rem 0.5rem;
transition: background-color 0.2s ease;
width: 100%;
}
.inline-edit-clickable:hover {
background-color: color-mix(in oklab, var(--color-accent, #ec305a) 10%, transparent);
}
.inline-edit-form {
align-items: center;
display: flex;
gap: 0.5rem;
}
/* Indicateur de statut auto-save : icône discrète à droite de l'input.
Largeur minimale fixe pour éviter le layout shift quand l'icône apparaît. */
.inline-edit-status {
align-items: center;
color: var(--color-text-secondary, #6c757d);
display: inline-flex;
font-size: 0.85rem;
min-width: 1.25rem;
transition: opacity 0.3s ease;
}
.inline-edit-status--saving {
color: var(--color-text-secondary, #6c757d);
}
.inline-edit-status--saved {
color: var(--bs-success, #198754);
}
.inline-edit-status--saved.inline-edit-status--fading {
opacity: 0;
}
.inline-edit-status--error {
color: var(--bs-danger, #dc3545);
}
.inline-edit-error {
color: var(--bs-danger, #dc3545);
font-size: 0.85rem;
}src/Controller/Admin/Action/Field/InlineEditAction.php
<?php
declare(strict_types=1);
/*
* This file is part of the Lecodeestdanslepre package.
*
* (c) 2025 Lecodeestdanslepre <https://lecodeestdanslepre.fr>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller\Admin\Action\Field;
use App\Controller\Admin\Action\Concerns\ValidatesAdminAjaxCsrfTrait;
use App\Service\Admin\Field\InlineEditService;
use Symfony\Bridge\Twig\Attribute\Template;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Generic inline edit action for entity fields in EasyAdmin index view.
*
* @return array{entity: object, entityType: string, field: string, editMode: bool, errors?: list<string>}
*/
#[Route('/field/{entityType}/{id}/inline-edit/{field}', name: self::class, methods: ['GET', 'POST'])]
#[IsGranted('ROLE_ADMIN')]
#[Template(template: self::class . '.html.twig')]
final class InlineEditAction extends AbstractController
{
use ValidatesAdminAjaxCsrfTrait;
public function __construct(
private readonly InlineEditService $inlineEditService,
) {}
/**
* @return array{entity: object, entityType: string, field: string, editMode: bool, errors?: list<string>}|JsonResponse
*/
public function __invoke(string $entityType, string $id, string $field, Request $request): array|JsonResponse
{
if (!$this->inlineEditService->isEntityTypeSupported($entityType)) {
throw new NotFoundHttpException(\sprintf('Entity type "%s" is not supported.', $entityType));
}
if (!$this->inlineEditService->isFieldEditable($entityType, $field)) {
throw new BadRequestHttpException(\sprintf('Field "%s" is not editable inline for entity type "%s".', $field, $entityType));
}
$entity = $this->inlineEditService->findEntity($entityType, $id);
if ($entity === null) {
throw new NotFoundHttpException(\sprintf('Entity "%s" with id "%s" not found.', $entityType, $id));
}
if ($request->isMethod('GET')) {
$mode = $request->query->get('mode', 'view');
return [
'entity' => $entity,
'entityType' => $entityType,
'field' => $field,
'editMode' => $mode === 'edit',
];
}
if (($csrfError = $this->validateCsrf($request)) instanceof JsonResponse) {
return $csrfError;
}
$value = $request->request->get('value');
if (!\is_string($value)) {
throw new BadRequestHttpException('Value must be a string.');
}
$inlineEditResult = $this->inlineEditService->updateField($entityType, $entity, $field, $value);
// Auto-save « background » via le controller Stimulus : l'UI ne veut pas
// re-render le fragment HTML (ça détruirait l'input et son focus pendant
// la frappe). On répond JSON, l'UI gère le statut côté client.
if ($this->wantsJsonResponse($request)) {
if ($inlineEditResult->hasErrors()) {
return new JsonResponse(
['errors' => $inlineEditResult->getErrors()],
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
return new JsonResponse(['status' => 'ok']);
}
if ($inlineEditResult->hasErrors()) {
return [
'entity' => $entity,
'entityType' => $entityType,
'field' => $field,
'errors' => $inlineEditResult->getErrors(),
'editMode' => true,
];
}
return [
'entity' => $entity,
'entityType' => $entityType,
'field' => $field,
'editMode' => false,
];
}
private function wantsJsonResponse(Request $request): bool
{
$accept = $request->headers->get('Accept', '');
// L'auto-save background envoie explicitement Accept: application/json.
// Les transitions HTML (fetch ?mode=view|edit) envoient Accept: text/html.
return str_contains((string) $accept, 'application/json');
}
}src/Controller/Admin/Action/Concerns/ValidatesAdminAjaxCsrfTrait.php
<?php
declare(strict_types=1);
/*
* This file is part of the Lecodeestdanslepre package.
*
* (c) 2025 Lecodeestdanslepre <https://lecodeestdanslepre.fr>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller\Admin\Action\Concerns;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Validates the stateless `admin_ajax` CSRF token carried by the `X-CSRF-Token`
* header on admin AJAX writes (double-submit cookie pattern, see the front-end
* `csrfHeaders()` helper in `assets/utils/csrf.js`).
*
* Factored here so every admin write endpoint (AI generation, inline edit,
* media upload/patch/delete) enforces the exact same check instead of relying
* on `SameSite=lax` alone. Must be used inside an
* {@see \Symfony\Bundle\FrameworkBundle\Controller\AbstractController} — it
* calls `isCsrfTokenValid()`.
*/
trait ValidatesAdminAjaxCsrfTrait
{
/**
* Returns a 403 JSON response when the token is missing or invalid, or null
* when the request is authorised to proceed.
*/
protected function validateCsrf(Request $request): ?JsonResponse
{
if (!$this->isCsrfTokenValid('admin_ajax', $request->headers->get('X-CSRF-Token'))) {
return new JsonResponse(['error' => 'Token CSRF invalide.'], Response::HTTP_FORBIDDEN);
}
return null;
}
}src/Service/Admin/Field/InlineEditService.php
<?php
declare(strict_types=1);
/*
* This file is part of the Lecodeestdanslepre package.
*
* (c) 2025 Lecodeestdanslepre <https://lecodeestdanslepre.fr>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Service\Admin\Field;
use App\Dto\Admin\InlineEditResult;
use App\Entity\Contact\ContactMessage;
use App\Entity\Content\Category;
use App\Entity\Content\Page;
use App\Entity\Content\Post;
use App\Enum\Contact\ContactMessageStatus;
use App\Enum\Content\ContentStatus;
use App\Enum\Content\ProficiencyLevel;
use App\Repository\Content\CategoryRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Generic service for inline editing of entity fields.
*
* Cache invalidation is automatically handled by ContentChangeDoctrineSubscriber.
*/
final readonly class InlineEditService
{
/**
* Mapping of entity type slugs to their FQCN.
*
* @var array<string, class-string>
*/
private const array ENTITY_MAP = [
'post' => Post::class,
'page' => Page::class,
'category' => Category::class,
'contact_message' => ContactMessage::class,
];
/**
* Whitelisted fields that can be edited inline, per entity type.
*
* @var array<string, list<string>>
*/
private const array EDITABLE_FIELDS = [
'post' => ['title', 'status', 'createdAt', 'proficiencyLevel', 'category'],
'page' => ['title', 'status', 'createdAt'],
'category' => ['title', 'status', 'createdAt'],
'contact_message' => ['status'],
];
/**
* Field type configuration for special handling.
*
* @var array<string, string>
*/
private const array FIELD_TYPES = [
'status' => 'enum:status',
'proficiencyLevel' => 'enum:proficiencyLevel',
'createdAt' => 'datetime',
'category' => 'association:category',
];
public function __construct(
private EntityManagerInterface $entityManager,
private ValidatorInterface $validator,
private CategoryRepository $categoryRepository,
) {}
/**
* Get the entity class for a given type slug.
*
* @return class-string|null
*
* @api
*/
public function getEntityClass(string $entityType): ?string
{
return self::ENTITY_MAP[$entityType] ?? null;
}
/**
* Check if an entity type is supported.
*/
public function isEntityTypeSupported(string $entityType): bool
{
return isset(self::ENTITY_MAP[$entityType]);
}
/**
* Check if a field is editable for a given entity type.
*/
public function isFieldEditable(string $entityType, string $field): bool
{
return \in_array($field, self::EDITABLE_FIELDS[$entityType] ?? [], true);
}
/**
* Get the field type configuration.
*/
public function getFieldType(string $field): ?string
{
return self::FIELD_TYPES[$field] ?? null;
}
/**
* Get options for enum fields.
*
* @return array<string, string>
*/
public function getEnumOptions(string $entityType, string $field): array
{
return match (true) {
'contact_message' === $entityType && 'status' === $field => array_combine(
array_map(static fn(ContactMessageStatus $contactMessageStatusEnum): string => $contactMessageStatusEnum->value, ContactMessageStatus::cases()),
array_map(static fn(ContactMessageStatus $contactMessageStatusEnum): string => $contactMessageStatusEnum->getLabel(), ContactMessageStatus::cases()),
),
'status' === $field => $this->contentStatusOptions(),
'proficiencyLevel' === $field => array_combine(
array_map(static fn(ProficiencyLevel $proficiencyLevelEnum): string => $proficiencyLevelEnum->value, ProficiencyLevel::cases()),
array_map(static fn(ProficiencyLevel $proficiencyLevelEnum): string => $proficiencyLevelEnum->getLabel(), ProficiencyLevel::cases()),
),
default => [],
};
}
/**
* Options de statut proposées en édition inline.
*
* @return array<string, string>
*/
private function contentStatusOptions(): array
{
$selectable = ContentStatus::cases();
return array_combine(
array_map(static fn(ContentStatus $status): string => $status->value, $selectable),
array_map(static fn(ContentStatus $status): string => $status->getLabel(), $selectable),
);
}
/**
* Get options for association fields.
*
* @return array<string, string>
*/
public function getAssociationOptions(string $field): array
{
return match ($field) {
'category' => $this->getCategoryOptions(),
default => [],
};
}
/**
* Find an entity by type and ID.
*/
public function findEntity(string $entityType, string $id): ?object
{
$entityClass = $this->getEntityClass($entityType);
if ($entityClass === null) {
return null;
}
return $this->entityManager->getRepository($entityClass)->find($id);
}
/**
* Update a single field on an entity.
*
* @return InlineEditResult Result with success status and potential errors
*/
public function updateField(string $entityType, object $entity, string $field, string $value): InlineEditResult
{
// Convert value based on field type
$convertedValue = $this->convertValue($entityType, $field, $value);
if ($convertedValue instanceof InlineEditResult) {
return $convertedValue;
}
// Update the field value using appropriate method
$updateResult = $this->setFieldValue($entity, $field, $convertedValue);
if ($updateResult instanceof InlineEditResult) {
return $updateResult;
}
// Validate the entity. ANY violation blocks the flush — the previous
// code only collected violations whose path matched the edited field
// and, when none did, fell through and flushed an entity that was
// still invalid on another field (#1392). Surface the edited field's
// errors when present, otherwise the full list, but never persist an
// invalid entity.
$constraintViolationList = $this->validator->validate($entity);
if (\count($constraintViolationList) > 0) {
$allErrors = [];
$fieldErrors = [];
foreach ($constraintViolationList as $violation) {
$message = (string) $violation->getMessage();
$allErrors[] = $message;
if (str_contains($violation->getPropertyPath(), $field)) {
$fieldErrors[] = $message;
}
}
return new InlineEditResult(false, $fieldErrors !== [] ? $fieldErrors : $allErrors);
}
// Persist changes (cache is automatically invalidated by ContentChangeDoctrineSubscriber)
$this->entityManager->flush();
return new InlineEditResult(true);
}
/**
* Convert string value to the appropriate type based on field configuration.
*/
private function convertValue(string $entityType, string $field, string $value): mixed
{
$fieldType = $this->getFieldType($field);
if ($fieldType === null) {
// Simple string field
return $value;
}
if (str_starts_with($fieldType, 'enum:')) {
return $this->convertEnumValue($entityType, $field, $value);
}
if ($fieldType === 'datetime') {
return $this->convertDateTimeValue($value);
}
if (str_starts_with($fieldType, 'association:')) {
return $this->convertAssociationValue($field, $value);
}
return $value;
}
/**
* Convert string to enum value.
*/
private function convertEnumValue(string $entityType, string $field, string $value): ContentStatus|ProficiencyLevel|ContactMessageStatus|InlineEditResult
{
return match (true) {
'contact_message' === $entityType && 'status' === $field => ContactMessageStatus::tryFrom($value) ?? new InlineEditResult(false, ['Statut invalide.']),
'status' === $field => ContentStatus::tryFrom($value) ?? new InlineEditResult(false, ['Statut invalide.']),
'proficiencyLevel' === $field => ProficiencyLevel::tryFrom($value) ?? new InlineEditResult(false, ['Niveau invalide.']),
default => new InlineEditResult(false, ['Type enum inconnu.']),
};
}
/**
* Convert string to DateTimeImmutable.
*/
private function convertDateTimeValue(string $value): \DateTimeImmutable|InlineEditResult|null
{
if ($value === '') {
return null;
}
try {
return new \DateTimeImmutable($value);
} catch (\Exception) {
return new InlineEditResult(false, ['Format de date invalide.']);
}
}
/**
* Convert string ID to association entity.
*/
private function convertAssociationValue(string $field, string $value): ?object
{
if ($value === '') {
return null;
}
return match ($field) {
'category' => $this->categoryRepository->find($value),
default => null,
};
}
/**
* Set field value on entity, handling special cases like private(set) properties.
*
* Dynamic method and property access are intentionally used here but all possible
* values are known and validated via FIELD_TYPES and EDITABLE_FIELDS.
*/
private function setFieldValue(object $entity, string $field, mixed $value): ?InlineEditResult
{
// Handle fields with setters (like createdAt which uses private(set))
$setter = 'set' . ucfirst($field);
if (method_exists($entity, $setter)) {
// @phpstan-ignore-next-line Dynamic method call
$entity->{$setter}($value);
return null;
}
// Direct property assignment
if (!property_exists($entity, $field)) {
return new InlineEditResult(false, [\sprintf('Property "%s" does not exist.', $field)]);
}
// @phpstan-ignore-next-line Dynamic property access
$entity->{$field} = $value;
return null;
}
/**
* Get category options for select field.
*
* @return array<string, string>
*/
private function getCategoryOptions(): array
{
$categories = $this->categoryRepository->findBy([], ['title' => 'ASC']);
$options = ['' => '-- Aucune --'];
foreach ($categories as $category) {
$options[(string) $category->id] = $category->title;
}
return $options;
}
}src/Dto/Admin/InlineEditResult.php
<?php
declare(strict_types=1);
/*
* This file is part of the Lecodeestdanslepre package.
*
* (c) 2025 Lecodeestdanslepre <https://lecodeestdanslepre.fr>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Dto\Admin;
/**
* Result object for inline edit operations.
* @api
*/
final readonly class InlineEditResult
{
/**
* @param list<string> $errors
*/
public function __construct(
private bool $success,
private array $errors = [],
) {}
public function isSuccess(): bool
{
return $this->success;
}
public function hasErrors(): bool
{
return $this->errors !== [];
}
/**
* @return list<string>
*/
public function getErrors(): array
{
return $this->errors;
}
}templates/App/Controller/Admin/Action/Field/InlineEditAction.html.twig
{# Template for inline editable fields in EasyAdmin #}
{% set edit_url = path('App\\Controller\\Admin\\Action\\Field\\InlineEditAction', { entityType: entityType, id: entity.id, field: field }) %}
{% set field_type = inline_edit_field_type(field) %}
{% set current_value = attribute(entity, field) %}
<div
id="{{ entityType }}-{{ entity.id }}-{{ field }}"
data-controller="inline-edit"
data-inline-edit-url-value="{{ edit_url }}"
data-action="click->inline-edit#stopBubble"
class="inline-edit-frame"
>
{% if editMode|default(false) %}
{# Edit mode — input only, no buttons. Save = auto on input/change/blur/Enter, cancel = Escape #}
{% set text_actions = 'input->inline-edit#scheduleSave keydown.enter->inline-edit#enter keydown.esc->inline-edit#cancelKey blur->inline-edit#blur' %}
{% set choice_actions = 'change->inline-edit#saveImmediate keydown.enter->inline-edit#enter keydown.esc->inline-edit#cancelKey blur->inline-edit#blur' %}
<div class="inline-edit-form">
{% if field_type starts with 'enum:' %}
{% set options = inline_edit_enum_options(entityType, field) %}
{% set current_enum_value = current_value ? current_value.value : '' %}
<select
name="value"
class="form-select form-select-sm {% if errors|default([]) %}is-invalid{% endif %}"
data-inline-edit-target="input"
data-action="{{ choice_actions }}"
autofocus
>
{% for value, label in options %}
<option value="{{ value }}" {% if value == current_enum_value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% elseif field_type == 'datetime' %}
{% set datetime_value = current_value ? current_value|date('Y-m-d\\TH:i') : '' %}
<input
type="datetime-local"
name="value"
value="{{ datetime_value }}"
class="form-control form-control-sm {% if errors|default([]) %}is-invalid{% endif %}"
data-inline-edit-target="input"
data-action="{{ choice_actions }}"
autofocus
>
{% elseif field_type starts with 'association:' %}
{% set options = inline_edit_association_options(field) %}
{% set current_id = current_value ? current_value.id : '' %}
<select
name="value"
class="form-select form-select-sm {% if errors|default([]) %}is-invalid{% endif %}"
data-inline-edit-target="input"
data-action="{{ choice_actions }}"
autofocus
>
{% for value, label in options %}
<option value="{{ value }}" {% if value == current_id %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
{% else %}
<input
type="text"
name="value"
value="{{ current_value }}"
class="form-control form-control-sm {% if errors|default([]) %}is-invalid{% endif %}"
data-inline-edit-target="input"
data-action="{{ text_actions }}"
autofocus
required
>
{% endif %}
<span
class="inline-edit-status"
data-inline-edit-target="status"
aria-live="polite"
aria-atomic="true"
>
{% if errors|default([]) %}
{% for error in errors %}<span class="inline-edit-error">{{ error }}</span>{% endfor %}
{% endif %}
</span>
</div>
{% else %}
{# View mode - clickable text #}
<span
class="inline-edit-value inline-edit-clickable"
data-action="click->inline-edit#edit"
title="Cliquez pour éditer"
>
{% if field_type starts with 'enum:' %}
{% if current_value %}
{% set text_class = current_value.color == 'warning' ? 'text-dark' : 'text-white' %}
<span class="badge bg-{{ current_value.color }} {{ text_class }}"><i class="fas {{ current_value.icon }}"></i> {{ current_value.label }}</span>
{% else %}
-
{% endif %}
{% elseif field_type == 'datetime' %}
{{ current_value ? current_value|date('d/m/Y H:i') : '-' }}
{% elseif field_type starts with 'association:' %}
{{ current_value ? current_value.title : '-- Aucune --' }}
{% else %}
{{ current_value }}
{% endif %}
</span>
{% endif %}
</div>templates/admin/field/inline_editable.html.twig
{# Unified inline editable field template.
Supports all field types via optional variables:
- empty_label: text shown when value is empty (default: none)
- raw_value: set to true to render value as raw HTML (e.g., badges)
#}
{% set field_value = field.formattedValue %}
{% set entity_id = entity.primaryKeyValue %}
{% set field_name = field.property %}
{% set entity_type = entity.instance|entity_type %}
{% set edit_url = path('App\\Controller\\Admin\\Action\\Field\\InlineEditAction', { entityType: entity_type, id: entity_id, field: field_name }) %}
<div
id="{{ entity_type }}-{{ entity_id }}-{{ field_name }}"
data-controller="inline-edit"
data-inline-edit-url-value="{{ edit_url }}"
data-action="click->inline-edit#stopBubble"
class="inline-edit-frame"
>
{# Initial view mode - clickable text #}
<span
class="inline-edit-value inline-edit-clickable"
data-action="click->inline-edit#edit"
title="Cliquez pour éditer"
>
{% if raw_value is defined and raw_value %}
{{ field_value|raw }}
{% elseif empty_label is defined %}
{{ field_value ?: empty_label }}
{% else %}
{{ field_value }}
{% endif %}
</span>
</div>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. Ce blog a eu un système de snapshots par Doctrine subscriber (retiré depuis) ; si votre projet en a un, 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 devrait fusionner.
Et un quatrième qui n'est plus tout à fait ouvert : la concurrence entre surfaces d'écriture. L'inline edit écrit dans le dos du formulaire EasyAdmin, les outils MCP du blog aussi ; ce cocktail finit en merge trois voies côté formulaire, pour arrêter d'écraser silencieusement les modifications croisées. C'est une autre histoire, et elle est assez retorse pour mériter la sienne.
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.
Ce billet est publié sous licence Creative Commons BY-NC-SA 4.0 (attribution, pas d'usage commercial, partage dans les mêmes conditions).