Skip to content

Symfony UX — Twig Components + Live Components

TL;DR Les Twig Components apportent à Twig une vraie notion de composant : tu écris <twig:Alert variant="danger">…</twig:Alert> et derrière, un fichier templates/components/Alert.html.twig (anonyme) ou une classe PHP App\Twig\Components\Alert (avec props typées, méthodes, validation) prend le relais. Les Live Components ajoutent la réactivité côté serveur : un changement d'input déclenche un POST silencieux, le composant se re-rend et le HTML est patché dans le DOM par UX (sans rechargement, sans framework JS lourd). C'est l'équivalent PHP de Phoenix LiveView ou HTMX, mais intégré à Symfony : injection de dépendances, validation Symfony, formulaires, autorisation, le tout dans un seul fichier PHP + Twig. Idéal pour formulaires dynamiques, dashboards, recherches live, et tout cas où React/Vue serait surdimensionné. Compatible Mercure pour temps réel multi-utilisateurs.

🧠 Mental model — ASCII + analogie

TWIG COMPONENT (stateless, server-rendered)
  templates/components/Alert.html.twig
    <div class="alert alert--{{ variant }}">{{ block('content') }}</div>

  Usage Twig :
    <twig:Alert variant="danger">Erreur</twig:Alert>


                                  rendered HTML server-side, end of story

LIVE COMPONENT (stateful, réactif)
  src/Twig/Components/ProductSearch.php
    #[AsLiveComponent]
    final class ProductSearch {
        #[LiveProp(writable: true)] public string $query = '';
        public function getResults(): array { /* search */ }
    }
  templates/components/ProductSearch.html.twig
    <div {{ attributes }}>
        <input data-model="query" />
        {% for r in this.results %}{{ r.name }}{% endfor %}
    </div>

  Cycle de vie :
    user types ──► JS UX detects change ──► POST /_components/ProductSearch/_render
                                            with state { query: "abc" }

                                          PHP: hydrate, run, render template

                                          response: HTML (diff applied by idiomorph)

                                          UI updated, no JS custom written

Analogie : Twig Components = legos statiques, tu assembles, tu colles, c'est fait. Live Components = legos pilotés par un télégraphe — chaque modification envoie un message au serveur qui renvoie la nouvelle forme à assembler. C'est le même HTML qu'au render initial, simplement re-calculé à chaque interaction, et idiomorph se charge de mettre à jour seulement les nœuds modifiés (préserve focus, scroll, état des inputs non touchés).

🔐 Le modèle d'hydratation — ce qu'un staff engineer doit comprendre

Live Components est stateless côté serveur : entre deux interactions, aucun état n'est gardé en session ni en mémoire. Tout l'état vit dans le DOM, sérialisé en JSON dans l'attribut data-live-props-value du composant. À chaque interaction, le client renvoie cet état, le serveur réhydrate un objet PHP neuf, exécute l'action/le re-render, et renvoie le HTML + le nouvel état signé. C'est exactement le même modèle qu'un JWT : le serveur ne fait pas confiance à ce qu'il a en mémoire (il n'a rien), il fait confiance à ce qui est signé.

                       Sérialisé dans le DOM, visible & modifiable par le client
                       ┌──────────────────────────────────────────────────────┐
  data-live-props-value│ { "query":"abc", "category":"all", "@checksum":"..." } │
                       └──────────────────────────────────────────────────────┘
                                          │  POST au prochain événement

   ┌──────────────────────────────────────────────────────────────────────────┐
   │ 1. Vérifie le checksum (HMAC sur LiveProps + secret app)                   │
   │    → altéré ? 400. C'est ta seule barrière d'intégrité.                    │
   │ 2. Hydrate : JSON → propriétés PHP typées (DTO, enum, entité Doctrine…)    │
   │ 3. Exécute l'action OU juste re-render                                     │
   │ 4. Re-sérialise (deshydrate) + recalcule le checksum                       │
   │ 5. Renvoie HTML + nouvel état signé                                        │
   └──────────────────────────────────────────────────────────────────────────┘

Trois conséquences que les juniors ratent et qui font échouer les revues d'archi :

DécisionPourquoiLe réflexe staff
writable: true = endpoint publicLe client peut POSTer n'importe quelle valeur d'une LiveProp writable, dans le type déclaré. C'est littéralement un paramètre de requête.Traite chaque writable comme un champ de form non-trusté : valide, borne, ré-autorise côté serveur. Jamais d'ID d'autorisation writable.
Le checksum protège l'intégrité, pas la confidentialitéLes LiveProps non-writable sont signées (le client ne peut pas les changer) mais lisibles en clair dans le HTML.Ne mets jamais de secret (hash de mot de passe, token interne, prix d'achat) dans une LiveProp, même non-writable.
L'hydratation des entités refait un find()Une entité Doctrine en LiveProp n'est pas re-sérialisée entière : seul son identifiant est stocké, puis rechargée par find() à chaque requête (1 requête SQL/interaction).Sur une liste de 50 lignes chacune en Live Component, c'est 50 find(). Mesure-le. Préfère un DTO immuable si l'entité est en lecture seule.

Hydratation custom. Pour des objets non triviaux (value objects, DTO), tu contrôles la (dé)sérialisation :

php
use Symfony\UX\LiveComponent\Attribute\LiveProp;

#[AsLiveComponent]
final class DateRangeFilter
{
    use DefaultActionTrait;

    // Value object : on dit à Live comment l'aplatir et le reconstruire.
    #[LiveProp(writable: true, hydrateWith: 'hydrateRange', dehydrateWith: 'dehydrateRange')]
    public DateRange $range;

    public function dehydrateRange(DateRange $range): array
    {
        return ['from' => $range->from->format('Y-m-d'), 'to' => $range->to->format('Y-m-d')];
    }

    public function hydrateRange(array $data): DateRange
    {
        return new DateRange(
            new \DateTimeImmutable($data['from']),
            new \DateTimeImmutable($data['to']),
        );
    }
}

Pour les objets exposant des propriétés publiques scalaires simples, useSerializerForHydration: true délègue au Serializer Symfony. Pour les entités, LiveProp ne persiste que l'@id par défaut ; expose plus de champs writable explicitement via writablePaths si (et seulement si) tu acceptes que le client les modifie :

php
#[LiveProp(writable: ['title', 'status'])] // seuls title et status sont modifiables côté client
public Post $post;

🛠️ Code minimal (PHP 8.2+ + Twig + JS)

Installation

bash
composer require symfony/ux-twig-component
composer require symfony/ux-live-component
# Avec AssetMapper, les controllers JS sont auto-chargés via controllers.json

Twig Component anonyme

twig
{# templates/components/Alert.html.twig #}
{# anonymous: pas de classe PHP, juste props via {% props %} #}
{% props variant = 'info', dismissible = false %}

<div {{ attributes.defaults({ class: 'alert alert--' ~ variant }) }}
     role="alert"
     {% if dismissible %}data-controller="alert-dismiss"{% endif %}>
    {% block content %}{% endblock %}
    {% if dismissible %}
        <button type="button" data-action="alert-dismiss#close" aria-label="Fermer">&times;</button>
    {% endif %}
</div>
twig
{# Usage #}
<twig:Alert variant="danger" dismissible>
    Une erreur s'est produite : {{ error }}
</twig:Alert>

Twig Component class-based

php
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsTwigComponent]
final class Button
{
    public string $label = '';
    public string $variant = 'primary';
    public ?string $icon = null;
    public string $type = 'button';

    #[ExposeInTemplate]
    public function getClasses(): string
    {
        return sprintf(
            'btn btn--%s%s',
            $this->variant,
            $this->icon !== null ? ' btn--with-icon' : ''
        );
    }
}
twig
{# templates/components/Button.html.twig #}
<button type="{{ type }}" class="{{ classes }}" {{ attributes }}>
    {% if icon %}<i class="icon icon--{{ icon }}"></i>{% endif %}
    {{ label ?: block('content') }}
</button>
twig
{# Usage #}
<twig:Button label="Sauvegarder" variant="success" icon="check" />

<twig:Button variant="danger" type="submit">
    Supprimer définitivement
</twig:Button>

Live Component — formulaire avec validation live

php
namespace App\Twig\Components;

use App\Form\PostType;
use App\Entity\Post;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
final class PostForm
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly Security $security,
    ) {}

    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(PostType::class, new Post());
    }

    #[LiveAction]
    public function save(): \Symfony\Component\HttpFoundation\RedirectResponse
    {
        $this->submitForm();

        /** @var Post $post */
        $post = $this->getForm()->getData();
        $post->setAuthor($this->security->getUser());

        $this->em->persist($post);
        $this->em->flush();

        return $this->redirectToRoute('post_show', ['id' => $post->getId()]);
    }

    #[LiveAction]
    public function addTag(#[LiveArg] string $tag): void
    {
        /** @var Post $post */
        $post = $this->getForm()->getData();
        $post->addTag($tag);
    }
}
twig
{# templates/components/PostForm.html.twig #}
<div {{ attributes }}>
    {{ form_start(form) }}
        {{ form_row(form.title) }}
        {{ form_row(form.body) }}

        <div data-controller="tag-input">
            {% for tag in form.data.tags %}
                <span class="tag">{{ tag }}</span>
            {% endfor %}
            <input type="text" data-action="keydown.enter->tag-input#add">
        </div>

        <twig:Button label="Publier" variant="primary"
                     data-action="live#action"
                     data-live-action-param="save" />
    {{ form_end(form) }}
</div>

Avec ComponentWithFormTrait, chaque modification d'un input du formulaire :

  1. Déclenche un POST partiel vers le serveur.
  2. Le serveur re-hydrate le composant, re-soumet le form (sans flush DB), renvoie le HTML.
  3. UX patch le DOM via idiomorph : préserve la position du curseur, le scroll, les inputs non touchés.

LiveProp — propriétés réactives

php
#[AsLiveComponent]
final class ProductSearch
{
    use DefaultActionTrait;

    #[LiveProp(writable: true)]
    public string $query = '';

    #[LiveProp(writable: true)]
    public string $category = 'all';

    #[LiveProp(writable: true, onUpdated: 'onSortChanged')]
    public string $sort = 'name';

    #[LiveProp]
    public int $perPage = 20;     // non writable depuis le DOM

    public function __construct(private readonly ProductRepository $repo) {}

    public function getResults(): array
    {
        return $this->repo->search(
            query: $this->query,
            category: $this->category === 'all' ? null : $this->category,
            sort: $this->sort,
            limit: $this->perPage,
        );
    }

    public function onSortChanged(string $previous): void
    {
        // hook appelé lorsque $sort change, avant le render
    }
}
twig
{# templates/components/ProductSearch.html.twig #}
<div {{ attributes }}>
    <input
        data-model="debounce(300)|query"
        type="search"
        placeholder="Rechercher un produit"
    >

    <select data-model="category">
        <option value="all">Toutes catégories</option>
        {% for c in categories %}
            <option value="{{ c.slug }}">{{ c.name }}</option>
        {% endfor %}
    </select>

    <select data-model="on(change)|sort">
        <option value="name">Nom</option>
        <option value="price">Prix</option>
        <option value="newest">Plus récents</option>
    </select>

    <ul>
        {% for product in this.results %}
            <li>{{ product.name }} — {{ product.price|format_currency('EUR') }}</li>
        {% else %}
            <li><em>Aucun résultat.</em></li>
        {% endfor %}
    </ul>
</div>

Les modificateurs data-model :

  • debounce(300)|query : debounce 300ms avant l'envoi.
  • on(change)|category : synchronise au change (au lieu de input).
  • norender|hiddenProp : modifie côté client sans re-render serveur.

File upload live

php
#[AsLiveComponent]
final class AvatarUploader
{
    use DefaultActionTrait;

    #[LiveProp]
    public ?string $previewUrl = null;

    #[LiveAction]
    public function upload(Request $request, FileUploader $uploader): void
    {
        /** @var UploadedFile $file */
        $file = $request->files->get('avatar');
        $path = $uploader->upload($file);
        $this->previewUrl = $path;
    }
}
twig
<form {{ attributes }} data-controller="form-upload">
    <input type="file" name="avatar"
           data-action="change->live#action"
           data-live-action-param="upload">

    {% if previewUrl %}
        <img src="{{ previewUrl }}" alt="Aperçu" width="120">
    {% endif %}
</form>

🎯 Patterns courants

Slots et block

twig
{# templates/components/Card.html.twig #}
<article class="card" {{ attributes }}>
    {% block header %}<header class="card__header">{{ title }}</header>{% endblock %}
    <div class="card__body">{% block content %}{% endblock %}</div>
    {% block footer %}{% endblock %}
</article>
twig
<twig:Card title="Mon article">
    <twig:block name="header">
        <h2>Personnalisé</h2>
    </twig:block>

    Voici le contenu principal.

    <twig:block name="footer">
        <a href="#">Lire la suite</a>
    </twig:block>
</twig:Card>
php
#[AsLiveComponent]
final class UserMenu
{
    use DefaultActionTrait;

    #[LiveProp(writable: true)]
    public bool $open = false;

    #[LiveProp]
    public bool $loaded = false;

    public ?array $notifications = null;

    #[LiveAction]
    public function open(NotificationRepository $repo): void
    {
        $this->open = true;
        if (!$this->loaded) {
            $this->notifications = $repo->findUnread();
            $this->loaded = true;
        }
    }
}

Dashboard interactif

Combine plusieurs Live Components imbriqués. Chaque component a son propre cycle, et tu peux émettre des events entre composants. Il y a trois portées d'émission, à choisir consciemment :

php
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\ComponentToolsTrait; // fournit emit()/emitUp()/emitSelf()

#[AsLiveComponent]
final class FilterPanel
{
    use DefaultActionTrait;
    use ComponentToolsTrait;

    #[LiveAction]
    public function apply(): void
    {
        // emit()      : remonte au navigateur, ré-émis vers TOUS les composants montés
        // emitUp()    : seulement les parents (remonte l'arbre)
        // emitSelf()  : seulement ce composant (re-trigger un listener local)
        $this->emit('filters:changed', ['category' => $this->category]);
    }
}

#[AsLiveComponent]
final class ResultGrid
{
    use DefaultActionTrait;

    // Le payload de l'event est injecté par nom d'argument (#[LiveArg] optionnel).
    #[LiveListener('filters:changed')]
    public function onFiltersChanged(#[LiveArg] string $category): void
    {
        $this->category = $category; // déclenche un re-render de ResultGrid
    }
}

Coût caché : emit() fait un aller-retour réseau par composant qui écoute (chaque listener = sa propre requête de re-render, parallélisées par le client). Sur un dashboard avec 8 widgets à l'écoute, un emit() = 8 requêtes PHP simultanées. Préfère emitUp()/emitSelf() quand la portée est connue, et regroupe l'état partagé dans un parent unique plutôt que de fan-out.

Temps réel multi-utilisateurs via Mercure

php
#[AsLiveComponent]
final class LiveCounter
{
    use DefaultActionTrait;

    #[LiveProp]
    public int $count = 0;

    public function mount(int $initial): void
    {
        $this->count = $initial;
    }
}
twig
<div {{ attributes }}
     data-controller="live"
     data-live-topics-value='["counter/{{ counterId }}"]'>
    <strong>{{ count }}</strong>
</div>

Quand le serveur publie sur le topic counter/123, tous les clients abonnés re-render automatiquement.

Debounce, sync, throttle

html
<input data-model="debounce(500)|search">       <!-- attend 500ms après dernière frappe -->
<input data-model="throttle(200)|x">            <!-- max 1 envoi / 200ms -->
<input data-model="on(blur)|name">              <!-- envoie au blur -->
<input data-model="norender|cursorPos">         <!-- envoie sans re-render -->
<input data-model.lazy="email">                 <!-- alias de on(change) -->

Validation live

php
#[AsLiveComponent]
final class RegistrationForm
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    #[LiveAction]
    public function validateField(#[LiveArg] string $field): void
    {
        $this->submitForm(validateAll: false);
        // Symfony validation appelée, erreurs disponibles dans le template
    }
}
twig
{% for child in form %}
    <div class="form-row">
        {{ form_label(child) }}
        {{ form_widget(child, {
            attr: {
                'data-action': 'blur->live#action',
                'data-live-action-param': 'validateField',
                'data-live-field-param': child.vars.name,
            }
        }) }}
        {{ form_errors(child) }}
    </div>
{% endfor %}

🔄 Versions — Symfony 5.4 / 6.4 / 7.x + Symfony UX versions

Composant5.4 LTS6.4 LTS7.x
symfony/ux-twig-component2.x (Encore)2.20+2.x recommandé
symfony/ux-live-component2.x2.20+2.x
Syntaxe <twig:Comp />2.8+OKOK
Anonymous components2.8+OKOK
{% props %} natif Twign/aUX 2.18+OK
Form trait2.xOKOK
data-model modifiers2.6+OKOK
File upload live2.8+OKOK
idiomorph DOM patchingnonUX 2.17+ par défautOK
Mercure live2.6+OKOK
Validation live (submitForm)2.xOKOK
#[LiveListener] / emit()2.xOKOK

Notes :

  • Avant UX 2.17, le DOM patching utilisait morphdom (moins robuste sur les forms imbriqués). idiomorph depuis 2.17 résout 90% des cas de "perte de focus" ou "scroll qui saute".
  • En 5.4 LTS, UX 2.x marche mais nécessite Encore. AssetMapper n'est pas disponible avant 6.3.
  • <twig:Comp /> est la syntaxe moderne ; l'ancienne fonction Twig reste supportée :
twig
{{ component('Comp', { variant: 'danger' }) }}

⚠️ Pitfalls — 6-10

  1. State non sérialisable : LiveProp doit pouvoir être sérialisée en JSON (string, int, bool, array, entités Doctrine via useSerializerForHydration: true). Objets quelconques → erreur runtime.
  2. Re-render trop fréquent : chaque data-model sans debounce envoie une requête par keystroke. Inputs texte → toujours debounce(200) minimum.
  3. Méthodes lourdes appelées au render : getResults() appelée à chaque re-render. Si elle hit la DB, tu paies à chaque touche. Utilise #[ExposeInTemplate] avec un cache mémoire, ou compute en mount.
  4. Form trait + LiveProp writable sur la data : ne mets pas #[LiveProp] sur $post si tu utilises ComponentWithFormTrait — c'est le form qui gère l'état.
  5. CSRF token et Live : Live Components gèrent leur token automatiquement, mais si tu inclus un form imbriqué non-Live, ne mets pas de _token dupliqué.
  6. Sécurité : tout LiveProp(writable: true) est modifiable depuis le navigateur. N'expose pas userId writable. Utilise #[LiveProp] (non writable) ou re-vérifie côté serveur.
  7. mount() vs __construct : mount(args) reçoit les paramètres du composant (<twig:MyComp foo="x" />), __construct reçoit les services injectés. Ne pas confondre.
  8. Imbrication profonde : un Live Component dans un Live Component dans un Live Component. Possible mais coûteux : chaque parent re-render redessine les enfants. Préfère un seul Live avec sous-Twig Components statiques.
  9. JS controllers Stimulus dans un Live Component : ils sont détachés et rattachés à chaque morph. Toujours libérer en disconnect() (timers, listeners). Idiomorph aide mais ne magique pas tout.
  10. File uploads et re-render : un <input type="file"> perd son FileList si le composant re-rend pendant que l'utilisateur sélectionne. Utilise norender ou isole l'upload dans un sous-composant.

🏭 Production — perf, sécurité, observabilité, scale

Le coût réel d'un Live Component

Chaque interaction qui re-render est une requête HTTP qui boote le kernel Symfony complet : routing, security firewall, hydratation, ton code, rendu Twig, dehydratation. Ce n'est pas un appel WebSocket léger comme LiveView. Le budget mental :

1 keystroke (sans debounce) ≈ 1 requête PHP ≈ 5–40 ms de CPU + I/O DB
Input de recherche, frappe normale (~6 car/s) sans debounce
   = 6 req/s/utilisateur. 200 users actifs = 1200 req/s rien que pour taper.
Avec debounce(300) : ~1 req par pause de frappe → divise par 5–10.

Règles de budget production :

  • Tout input texte : debounce(300) minimum, debounce(500) pour les recherches qui hit la DB.
  • Toute méthode getXxx() appelée dans le template est exécutée à chaque re-render. Mémoïse :
php
private ?array $resultsCache = null;

public function getResults(): array
{
    // Recalculé une seule fois par cycle de requête (un re-render = une instance neuve).
    return $this->resultsCache ??= $this->repo->search($this->query, $this->category);
}
  • N'appelle pas plusieurs fois le même getter coûteux dans le template ; affecte à une variable Twig : {% set results = this.results %}.

Sécurité — surface d'attaque spécifique

VecteurRisqueMitigation
LiveProp writable tamperingLe client POST une valeur arbitraire (type respecté). IDOR si writable porte un identifiant.Jamais d'ID d'autorisation writable ; re-#[IsGranted] ou voter dans chaque LiveAction.
Mass assignment via writable: [...] sur entitéExposer trop de champs writable = le client édite role, price, isAdmin.Liste blanche minimale de writablePaths. Préfère un DTO de form dédié.
LiveAction = endpoint publicToute méthode #[LiveAction] est appelable par n'importe qui qui connaît le nom du composant.#[IsGranted] sur l'action sensible ; valide les #[LiveArg].
Fuite via LiveProp non-writableSignée ≠ chiffrée : tout est lisible dans le HTML source.Aucun secret en LiveProp. Recharge les données sensibles côté serveur depuis l'ID.
DoS par re-renderUn attaquant scripte 10k req/s sur /_components/....Rate-limit le firewall sur le path _components, debounce, cache les getters.
php
#[LiveAction]
#[IsGranted('ROLE_EDITOR')] // l'attribut security marche sur les LiveActions
public function publish(): void { /* ... */ }

Observabilité

Les requêtes Live arrivent toutes sur la route interne live_component (path /_components/{_live_component}/{_live_action}). Pour les voir distinctement en prod :

  • Logs / APM : tague par _route == 'ux_live_component' et par header X-Requested-With: XMLHttpRequest. Sépare la latence p95 des re-renders de celle des pages full. Un p95 de re-render > 200 ms ruine la sensation "instantanée".
  • Métrique clé : requêtes Live par interaction utilisateur. Si elle grimpe, tu as un emit() qui fan-out ou un debounce manquant.
  • Profiler : le Symfony Profiler s'attache aux requêtes Live (toolbar invisible mais token disponible) — inspecte les requêtes Doctrine d'un re-render comme une page normale. Le N+1 le plus courant : un getter appelé dans une boucle Twig.
  • Frontend : écoute les events JS live:connect, live:request, live:response pour mesurer le RTT perçu et afficher un état de chargement (data-loading est posé automatiquement sur le composant pendant la requête — style-le).

Scale

  • Stateless = horizontalement scalable : aucune affinité de session requise, n'importe quel worker PHP-FPM peut traiter n'importe quelle interaction. C'est l'avantage structurel sur LiveView (process Erlang collant à un nœud).
  • Le DOM grossit avec l'état : data-live-props-value voyage à chaque requête. Un composant avec un gros tableau en LiveProp = payload lourd montant ET descendant à chaque keystroke. Garde les LiveProps minces ; recharge les listes depuis la DB plutôt que de les trimballer.
  • Cache HTTP : les re-renders sont des POST → non cacheables. Mets le cache là où il compte : repositories (getResults() derrière un cache applicatif clé par hash de config), fragments Twig statiques en sous-Twig Components.
  • Mercure pour le multi-user : externalise la diffusion temps réel vers le hub Mercure plutôt que du polling. Le hub scale indépendamment des workers PHP.

🧪 Testing

Test d'un Twig Component

php
namespace App\Tests\Twig;

use App\Twig\Components\Button;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents;

final class ButtonTest extends KernelTestCase
{
    use InteractsWithTwigComponents;

    public function testRendersWithVariant(): void
    {
        $rendered = $this->renderTwigComponent('Button', [
            'label' => 'OK',
            'variant' => 'success',
        ]);

        $this->assertStringContainsString('class="btn btn--success"', $rendered);
        $this->assertStringContainsString('>OK<', $rendered);
    }

    public function testExposesComputedClasses(): void
    {
        $component = $this->mountTwigComponent('Button', [
            'label' => 'Save',
            'icon' => 'check',
        ]);

        $this->assertSame('btn btn--primary btn--with-icon', $component->getClasses());
    }
}

Test d'un Live Component

php
namespace App\Tests\Twig;

use App\Twig\Components\ProductSearch;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;

final class ProductSearchTest extends KernelTestCase
{
    use InteractsWithLiveComponents;

    public function testFiltersByQuery(): void
    {
        $component = $this->createLiveComponent(
            name: 'ProductSearch',
            data: ['query' => '', 'category' => 'all'],
        );

        $component->set('query', 'iphone');
        $component->refresh();

        $rendered = $component->render()->toString();
        $this->assertStringContainsString('iPhone 15', $rendered);
        $this->assertStringNotContainsString('Galaxy S24', $rendered);
    }

    public function testActionAddTag(): void
    {
        $component = $this->createLiveComponent('PostForm');
        $component->call('addTag', ['tag' => 'symfony']);

        $this->assertContains('symfony', $component->object()->getForm()->getData()->getTags());
    }
}

Test E2E avec Panther

php
public function testLiveSearchUpdatesResults(): void
{
    $client = self::createPantherClient();
    $client->request('GET', '/products');

    $input = $client->getWebDriver()->findElement(
        \Facebook\WebDriver\WebDriverBy::cssSelector('input[data-model*="query"]')
    );
    $input->sendKeys('phone');

    // Attendre que le composant Live se rende avec les résultats
    $client->waitFor('li:contains("iPhone")');
    $this->assertSelectorTextContains('ul', 'iPhone');
}

🎬 Cas d'usage concrets

Formulaire intake client cabinet juridique

Un cabinet d'avocats reçoit chaque mois des dizaines de nouveaux clients. Pour standardiser le recueil d'informations (état civil, situation matrimoniale, type de dossier, conflits d'intérêts potentiels, documents fournis), l'équipe IT a développé un formulaire d'intake numérique remplaçant le PDF qui transitait par mail. Le défi : le formulaire est dynamique. Si le client coche "litige immobilier", des questions spécifiques apparaissent (référence cadastrale, copropriété, copie acte). S'il coche "divorce", on demande la date de mariage, le régime matrimonial, l'existence d'enfants. S'il déclare une procédure en cours, on affiche un champ pour saisir le numéro de RG et la juridiction. La solution Live Component remplace une jungle de JavaScript jQuery jadis difficile à maintenir : une seule classe ClientIntakeForm avec LiveProp(writable: true) pour chaque champ de premier niveau, et des getXxxFieldsVisible() calculés qui pilotent les blocs conditionnels via {% if %} dans le template. La validation est progressive : à chaque blur de champ, le formulaire repasse côté serveur, la validation Symfony se déclenche, et les erreurs apparaissent en rouge sous chaque champ concerné, sans perte de focus. Avantage massif : la logique de conditionalité vit dans le code PHP, versionnée, testable. L'avocat associé peut faire évoluer les règles métier sans toucher au front. Lors de la soumission finale, un PDF de synthèse est généré et envoyé pour signature électronique.

Validation live KYC banque

Une néobanque B2C en pleine croissance doit onboarder des centaines de clients par jour avec un parcours KYC (Know Your Customer) conforme à la directive AMLD6. Le formulaire d'inscription demande nom, prénom, date de naissance, numéro de pièce d'identité, justificatif de domicile, situation professionnelle, déclaration PEP (personne politiquement exposée). Chaque champ doit être validé en temps réel pour ne pas frustrer l'utilisateur en lui annonçant ses erreurs à la fin. Le Live Component KycForm valide chaque champ au blur via submitForm(validateAll: false) et un LiveArg qui indique quel champ doit être contrôlé. Plus subtil : le numéro de pièce d'identité est cross-checké en temps réel contre l'API de la base nationale via un service IdentityChecker, et le composant affiche une icône verte ou un message d'erreur ("ce numéro n'est pas reconnu"). Pour les PEP, une recherche fuzzy déclenche dès le 3e caractère du nom une vérification contre la liste consolidée OFAC/UE/FR. Le composant gère l'upload des documents (CNI recto/verso, justificatif de domicile) via LiveAction avec preview immédiat. Les images sont stockées en S3 avec chiffrement côté serveur et URL signée. La fluidité de l'UX a transformé le taux d'abandon : 73% avant à 38% après, soit deux fois plus de comptes ouverts par jour à effort équivalent.

Configurateur produit e-commerce avec preview

Une marque de cuisines équipées en kit propose à ses clients un configurateur 3D pour assembler leur cuisine : ils choisissent la disposition (linéaire, en L, en U), le linéaire en mètres, les matériaux des façades, le plan de travail, les électroménagers intégrés, les poignées. À chaque sélection, le prix se met à jour, la dispo stock se confirme, et un visuel 3D régénéré côté serveur (via une lib headless) est affiché. Le Live Component KitchenConfigurator centralise une vingtaine de LiveProp(writable: true). Le getPrice() recalcule à chaque rendu en appelant le service de pricing (avec un cache mémoire par hash de configuration pour éviter de retaper la DB). Le getRender3d() génère une URL signée vers un endpoint qui produit l'image PNG via un microservice de rendu, mise en cache 1h. Les changements de matériau utilisent data-model="on(change)|material" pour ne déclencher qu'au commit du select, pas au survol. Quand le client clique sur "Ajouter au devis", un LiveAction save persiste sa configuration en base, lui envoie un PDF récapitulatif et l'invite à finaliser sa commande. Le configurateur gère aussi la sauvegarde anonyme via cookie (configuration persistée 30 jours pour récupération ultérieure). Avantage Live Components : zéro JS custom pour cette logique complexe, ce qui a permis à l'équipe back PHP de livrer la feature en 3 semaines au lieu des 8 estimées pour une implémentation React maison.

🛠️ Exemple end-to-end

Formulaire d'intake client cabinet juridique avec champs conditionnels, validation live et persistance progressive.

php
<?php
// src/Twig/Components/ClientIntakeForm.php
declare(strict_types=1);

namespace App\Twig\Components;

use App\Entity\ClientIntake;
use App\Form\ClientIntakeType;
use App\Repository\ClientIntakeRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
final class ClientIntakeForm
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    #[LiveProp]
    public ?int $intakeId = null;

    #[LiveProp(writable: true)]
    public bool $hasOngoingProcedure = false;

    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly ClientIntakeRepository $repo,
    ) {}

    protected function instantiateForm(): FormInterface
    {
        $intake = $this->intakeId !== null
            ? $this->repo->find($this->intakeId)
            : new ClientIntake();

        return $this->createForm(ClientIntakeType::class, $intake);
    }

    public function isDivorceFile(): bool
    {
        $data = $this->getForm()->getData();
        return $data instanceof ClientIntake && $data->getCaseType() === 'divorce';
    }

    public function isRealEstateFile(): bool
    {
        $data = $this->getForm()->getData();
        return $data instanceof ClientIntake && $data->getCaseType() === 'real_estate';
    }

    #[LiveAction]
    public function validateField(#[LiveArg] string $field): void
    {
        $this->submitForm(validateAll: false);
    }

    #[LiveAction]
    public function saveDraft(): void
    {
        $this->submitForm(validateAll: false);

        /** @var ClientIntake $intake */
        $intake = $this->getForm()->getData();
        $intake->setStatus('draft');
        $intake->touch();

        if ($this->intakeId === null) {
            $this->em->persist($intake);
        }
        $this->em->flush();
        $this->intakeId = $intake->getId();
    }

    #[LiveAction]
    public function submit(): RedirectResponse
    {
        $this->submitForm();

        /** @var ClientIntake $intake */
        $intake = $this->getForm()->getData();
        $intake->setStatus('submitted');
        $intake->setSubmittedAt(new \DateTimeImmutable());

        if ($this->intakeId === null) {
            $this->em->persist($intake);
        }
        $this->em->flush();

        return $this->redirectToRoute('intake_thanks', ['id' => $intake->getId()]);
    }
}
twig
{# templates/components/ClientIntakeForm.html.twig #}
<div {{ attributes }} class="intake-form" data-controller="auto-save">
    {{ form_start(form) }}

    <fieldset>
        <legend>Identité</legend>
        <div class="form-row">
            {{ form_label(form.lastName) }}
            {{ form_widget(form.lastName, { attr: {
                'data-action': 'blur->live#action',
                'data-live-action-param': 'validateField',
                'data-live-field-param': 'lastName',
            }}) }}
            {{ form_errors(form.lastName) }}
        </div>

        <div class="form-row">
            {{ form_label(form.firstName) }}
            {{ form_widget(form.firstName, { attr: {
                'data-action': 'blur->live#action',
                'data-live-action-param': 'validateField',
                'data-live-field-param': 'firstName',
            }}) }}
            {{ form_errors(form.firstName) }}
        </div>

        <div class="form-row">
            {{ form_label(form.birthDate) }}
            {{ form_widget(form.birthDate) }}
            {{ form_errors(form.birthDate) }}
        </div>
    </fieldset>

    <fieldset>
        <legend>Nature du dossier</legend>
        <div class="form-row">
            {{ form_label(form.caseType) }}
            {{ form_widget(form.caseType) }}
        </div>

        {% if this.isDivorceFile %}
            <div class="form-row conditional">
                {{ form_label(form.marriageDate) }}
                {{ form_widget(form.marriageDate) }}
                {{ form_errors(form.marriageDate) }}
            </div>
            <div class="form-row conditional">
                {{ form_label(form.matrimonialRegime) }}
                {{ form_widget(form.matrimonialRegime) }}
                {{ form_errors(form.matrimonialRegime) }}
            </div>
            <div class="form-row conditional">
                {{ form_label(form.hasChildren) }}
                {{ form_widget(form.hasChildren) }}
            </div>
        {% endif %}

        {% if this.isRealEstateFile %}
            <div class="form-row conditional">
                {{ form_label(form.cadastralReference) }}
                {{ form_widget(form.cadastralReference, { attr: {
                    'data-action': 'blur->live#action',
                    'data-live-action-param': 'validateField',
                    'data-live-field-param': 'cadastralReference',
                }}) }}
                {{ form_errors(form.cadastralReference) }}
            </div>
            <div class="form-row conditional">
                {{ form_label(form.isCoOwnership) }}
                {{ form_widget(form.isCoOwnership) }}
            </div>
        {% endif %}
    </fieldset>

    <fieldset>
        <legend>Procédure en cours</legend>
        <label>
            <input type="checkbox" data-model="hasOngoingProcedure">
            J'ai déjà une procédure en cours sur ce dossier
        </label>

        {% if hasOngoingProcedure %}
            <div class="form-row conditional">
                {{ form_label(form.rgNumber) }}
                {{ form_widget(form.rgNumber) }}
                {{ form_errors(form.rgNumber) }}
            </div>
            <div class="form-row conditional">
                {{ form_label(form.jurisdiction) }}
                {{ form_widget(form.jurisdiction) }}
                {{ form_errors(form.jurisdiction) }}
            </div>
        {% endif %}
    </fieldset>

    <div class="actions">
        <button type="button"
                class="btn btn--ghost"
                data-action="live#action"
                data-live-action-param="saveDraft">
            Enregistrer le brouillon
        </button>

        <button type="button"
                class="btn btn--primary"
                data-action="live#action"
                data-live-action-param="submit">
            Soumettre au cabinet
        </button>
    </div>

    {{ form_end(form) }}
</div>

Le composant gère trois cas : (1) sauvegarde automatique en brouillon toutes les 30 secondes via le controller Stimulus auto-save qui déclenche live#action saveDraft, (2) validation au blur de chaque champ critique pour donner du feedback instantané, (3) soumission finale qui passe la validation complète et redirige vers une page de remerciement. Côté tests, InteractsWithLiveComponents permet de simuler set('hasOngoingProcedure', true) puis call('validateField', ['field' => 'rgNumber']) et de vérifier que la validation Symfony s'applique bien sur le champ conditionnel.

🔁 Quand utiliser / éviter

Utilise Twig Components quand :

  • Tu veux factoriser des bouts de template (cards, alerts, buttons, layouts).
  • Tu veux du typage des props (class-based).
  • Pas de réactivité — juste de la composition.

Utilise Live Components quand :

  • Formulaires dynamiques (champs conditionnels, validation au blur, ajout/retrait de lignes).
  • Recherche live, filtres facettés.
  • Dashboards qui se mettent à jour selon des sélections utilisateur.
  • Tu veux écrire zéro JS custom pour 90% des interactions.
  • L'équipe est backend-heavy.

Évite si :

  • Latence réseau forte (utilisateurs mobile 3G) — chaque interaction = aller-retour. Préférer client-side.
  • Composant ultra-interactif (drag-and-drop, canvas) — JS pur ou React.
  • App offline — Live nécessite le serveur.
  • Trafic massif et serveurs limités — chaque keystroke = requête PHP. À budgéter ou debounce agressivement.

Comparaison vs HTMX vs LiveView Phoenix vs React Server Components

CritèreLive Components (Symfony)HTMXPhoenix LiveView (Elixir)React Server Components
Langage backendPHPn'importe lequelElixirNode.js / Next.js
TransportHTTP POST partielHTTP (GET/POST headers)WebSocket persistentRSC streaming + Server Actions
État serveursérialisé dans le DOM (LiveProp)stateless (vrai REST)process Erlang (état mémoire)re-fetched à chaque interaction
Re-render granularitécomposant entier morphéswap CSS-selector ciblédiff fin sur DOMcomposants serveur
JS custom requisminimal (Stimulus optionnel)aucun (attributs hx-*)minimalclient components pour interactions
Latence sur inputRTT serveur (debounce conseillé)idemtrès faible (WebSocket)idem RTT
Temps réel multi-uservia Mercurevia SSE/WebSocket addonnatif (pub-sub Phoenix)non natif (besoin librairie)
Bundle JS~40 kB (Stimulus + UX Live)~14 kB~30 kB~80 kB (React runtime)
DX backend-heavyexcellentexcellentexcellent (mais Elixir)bon (mais culture JS)
Courbe apprentissagefaible (PHP/Twig connus)très faiblemoyenne (Elixir)moyenne-haute
Écosystème composantsgrandissant (UX packages)limité (DIY)limitéénorme (React)
SEOexcellent (HTML rendu serveur)excellentexcellentexcellent
Mode offlinenonnonnonpartiel (RSC + cache)

Quand préférer chacun :

  • Live Components : équipe Symfony, projet PHP, besoin de composants riches avec validation Symfony native.
  • HTMX : projet polyglotte, équipe qui veut rien apprendre côté front, interactions simples.
  • LiveView : si tu choisis Elixir/Phoenix pour le projet, c'est le standard.
  • RSC : projet Next.js, équipe React expérimentée, besoin de SSR avancé + streaming.

Pour une équipe Symfony 6.4/7.x, Live Components est le sweet spot : intégration parfaite avec services, security, validation, formulaires, Mercure. Pour les rares cas où tu as besoin de plus d'interactivité, ajoute un Stimulus controller dans le composant Live — la composition est native.

🏋️ Exercices

Progressifs : implémenter → production-grade → casser puis réparer. Crée une app Symfony 7.x + symfony/ux-live-component pour les faire.

1. Recherche live paginée (implémenter)

Objectif : un ProductSearch avec query débouncée, filtre catégorie, et pagination « charger plus » sans recharger la liste existante. Indice/Solution : #[LiveProp(writable: true)] $query + data-model="debounce(300)|query". Une #[LiveProp(writable: true)] $page = 1, et un #[LiveAction] loadMore() qui fait $this->page++. Mémoïse getResults() et concatène les pages. Vérifie que l'input garde le focus après chaque frappe (idiomorph).

2. Formulaire à lignes dynamiques (implémenter)

Objectif : un éditeur de facture où l'utilisateur ajoute/supprime des lignes (CollectionType), le total HT/TTC se recalcule live, sans flush DB tant qu'on n'a pas cliqué « Enregistrer ». Indice/Solution : ComponentWithFormTrait + CollectionType avec allow_add/allow_delete. #[LiveAction] addLine() / removeLine(#[LiveArg] int $index) manipulent les données du form sans persister. Le total est un getter mémoïsé sur getForm()->getData(). Un seul #[LiveAction] save() persiste.

3. Durcir l'autorisation (production-grade)

Objectif : sur l'éditeur de facture, garantir qu'un utilisateur ne peut éditer que ses propres factures, même en POSTant un invoiceId falsifié. Indice/Solution : invoiceId en #[LiveProp] non-writable ne suffit pas (il est signé à la création, mais un autre utilisateur a le sien). La vraie barrière : un voter #[IsGranted('EDIT', subject: 'invoice')] qui recharge l'entité et compare getOwner() à l'utilisateur courant, appelé dans instantiateForm() ET dans chaque LiveAction. Écris un test qui monte le composant avec l'ID d'autrui et attend un AccessDeniedException.

4. Tuer le N+1 caché (production-grade)

Objectif : un dashboard liste 30 commandes, chacune affichant le nom du client et le total via des getters. Mesure les requêtes Doctrine d'un re-render, puis ramène-les à un nombre constant. Indice/Solution : ouvre le Profiler sur la requête _components. Tu verras 1 + 30 requêtes (lazy-load de order.customer). Fix : un seul fetch-join dans le repository, exposé via un getter mémoïsé, ou des DTO plats hydratés en une requête. Assert le compte de requêtes avec DoctrineTestBundle ou un logger SQL en test.

5. Casser puis réparer — perte du FileList (break-then-fix)

Objectif : reproduire le bug où un <input type="file"> perd la sélection de l'utilisateur quand un autre champ déclenche un re-render, puis le corriger. Indice/Solution : mets l'upload dans le même composant qu'un input débouncé. Tape dans l'input pendant que le file dialog est ouvert → le morph détruit le FileList. Fix : isole l'upload dans un sous-composant Live dédié (re-render indépendant), ou marque les champs voisins norender, ou ajoute data-live-ignore sur le bloc de l'input file pour qu'idiomorph ne le touche pas.

6. Temps réel multi-onglets (break-then-fix)

Objectif : un compteur partagé via Mercure ; reproduire une race où deux onglets incrémentent « en même temps » et perdent un incrément, puis garantir la cohérence. Indice/Solution : $this->count++ lit l'état hydraté du DOM (potentiellement périmé) → perte de mise à jour classique. Fix : ne stocke pas le compteur en LiveProp writable. L'incrément doit être une opération atomique côté serveur (UPDATE counter SET value = value + 1) puis publier la nouvelle valeur sur le topic Mercure ; les LiveProps ne servent qu'à l'affichage, jamais de source de vérité pour un compteur concurrent.

🎤 En entretien

Q : Où vit l'état d'un Live Component entre deux interactions, et quelle est l'implication sécurité ? Dans le DOM (data-live-props-value), sérialisé en JSON et signé par HMAC — pas en session ni en mémoire serveur. Implication : toute LiveProp writable est un paramètre de requête non-trusté (valider/ré-autoriser côté serveur), et le checksum garantit l'intégrité mais pas la confidentialité (tout est lisible en clair). Donc aucun secret en LiveProp.

Q : Live Components vs Phoenix LiveView — différence architecturale fondamentale ? LiveView garde l'état dans un process Erlang en mémoire, sur une connexion WebSocket persistante collée à un nœud → diff fin, latence ultra-faible, mais affinité de nœud et état serveur à gérer. Live Components est stateless sur HTTP POST : l'état voyage dans le DOM, n'importe quel worker PHP traite n'importe quelle requête → scale horizontal trivial, mais chaque interaction boote le kernel (plus lourd) et exige un debounce.

Q : Tu vois 1200 req/s sur /_components pour 200 users. Diagnostic ? Probablement des data-model sans debounce sur des inputs texte (1 req/keystroke), et/ou un emit() qui fan-out vers N composants à l'écoute (N req par interaction). Fix : debounce(300) sur les inputs, remplacer emit() par emitUp()/emitSelf() à portée connue, regrouper l'état partagé dans un parent unique, et rate-limiter le path.

Q : Quand refuserais-tu Live Components au profit d'autre chose ? Latence réseau élevée (mobile 3G : chaque interaction = RTT visible), interactions ultra-fines à 60 fps (drag-and-drop, canvas, jeu) où le round-trip serveur est rédhibitoire, ou besoin offline. Là, du JS client (Stimulus riche, Vue/React îlot) gère l'interaction locale ; Live reste pertinent pour la couche formulaire/validation/persistance.

🔗 Liens

Bibliothèque tech perso — Achref