Aller au contenu principal

Comment j'ai transformé EasyAdmin en tableur intelligent

Implémentez l'édition inline dans EasyAdmin avec Symfony UX Turbo et Stimulus. Textes, enums, dates, associations : modifiez vos champs sans quitter la liste.
Catégorie

UX

Une application techniquement parfaite ne sert à rien si l'utilisateur s'y perd. Relevez le défi de l'interface intuitive et transformez vos lignes de code en une expérience fluide.

Lecture
5 min
Niveau
Expert
févr 1 2026
Partager

Il y a cette frustration chez nous autre développeurs Symfony. Vous construisez un back-office avec EasyAdmin, tout fonctionne, c'est propre, c'est élégant. Et puis votre client vous dit : « Super, mais je peux pas juste cliquer sur le titre pour le modifier directement ? Comme dans Excel ? »

Oui c’est possible, mais pas nativement.

Chaque modification exige trois clics, un chargement de page complet, un formulaire entier pour changer un seul mot. En 2026, c'est presque insultant.

Alors j'ai décidé de régler ça définitivement car Symfony nous propose tous les éléments pour le faire, notamment grâce à Symfony UX Turbo

Le résultat : cliquez, modifiez, terminé

Voici ce que nous allons construire : vous cliquez sur n'importe quel champ dans la liste EasyAdmin — un titre, un statut, une date, une catégorie — et il se transforme instantanément en champ éditable. Deux boutons apparaissent : valider ou annuler. La sauvegarde se fait en AJAX, le champ reprend son apparence normale, et vous n'avez jamais quitté la page.

Ça fonctionne avec :

  • Les champs texte (évidemment)
  • Les enums PHP 8.1+ (select natif)
  • Les dates et heures (datetime-local)
  • Les associations Doctrine (select avec les entités liées)

Le tout sans recharger la page, sans Webpack, sans Node.js dans votre pipeline de build : juste Symfony UX et Asset Mapper.

L'architecture en un coup d'œil

Avant de plonger dans le code, voici comment les pièces s'assemblent :

┌─────────────────────────────────────────────────────────────────────┐
│                         NAVIGATEUR                                  │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │  <turbo-frame id="post-42-title">                            │   │
│  │     ↓ data-controller="inline-edit"                          │   │
│  │     ↓ data-inline-edit-url-value="/admin/field/post/42/..."  │   │
│  │                                                              │   │
│  │  inline_edit_controller.js                                   │   │
│  │     → edit()  : GET ?mode=edit   → affiche le formulaire     │   │
│  │     → save()  : POST value=...   → sauvegarde et retour vue  │   │
│  │     → cancel(): GET ?mode=view   → retour à l'affichage      │   │
│  └──────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘
                                    ↓ HTTP
┌─────────────────────────────────────────────────────────────────────┐
│                          SERVEUR                                    │
│  ┌────────────────────────────────────────────────────────────────┐ │
│  │  InlineEditAction.php                                          │ │
│  │     → Route: /admin/field/{entityType}/{id}/inline-edit/{field}│ │
│  │     → Valide entity + field                                    │ │
│  │     → GET: retourne template (mode=view ou mode=edit)          │ │
│  │     → POST: appelle InlineEditService puis retourne template   │ │
│  └────────────────────────────────────────────────────────────────  │
│                                    ↓                                │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │  InlineEditService.php                                       │   │
│  │     → ENTITY_MAP: slug → FQCN (post → Post::class)           │   │
│  │     → EDITABLE_FIELDS: whitelist par entité                  │   │
│  │     → FIELD_TYPES: enum, datetime, association               │   │
│  │     → convertValue(): string → type approprié                │   │
│  │     → setFieldValue(): gère private(set) PHP 8.4             │   │
│  │     → validate() + flush()                                   │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                                    ↓                                │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │  InlineEditAction.html.twig                                  │   │
│  │     → Mode view: <span> cliquable avec valeur formatée       │   │
│  │     → Mode edit: <form> avec input/select + boutons ✓/✗      │   │
│  │     → Gère: text, enum (select), datetime, association       │   │
│  └──────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘

Aucune dépendance externe, le tout compatible avec Symfony 7+ et EasyAdmin 4.

Étape 1 : le controller Stimulus

Il s’agit du coeur qui orchestrent tout le flux.

Créez le fichier assets/controllers/inline_edit_controller.js :

JavaScript
import { Controller } from "@hotwired/stimulus";

/**
 * Inline edit controller for EasyAdmin fields
 *
 * Handles:
 * - Switching between view and edit modes
 * - Form submission via Turbo
 * - Keyboard shortcuts (Esc to cancel, Enter to save)
 */
export default class extends Controller {
    static targets = ["form", "input", "saveButton"];
    static values = {
        url: String,
    };

    /**
     * Switch to edit mode by fetching the edit form via Turbo
     */
    async edit(event) {
        event.preventDefault();
        event.stopPropagation(); // CRUCIAL: empêche EasyAdmin de rediriger

        try {
            const response = await fetch(`${this.urlValue}?mode=edit`, {
                headers: {
                    Accept: "text/vnd.turbo-stream.html",
                },
            });

            if (!response.ok) {
                throw new Error("Failed to fetch edit form");
            }

            const html = await response.text();
            this.element.innerHTML = html;

            // Focus l'input après un court délai pour que le DOM soit prêt
            setTimeout(() => {
                const input = this.element.querySelector('input[name="value"]');
                if (input) {
                    input.focus();
                    input.select();
                }
            }, 50);
        } catch (error) {
            console.error("Error switching to edit mode:", error);
        }
    }

    /**
     * Save changes via Turbo form submission
     */
    async save(event) {
        event.preventDefault();
        event.stopPropagation();

        const form = event.target;
        const formData = new FormData(form);

        try {
            // Désactive le bouton pour éviter les doubles soumissions
            if (this.hasSaveButtonTarget) {
                this.saveButtonTarget.disabled = true;
            }

            const response = await fetch(this.urlValue, {
                method: "POST",
                body: formData,
                headers: {
                    Accept: "text/vnd.turbo-stream.html",
                },
            });

            if (!response.ok) {
                throw new Error("Failed to save changes");
            }

            const html = await response.text();
            this.element.innerHTML = html;
        } catch (error) {
            console.error("Error saving changes:", error);

            if (this.hasSaveButtonTarget) {
                this.saveButtonTarget.disabled = false;
            }
        }
    }

    /**
     * Cancel editing and return to view mode
     */
    async cancel(event) {
        event.preventDefault();
        event.stopPropagation();

        try {
            const response = await fetch(`${this.urlValue}?mode=view`, {
                headers: {
                    Accept: "text/vnd.turbo-stream.html",
                },
            });

            if (!response.ok) {
                throw new Error("Failed to cancel edit");
            }

            const html = await response.text();
            this.element.innerHTML = html;
        } catch (error) {
            console.error("Error cancelling edit:", error);
        }
    }
}

Pourquoi event.stopPropagation() est crucial

EasyAdmin attache un écouteur de clic sur chaque cellule <td> de la liste. Cliquez n'importe où, et vous êtes redirigé vers la page d'édition.

Ma première tentative : event.preventDefault(). Échec. EasyAdmin utilise stopPropagation en interne, donc mon événement n'atteint même pas le bon handler.

La solution ? Un event.stopPropagation() stratégique dans chaque méthode — edit(), save(), ET cancel(). Sinon, cliquer sur le bouton de validation vous téléporte quand même vers le formulaire complet.

Étape 2 : le controller PHP

Le contrôleur suit le pattern ADR (Action-Domain-Responder) : une seule méthode __invoke(), une responsabilité unique.

Créez src/Controller/Admin/Field/InlineEditAction.php :

PHP
<?php

declare(strict_types=1);

namespace App\Controller\Admin\Field;

use App\Service\Admin\Field\InlineEditService;
use Symfony\Bridge\Twig\Attribute\Template;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
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;

/**
 * @return array{entity: object, entityType: string, field: string, editMode: bool, errors?: list<string>}
 */
#[Route('/admin/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
{
    public function __construct(
        private readonly InlineEditService $inlineEditService,
    ) {
    }

    /**
     * @return array{entity: object, entityType: string, field: string, editMode: bool, errors?: list<string>}
     */
    public function __invoke(string $entityType, string $id, string $field, Request $request): array
    {
        // Étape 1: Valider le type d'entité
        if (!$this->inlineEditService->isEntityTypeSupported($entityType)) {
            throw new NotFoundHttpException(\sprintf('Entity type "%s" is not supported.', $entityType));
        }

        // Étape 2: Vérifier que le champ est éditable (whitelist)
        if (!$this->inlineEditService->isFieldEditable($entityType, $field)) {
            throw new BadRequestHttpException(\sprintf(
                'Field "%s" is not editable inline for entity type "%s".',
                $field,
                $entityType
            ));
        }

        // Étape 3: Trouver l'entité
        $entity = $this->inlineEditService->findEntity($entityType, $id);
        if ($entity === null) {
            throw new NotFoundHttpException(\sprintf('Entity "%s" with id "%s" not found.', $entityType, $id));
        }

        // GET: retourne le mode vue ou édition
        if ($request->isMethod('GET')) {
            $mode = $request->query->get('mode', 'view');

            return [
                'entity' => $entity,
                'entityType' => $entityType,
                'field' => $field,
                'editMode' => $mode === 'edit',
            ];
        }

        // POST: sauvegarde les modifications
        $value = $request->request->get('value');
        if (!\is_string($value)) {
            throw new BadRequestHttpException('Value must be a string.');
        }

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

        // Si erreur de validation, retourne en mode édition avec les erreurs
        if ($inlineEditResult->hasErrors()) {
            return [
                'entity' => $entity,
                'entityType' => $entityType,
                'field' => $field,
                'errors' => $inlineEditResult->getErrors(),
                'editMode' => true,
            ];
        }

        // Succès: retourne en mode vue
        return [
            'entity' => $entity,
            'entityType' => $entityType,
            'field' => $field,
            'editMode' => false,
        ];
    }
}

Points clés

1. L'attribut #[Template] — Plus besoin de return $this->render(). Le tableau retourné est automatiquement passé au template.

2. La route générique/admin/field/{entityType}/{id}/inline-edit/{field} gère toutes les entités et tous les champs. Un seul contrôleur pour tout.

3. L’attribut #[IsGranted('ROLE_ADMIN')] — Il permet de garantir à la route d’être derrière le firewall de notre application Symfony.

4. La whitelist de sécurité — Seuls les champs explicitement déclarés dans le service sont éditables. Pas de risque d'édition sauvage.

Étape 3 : le service métier

C'est ici que la magie opère. Le service gère le mapping des entités, la conversion des types, et la persistance.

Créez src/Service/Admin/Field/InlineEditService.php :

PHP
<?php

declare(strict_types=1);

namespace App\Service\Admin\Field;

use App\Entity\Content\Category;
use App\Entity\Content\Page;
use App\Entity\Content\Post;
use App\Enum\Content\ContentStatusEnum;
use App\Enum\Content\ProficiencyLevelEnum;
use App\Repository\Content\CategoryRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final readonly class InlineEditService
{
    /**
     * Mapping des slugs vers les FQCN.
     * Ajouter une entité = 1 ligne ici.
     */
    private const array ENTITY_MAP = [
        'post' => Post::class,
        'page' => Page::class,
        'category' => Category::class,
    ];

    /**
     * Champs éditables par entité (whitelist de sécurité).
     * Ajouter un champ = 1 ligne ici.
     */
    private const array EDITABLE_FIELDS = [
        'post' => ['title', 'status', 'createdAt', 'publishedAt', 'proficiencyLevel', 'category'],
        'page' => ['title', 'status', 'createdAt'],
        'category' => ['title', 'status', 'createdAt'],
    ];

    /**
     * Configuration des types de champs spéciaux.
     * Les champs absents sont traités comme des strings.
     */
    private const array FIELD_TYPES = [
        'status' => 'enum:status',
        'proficiencyLevel' => 'enum:proficiencyLevel',
        'createdAt' => 'datetime',
        'publishedAt' => 'datetime',
        'category' => 'association:category',
    ];

    public function __construct(
        private EntityManagerInterface $entityManager,
        private ValidatorInterface $validator,
        private CategoryRepository $categoryRepository,
    ) {
    }

    public function getEntityClass(string $entityType): ?string
    {
        return self::ENTITY_MAP[$entityType] ?? null;
    }

    public function isEntityTypeSupported(string $entityType): bool
    {
        return isset(self::ENTITY_MAP[$entityType]);
    }

    public function isFieldEditable(string $entityType, string $field): bool
    {
        return \in_array($field, self::EDITABLE_FIELDS[$entityType] ?? [], true);
    }

    public function getFieldType(string $field): ?string
    {
        return self::FIELD_TYPES[$field] ?? null;
    }

    /**
     * Retourne les options pour les champs enum.
     */
    public function getEnumOptions(string $field): array
    {
        return match ($field) {
            'status' => array_combine(
                array_map(
                    static fn (ContentStatusEnum $e): string => $e->value,
                    ContentStatusEnum::cases()
                ),
                array_map(
                    static fn (ContentStatusEnum $e): string => $e->getLabel(),
                    ContentStatusEnum::cases()
                ),
            ),
            'proficiencyLevel' => array_combine(
                array_map(
                    static fn (ProficiencyLevelEnum $e): string => $e->value,
                    ProficiencyLevelEnum::cases()
                ),
                array_map(
                    static fn (ProficiencyLevelEnum $e): string => $e->getLabel(),
                    ProficiencyLevelEnum::cases()
                ),
            ),
            default => [],
        };
    }

    /**
     * Retourne les options pour les champs association.
     */
    public function getAssociationOptions(string $field): array
    {
        return match ($field) {
            'category' => $this->getCategoryOptions(),
            default => [],
        };
    }

    public function findEntity(string $entityType, string $id): ?object
    {
        $entityClass = $this->getEntityClass($entityType);
        if ($entityClass === null) {
            return null;
        }

        return $this->entityManager->getRepository($entityClass)->find($id);
    }

    /**
     * Met à jour un champ sur une entité.
     */
    public function updateField(object $entity, string $field, string $value): InlineEditResult
    {
        // Étape 1: Convertir la valeur string vers le type approprié
        $convertedValue = $this->convertValue($field, $value);
        if ($convertedValue instanceof InlineEditResult) {
            return $convertedValue; // Erreur de conversion
        }

        // Étape 2: Assigner la valeur (en gérant private(set))
        $updateResult = $this->setFieldValue($entity, $field, $convertedValue);
        if ($updateResult instanceof InlineEditResult) {
            return $updateResult;
        }

        // Étape 3: Valider l'entité
        $violations = $this->validator->validate($entity);

        if (\count($violations) > 0) {
            $errors = [];
            foreach ($violations as $violation) {
                if (str_contains($violation->getPropertyPath(), $field)) {
                    $errors[] = (string) $violation->getMessage();
                }
            }

            if ($errors !== []) {
                return new InlineEditResult(false, $errors);
            }
        }

        // Étape 4: Persister
        $this->entityManager->flush();

        return new InlineEditResult(true);
    }

    /**
     * Convertit une string vers le type approprié selon la config.
     */
    private function convertValue(string $field, string $value): mixed
    {
        $fieldType = $this->getFieldType($field);

        if ($fieldType === null) {
            return $value; // String simple
        }

        if (str_starts_with($fieldType, 'enum:')) {
            return $this->convertEnumValue($field, $value);
        }

        if ($fieldType === 'datetime') {
            return $this->convertDateTimeValue($value);
        }

        if (str_starts_with($fieldType, 'association:')) {
            return $this->convertAssociationValue($field, $value);
        }

        return $value;
    }

    private function convertEnumValue(string $field, string $value): mixed
    {
        return match ($field) {
            'status' => ContentStatusEnum::tryFrom($value)
                ?? new InlineEditResult(false, ['Statut invalide.']),
            'proficiencyLevel' => ProficiencyLevelEnum::tryFrom($value)
                ?? new InlineEditResult(false, ['Niveau invalide.']),
            default => new InlineEditResult(false, ['Type enum inconnu.']),
        };
    }

    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.']);
        }
    }

    private function convertAssociationValue(string $field, string $value): ?object
    {
        if ($value === '') {
            return null;
        }

        return match ($field) {
            'category' => $this->categoryRepository->find($value),
            default => null,
        };
    }

    /**
     * Assigne une valeur à une propriété, en gérant private(set) de PHP 8.4.
     */
    private function setFieldValue(object $entity, string $field, mixed $value): ?InlineEditResult
    {
        // Cherche d'abord un setter (pour les propriétés private(set))
        $setter = 'set' . ucfirst($field);
        if (method_exists($entity, $setter)) {
            $entity->{$setter}($value);
            return null;
        }

        // Sinon, assignation directe
        if (!property_exists($entity, $field)) {
            return new InlineEditResult(false, [\sprintf('Property "%s" does not exist.', $field)]);
        }

        $entity->{$field} = $value;

        return null;
    }

    private function getCategoryOptions(): array
    {
        $categories = $this->categoryRepository->findBy([], ['title' => 'ASC']);
        $options = ['' => '-- Aucune --'];

        foreach ($categories as $category) {
            if ($category->id !== null) {
                $options[(string) $category->id] = $category->title;
            }
        }

        return $options;
    }
}

Le piège PHP 8.4 : les propriétés asymétriques

PHP 8.4 introduit private(set) — vous pouvez lire une propriété publiquement, mais l'écriture reste privée. Doctrine l'utilise de plus en plus pour les timestamps automatiques.

PHP
// Dans votre entité
public \DateTimeImmutable $createdAt { private(set); }

Il faut donc détecter si un setter existe et l'utiliser automatiquement.

PHP
$setter = 'set' . ucfirst($field);
if (method_exists($entity, $setter)) {
    $entity->{$setter}($value);  // Utilise le setter
} else {
    $entity->{$field} = $value;   // Assignation directe
}

Étape 4 : l’objet résultat

Un petit value object pour structurer les retours du service.

Créez src/Service/Admin/Field/InlineEditResult.php :

PHP
<?php

declare(strict_types=1);

namespace App\Service\Admin\Field;

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;
    }
}

Étape 5 : le template principal

Ce template gère les deux modes (vue et édition) et les quatre types de champs.

Créez templates/App/Controller/Admin/Field/InlineEditAction.html.twig :

Twig
{# Template for inline editable fields in EasyAdmin #}
{% set edit_url = path('App\\Controller\\Admin\\Field\\InlineEditAction', {
    entityType: entityType,
    id: entity.id,
    field: field
}) %}
{% set field_type = inline_edit_field_type(field) %}
{% set current_value = attribute(entity, field) %}

<turbo-frame
    id="{{ entityType }}-{{ entity.id }}-{{ field }}"
    data-controller="inline-edit"
    data-inline-edit-url-value="{{ edit_url }}"
    class="inline-edit-frame"
>
    {% if editMode|default(false) %}
        {# ═══════════════════ MODE ÉDITION ═══════════════════ #}
        <form
            class="inline-edit-form d-flex align-items-center gap-2"
            data-inline-edit-target="form"
            data-action="submit->inline-edit#save keydown.esc->inline-edit#cancel"
        >
            {% if field_type starts with 'enum:' %}
                {# ─────── SELECT POUR ENUM ─────── #}
                {% set options = inline_edit_enum_options(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"
                    autofocus
                >
                    {% for value, label in options %}
                        <option value="{{ value }}" {% if value == current_enum_value %}selected{% endif %}>
                            {{ label }}
                        </option>
                    {% endfor %}
                </select>

            {% elseif field_type == 'datetime' %}
                {# ─────── INPUT 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"
                    autofocus
                >

            {% elseif field_type starts with 'association:' %}
                {# ─────── SELECT POUR 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"
                    autofocus
                >
                    {% for value, label in options %}
                        <option value="{{ value }}" {% if value == current_id %}selected{% endif %}>
                            {{ label }}
                        </option>
                    {% endfor %}
                </select>

            {% else %}
                {# ─────── INPUT TEXTE (défaut) ─────── #}
                <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"
                    autofocus
                    required
                >
            {% endif %}

            {# ─────── BOUTONS VALIDER / ANNULER ─────── #}
            <div class="btn-group" role="group">
                <button
                    type="submit"
                    class="btn btn-success btn-sm"
                    title="Valider"
                    data-inline-edit-target="saveButton"
                >
                    <i class="fas fa-check"></i>
                </button>
                <button
                    type="button"
                    class="btn btn-secondary btn-sm"
                    title="Annuler"
                    data-action="click->inline-edit#cancel"
                >
                    <i class="fas fa-times"></i>
                </button>
            </div>

            {# ─────── ERREURS DE VALIDATION ─────── #}
            {% if errors|default([]) %}
                <div class="invalid-feedback d-block">
                    {% for error in errors %}
                        <div>{{ error }}</div>
                    {% endfor %}
                </div>
            {% endif %}
        </form>

    {% else %}
        {# ═══════════════════ MODE VUE ═══════════════════ #}
        <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 %}
                    <span class="badge bg-{{ current_value.color }}">{{ 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 %}
</turbo-frame>

Le ballet Turbo-Stimulus

La magie opère grâce à la chorégraphie entre Turbo Frames et Stimulus :

  • Chaque champ est enveloppé dans un <turbo-frame> avec un ID unique
  • Quand l'utilisateur clique, Stimulus fait un fetch vers le serveur avec ?mode=edit
  • Le serveur retourne le même template, mais avec l'input visible
  • Turbo remplace automatiquement le contenu du frame
  • À la soumission, POST vers le serveur, validation, et retour en mode vue

Pas de manipulation DOM manuelle. Pas de innerHTML hasardeux. Juste des échanges HTTP propres.

Étape 6 : les templates de champ EasyAdmin

Ces templates servent de point d'entrée dans la liste EasyAdmin. Ils initialisent le <turbo-frame> et appellent le contrôleur Stimulus.

Créez templates/admin/field/inline_editable.html.twig (pour les champs texte) :

Twig
{# Custom field template for inline editable text fields #}
{% 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\\Field\\InlineEditAction', {
    entityType: entity_type,
    id: entity_id,
    field: field_name
}) %}

<turbo-frame
    id="{{ entity_type }}-{{ entity_id }}-{{ field_name }}"
    data-controller="inline-edit"
    data-inline-edit-url-value="{{ edit_url }}"
    class="inline-edit-frame"
>
    <span
        class="inline-edit-value inline-edit-clickable"
        data-action="click->inline-edit#edit"
        title="Cliquez pour éditer"
    >
        {{ field_value }}
    </span>
</turbo-frame>

Dupliquez ce template pour les autres types :

  • inline_editable_select.html.twig — pour les enums
  • inline_editable_datetime.html.twig — pour les dates
  • inline_editable_association.html.twig — pour les relations

La structure est identique. La différence se fait dans le template principal InlineEditAction.html.twig qui sait comment rendre chaque type.

Étape 7 : l’extension Twig

Cette extension expose les méthodes du service dans Twig.

Créez src/Twig/Extension/AdminExtension.php :

PHP
<?php

declare(strict_types=1);

namespace App\Twig\Extension;

use App\Entity\Content\Category;
use App\Entity\Content\Page;
use App\Entity\Content\Post;
use App\Service\Admin\Field\InlineEditService;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

final class AdminExtension extends AbstractExtension
{
    /**
     * Mapping inverse : FQCN → slug
     */
    private const array ENTITY_TYPE_MAP = [
        Post::class => 'post',
        Page::class => 'page',
        Category::class => 'category',
    ];

    public function __construct(
        private readonly InlineEditService $inlineEditService,
    ) {
    }

    #[\Override]
    public function getFilters(): array
    {
        return [
            new TwigFilter('entity_type', $this->getEntityType(...)),
        ];
    }

    #[\Override]
    public function getFunctions(): array
    {
        return [
            new TwigFunction('inline_edit_field_type', $this->getFieldType(...)),
            new TwigFunction('inline_edit_enum_options', $this->getEnumOptions(...)),
            new TwigFunction('inline_edit_association_options', $this->getAssociationOptions(...)),
        ];
    }

    /**
     * Convertit une instance d'entité en slug.
     */
    public function getEntityType(object|string $entity): ?string
    {
        $fqcn = \is_string($entity) ? $entity : $entity::class;

        return self::ENTITY_TYPE_MAP[$fqcn] ?? null;
    }

    public function getFieldType(string $field): ?string
    {
        return $this->inlineEditService->getFieldType($field);
    }

    public function getEnumOptions(string $field): array
    {
        return $this->inlineEditService->getEnumOptions($field);
    }

    public function getAssociationOptions(string $field): array
    {
        return $this->inlineEditService->getAssociationOptions($field);
    }
}

Étape 8 : l’intégration dans vos CrudControllers

Dernière étape : activer l'édition inline sur vos champs dans EasyAdmin.

Dans votre PostCrudController.php :

PHP
public function configureFields(string $pageName): iterable
{
    if (Crud::PAGE_INDEX === $pageName) {
        // Champ texte éditable
        yield TextField::new('title', 'Titre')
            ->setTemplatePath('admin/field/inline_editable.html.twig')
            ->renderAsHtml();

        // Champ enum éditable (select)
        yield ChoiceField::new('status', 'Statut')
            ->setTemplatePath('admin/field/inline_editable_select.html.twig')
            ->formatValue($this->formatStatusValue(...));

        // Autre enum
        yield ChoiceField::new('proficiencyLevel', 'Niveau')
            ->setTemplatePath('admin/field/inline_editable_select.html.twig')
            ->formatValue(static function ($value): string {
                if ($value instanceof ProficiencyLevelEnum) {
                    return sprintf(
                        '<span class="badge bg-%s">%s</span>',
                        $value->getColor(),
                        $value->getLabel()
                    );
                }
                return '';
            });

        // Association éditable
        yield AssociationField::new('category', 'Catégorie')
            ->setTemplatePath('admin/field/inline_editable_association.html.twig');

        // Datetime éditable
        yield DateTimeField::new('publishedAt', 'Date de publication')
            ->setTemplatePath('admin/field/inline_editable_datetime.html.twig')
            ->setFormat('dd/MM/yyyy HH:mm');

        yield DateTimeField::new('createdAt', 'Date de création')
            ->setTemplatePath('admin/field/inline_editable_datetime.html.twig')
            ->setFormat('dd/MM/yyyy HH:mm');

        return;
    }

    // ... configuration des autres pages (edit, detail, etc.)
}

Récapitulatif

src/
├── Controller/Admin/Field/
│   └── InlineEditAction.php              # Contrôleur ADR
├── Service/Admin/Field/
│   ├── InlineEditService.php             # Logique métier
│   └── InlineEditResult.php              # Value object résultat
└── Twig/Extension/
    └── AdminExtension.php                # Fonctions Twig

templates/
├── App/Controller/Admin/Field/
│   └── InlineEditAction.html.twig        # Template principal (vue/édition)
└── admin/field/
    ├── inline_editable.html.twig         # Template champ texte
    ├── inline_editable_select.html.twig  # Template champ enum
    ├── inline_editable_datetime.html.twig # Template champ date
    └── inline_editable_association.html.twig # Template champ relation

assets/controllers/
└── inline_edit_controller.js             # Contrôleur Stimulus

Ajouter une nouvelle entité ou un nouveau champ

C'est là que l'architecture générique paie.

Nouvelle entité ? Deux lignes :

PHP
// Dans InlineEditService::ENTITY_MAP
'product' => Product::class,

// Dans InlineEditService::EDITABLE_FIELDS
'product' => ['name', 'price', 'status'],

Nouveau champ ? Une ligne :

PHP
// Dans EDITABLE_FIELDS
'post' => ['title', 'status', ..., 'nouveauChamp'],

Nouveau type de champ ? Ajoutez-le dans FIELD_TYPES et gérez la conversion dans convertValue().

Pistes d’évolutions : utiliser les attributs PHP 8 pour déclarer les entités et les champs

Les pièges courants et leurs solutions

1. EasyAdmin redirige quand je clique sur le champ

Cause : event.stopPropagation() manquant. Solution : L'ajouter dans TOUTES les méthodes du contrôleur Stimulus (edit, save, cancel).

2. L'input n'est pas focus automatiquement

Cause : Le DOM n'est pas encore mis à jour quand focus() est appelé. Solution : Utiliser setTimeout avec un délai de 50ms.

3. Erreur "Cannot write to private(set) property"

Cause : PHP 8.4 avec propriétés asymétriques. Solution : Détecter et utiliser le setter si disponible.

4. La validation Symfony ne fonctionne pas

Spoiler : Elle fonctionne. Le validateur s'exécute exactement comme pour un formulaire classique. Si une contrainte échoue, vous récupérez les erreurs dans un objet standard et le template les affiche.

5. Les assets ne se chargent pas

Solution : Vérifiez que Asset Mapper est configuré et que votre contrôleur Stimulus suit la convention de nommage (inline_edit_controller.jsdata-controller="inline-edit").

Asset Mapper : le secret le mieux gardé de Symfony

Oubliez Webpack Encore. Oubliez les node_modules de 500 Mo. Oubliez les configurations Babel incompréhensibles.

Asset Mapper, c'est Symfony qui dit : « Les navigateurs modernes supportent les modules ES6 nativement. Pourquoi compiler quoi que ce soit ? »

Votre contrôleur Stimulus ? Un fichier .js standard. Vos styles ? Un fichier .css standard. Asset Mapper les sert directement, avec cache-busting automatique et import maps pour les dépendances.

Vous modifiez un fichier, vous rafraîchissez, ça fonctionne. Pas de watch. Pas de build. Pas de configuration.

La suite : ce qui manque encore

Cette implémentation couvre 90% des besoins. Les 10% restants :

  • L'édition de texte riche (WYSIWYG inline) demanderait l'intégration d'un éditeur comme Trix ou TipTap
  • Le drag-and-drop pour réordonner serait un complément naturel
  • L'édition en masse (sélectionner plusieurs lignes, appliquer le même statut) reste sur ma liste

Mais le plus dur est fait. Le pattern est établi, l'architecture est extensible. Ajouter ces fonctionnalités, c'est itérer sur une base solide, pas repartir de zéro.

Symfony UX mérite plus d'attention

On parle beaucoup de Laravel Livewire. On parle de Rails Hotwire. On parle moins de Symfony UX, et c'est dommage.

Turbo et Stimulus, combinés avec Asset Mapper, offrent une expérience de développement frontend qui rivalise avec n'importe quel framework JavaScript — sans la complexité. Vous écrivez du PHP, du Twig, et juste assez de JavaScript pour orchestrer les interactions.

L'édition inline dans EasyAdmin n'est qu'un exemple de ce qui devient possible quand on arrête de combattre le navigateur et qu'on travaille avec lui.

Le futur du développement web Symfony est déjà là : il suffit de l'utiliser.

Poursuivre la lecture

Sélectionné avec soin pour vous.

DevOps

Comment j'ai industrialisé mon side-project avec Google Jules

Ne laissez plus l'IA "deviner" si le code fonctionne. Apprenez à configurer une sandbox Docker pour Google Jules afin d'automatiser la QA, la sécurité et la performance, loin du "Vibe Coding".

5 min de lecture
DX

Les délimiteurs Twig : ce problème d'espace blanc que vous ignorez

Gaps inline-block, diffs bruyants, layouts instables : comprenez l'impact des délimiteurs Twig sur l'espace blanc et adoptez les bonnes pratiques avec {%- et {{-.

8 min de lecture
Symfony

PHP 8.4 Property Hooks : quand Doctrine 3.4 révolutionne vos Getters/Setters

Découvrez les Property Hooks PHP 8.4 avec Doctrine 3.4. Fini les getters/setters verbeux !

4 min de lecture