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 fichiertemplates/components/Alert.html.twig(anonyme) ou une classe PHPApp\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 writtenAnalogie : 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écision | Pourquoi | Le réflexe staff |
|---|---|---|
writable: true = endpoint public | Le 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 :
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 :
#[LiveProp(writable: ['title', 'status'])] // seuls title et status sont modifiables côté client
public Post $post;🛠️ Code minimal (PHP 8.2+ + Twig + JS)
Installation
composer require symfony/ux-twig-component
composer require symfony/ux-live-component
# Avec AssetMapper, les controllers JS sont auto-chargés via controllers.jsonTwig Component anonyme
{# 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">×</button>
{% endif %}
</div>{# Usage #}
<twig:Alert variant="danger" dismissible>
Une erreur s'est produite : {{ error }}
</twig:Alert>Twig Component class-based
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' : ''
);
}
}{# templates/components/Button.html.twig #}
<button type="{{ type }}" class="{{ classes }}" {{ attributes }}>
{% if icon %}<i class="icon icon--{{ icon }}"></i>{% endif %}
{{ label ?: block('content') }}
</button>{# 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
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);
}
}{# 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 :
- Déclenche un POST partiel vers le serveur.
- Le serveur re-hydrate le composant, re-soumet le form (sans flush DB), renvoie le HTML.
- 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
#[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
}
}{# 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 auchange(au lieu deinput).norender|hiddenProp: modifie côté client sans re-render serveur.
File upload live
#[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;
}
}<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
{# 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: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>Dropdown async (chargé à l'ouverture)
#[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 :
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, unemit()= 8 requêtes PHP simultanées. PréfèreemitUp()/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
#[AsLiveComponent]
final class LiveCounter
{
use DefaultActionTrait;
#[LiveProp]
public int $count = 0;
public function mount(int $initial): void
{
$this->count = $initial;
}
}<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
<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
#[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
}
}{% 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
| Composant | 5.4 LTS | 6.4 LTS | 7.x |
|---|---|---|---|
symfony/ux-twig-component | 2.x (Encore) | 2.20+ | 2.x recommandé |
symfony/ux-live-component | 2.x | 2.20+ | 2.x |
Syntaxe <twig:Comp /> | 2.8+ | OK | OK |
| Anonymous components | 2.8+ | OK | OK |
{% props %} natif Twig | n/a | UX 2.18+ | OK |
| Form trait | 2.x | OK | OK |
data-model modifiers | 2.6+ | OK | OK |
| File upload live | 2.8+ | OK | OK |
| idiomorph DOM patching | non | UX 2.17+ par défaut | OK |
| Mercure live | 2.6+ | OK | OK |
Validation live (submitForm) | 2.x | OK | OK |
#[LiveListener] / emit() | 2.x | OK | OK |
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 :
{{ component('Comp', { variant: 'danger' }) }}⚠️ Pitfalls — 6-10
- State non sérialisable :
LivePropdoit pouvoir être sérialisée en JSON (string, int, bool, array, entités Doctrine viauseSerializerForHydration: true). Objets quelconques → erreur runtime. - Re-render trop fréquent : chaque
data-modelsans debounce envoie une requête par keystroke. Inputs texte → toujoursdebounce(200)minimum. - 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 enmount. - Form trait + LiveProp writable sur la data : ne mets pas
#[LiveProp]sur$postsi tu utilisesComponentWithFormTrait— c'est le form qui gère l'état. - CSRF token et Live : Live Components gèrent leur token automatiquement, mais si tu inclus un form imbriqué non-Live, ne mets pas de
_tokendupliqué. - Sécurité : tout
LiveProp(writable: true)est modifiable depuis le navigateur. N'expose pasuserIdwritable. Utilise#[LiveProp](non writable) ou re-vérifie côté serveur. mount()vs__construct:mount(args)reçoit les paramètres du composant (<twig:MyComp foo="x" />),__constructreçoit les services injectés. Ne pas confondre.- 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.
- 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. - File uploads et re-render : un
<input type="file">perd sonFileListsi le composant re-rend pendant que l'utilisateur sélectionne. Utilisenorenderou 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 :
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
| Vecteur | Risque | Mitigation |
|---|---|---|
LiveProp writable tampering | Le 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 public | Toute 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-writable | Signé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-render | Un attaquant scripte 10k req/s sur /_components/.... | Rate-limit le firewall sur le path _components, debounce, cache les getters. |
#[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 headerX-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:responsepour mesurer le RTT perçu et afficher un état de chargement (data-loadingest 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-valuevoyage à 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
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
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
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
// 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()]);
}
}{# 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ère | Live Components (Symfony) | HTMX | Phoenix LiveView (Elixir) | React Server Components |
|---|---|---|---|---|
| Langage backend | PHP | n'importe lequel | Elixir | Node.js / Next.js |
| Transport | HTTP POST partiel | HTTP (GET/POST headers) | WebSocket persistent | RSC streaming + Server Actions |
| État serveur | sé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 DOM | composants serveur |
| JS custom requis | minimal (Stimulus optionnel) | aucun (attributs hx-*) | minimal | client components pour interactions |
| Latence sur input | RTT serveur (debounce conseillé) | idem | très faible (WebSocket) | idem RTT |
| Temps réel multi-user | via Mercure | via SSE/WebSocket addon | natif (pub-sub Phoenix) | non natif (besoin librairie) |
| Bundle JS | ~40 kB (Stimulus + UX Live) | ~14 kB | ~30 kB | ~80 kB (React runtime) |
| DX backend-heavy | excellent | excellent | excellent (mais Elixir) | bon (mais culture JS) |
| Courbe apprentissage | faible (PHP/Twig connus) | très faible | moyenne (Elixir) | moyenne-haute |
| Écosystème composants | grandissant (UX packages) | limité (DIY) | limité | énorme (React) |
| SEO | excellent (HTML rendu serveur) | excellent | excellent | excellent |
| Mode offline | non | non | non | partiel (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
- UX Twig Components : https://symfony.com/bundles/ux-twig-component/current/index.html
- UX Live Components : https://symfony.com/bundles/ux-live-component/current/index.html
- idiomorph DOM patching : https://github.com/bigskysoftware/idiomorph
- Article "Live Components vs HTMX" : https://ux.symfony.com/live-component
- Phoenix LiveView : https://hexdocs.pm/phoenix_live_view/
- HTMX docs : https://htmx.org/docs/
- React Server Components : https://react.dev/reference/rsc/server-components
- Mercure protocol : https://mercure.rocks/
- Stimulus handbook : https://stimulus.hotwired.dev/handbook/introduction
- SymfonyCasts UX Live : https://symfonycasts.com/screencast/live-component
- Article "Building reactive UIs without React" : https://symfony.com/blog/new-in-symfony-ux-2-live-components