Symfony UX — Stimulus + Turbo
TL;DR Stimulus + Turbo, c'est le pari de Basecamp/37signals (Hotwire) repris par Symfony UX : garder le rendu HTML côté serveur et n'ajouter du JS qu'aux endroits où c'est strictement nécessaire, via des controllers déclaratifs attachés à des attributs
data-controller. Turbo Drive intercepte tous les liens et formulaires pour transformer la navigation en SPA (sans rechargement complet). Turbo Frames remplace une portion ciblée du DOM (<turbo-frame>) sans toucher au reste. Turbo Streams permet au serveur de pousser des opérations (append,update,replace,remove) au navigateur, idéal couplé à Mercure pour du temps réel. Stimulus gère les comportements locaux (toggle, validation, autocomplete). Le combo permet de supprimer 70-90% du JS custom d'une app comparée à React/Vue, en gardant des perfs natives et un SEO impeccable. PHP 8.2+ avec attributs (#[Route],#[AsLiveComponent]...) joue parfaitement avec.
🧠 Mental model — ASCII + analogie
NAVIGATION CLASSIQUE (sans Hotwire)
click <a> ─► browser reload ─► server renders full HTML ─► CSS/JS re-parse
(flash, scroll lost, slow)
NAVIGATION TURBO DRIVE
click <a> ─► Turbo intercepts ─► fetch HTML ─► swap <body> ─► no reload
(smooth, scroll kept)
REMPLACEMENT TURBO FRAME
click <a data-turbo-frame="results">
─► fetch HTML ─► extract <turbo-frame id="results"> ─► swap that frame only
PUSH TURBO STREAM (HTTP ou Mercure)
server ─► <turbo-stream action="append" target="messages">
<template><div>nouveau message</div></template>
</turbo-stream>
─► appended at #messages without any custom JS
STIMULUS (comportement local)
<div data-controller="dropdown" data-dropdown-open-value="false">
<button data-action="click->dropdown#toggle">Menu</button>
<ul data-dropdown-target="menu" hidden>...</ul>
</div>
// dropdown_controller.js gère uniquement ce composantAnalogie : Hotwire (Stimulus + Turbo), c'est le menuisier qui renforce une maison plutôt que de la raser et la reconstruire. React/Vue, c'est démolir et bâtir une SPA neuve (qui doit redessiner toute la structure côté client). Hotwire garde les murs porteurs (HTML rendu serveur, sessions PHP, formulaires standards) et ajoute des charnières (Turbo) et des interrupteurs (Stimulus) là où c'est utile.
🛠️ Code minimal (PHP 8.2+ + Twig + JS)
Installation
composer require symfony/ux-turbo
composer require symfony/stimulus-bundle # avec AssetMapper
# OU pour Encore :
yarn add @hotwired/turbo @hotwired/stimulus @symfony/stimulus-bridge// assets/app.js
import { startStimulusApp } from '@symfony/stimulus-bundle';
import * as Turbo from '@hotwired/turbo'; // active Turbo Drive globalement
const app = startStimulusApp();
Turbo.session.drive = true;Stimulus controller — anatomie complète
// assets/controllers/search_controller.js
import { Controller } from '@hotwired/stimulus';
import { useDebounce } from 'stimulus-use';
export default class extends Controller {
static targets = ['input', 'results', 'spinner'];
static values = {
url: String,
minLength: { type: Number, default: 2 },
debounce: { type: Number, default: 200 },
};
static classes = ['loading'];
// `stimulus-use` lit `static debounces` pour wrapper ces méthodes.
// ⚠️ N'inclus QUE des méthodes appelées directement par un `data-action`
// ou par `inputTarget` — ici `fetch` est wrappée, donc onInput appelle
// `this.fetch(...)` qui est, lui, debounce.
static debounces = ['fetch'];
connect() {
// wait DOIT être lu après que les values soient résolues (connect, pas constructor)
useDebounce(this, { wait: this.debounceValue });
}
disconnect() {
// Crucial avec Turbo : un swap Drive appelle disconnect AVANT de jeter le DOM.
// Sans ça, une requête en vol pourrait écrire dans un `resultsTarget` détaché.
this.abortController?.abort();
}
inputTargetConnected(el) {
el.setAttribute('autocomplete', 'off');
}
onInput(event) {
const query = event.target.value.trim();
if (query.length < this.minLengthValue) {
this.resultsTarget.innerHTML = '';
this.abortController?.abort(); // annule un fetch en vol devenu inutile
return;
}
this.fetch(query); // debounce via `static debounces`
}
async fetch(query) {
// Race condition classique : taper "ab" puis "abc" lance 2 fetch.
// On annule TOUJOURS la requête précédente pour que la dernière frappe gagne.
this.abortController?.abort();
this.abortController = new AbortController();
this.element.classList.add(...this.loadingClasses);
try {
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`, {
signal: this.abortController.signal,
headers: { Accept: 'text/vnd.turbo-stream.html' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
// Turbo applique automatiquement le stream si le Content-Type est
// text/vnd.turbo-stream.html. Si on veut juste injecter du HTML brut,
// on lit le body nous-mêmes (cas ci-dessous).
const html = await response.text();
this.resultsTarget.innerHTML = html;
} catch (e) {
// AbortError = comportement attendu (frappe suivante), on l'ignore.
if (e.name !== 'AbortError') console.error(e);
} finally {
this.element.classList.remove(...this.loadingClasses);
}
}
}Détail staff : injecter du HTML reçu via
innerHTML = htmlcourt-circuite le pipeline Turbo Stream. Si le serveur renvoie de vrais<turbo-stream>, préfèreTurbo.renderStreamMessage(html)(importimport * as Turbo from '@hotwired/turbo'), qui exécute les actions (append,replace, …) au lieu de les afficher comme du texte inerte. LeAccept: text/vnd.turbo-stream.htmln'a de sens que si tu laisses Turbo traiter la réponse — sinon demandetext/html.
{# templates/search/index.html.twig #}
<div data-controller="search"
data-search-url-value="{{ path('search_ajax') }}"
data-search-debounce-value="300"
data-search-loading-class="opacity-50">
<input type="search"
data-search-target="input"
data-action="input->search#onInput"
placeholder="Rechercher...">
<div data-search-target="results"></div>
</div>Turbo Frame — remplacement partiel
{# templates/post/show.html.twig #}
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.body|raw }}</p>
<turbo-frame id="comments" src="{{ path('comments_list', { post: post.id }) }}" loading="lazy">
<p>Chargement des commentaires…</p>
</turbo-frame>
</article>#[Route('/posts/{id}/comments', name: 'comments_list')]
public function comments(Post $post, CommentRepository $repo): Response
{
return $this->render('comment/_frame.html.twig', [
'post' => $post,
'comments' => $repo->findBy(['post' => $post], ['createdAt' => 'DESC']),
]);
}{# templates/comment/_frame.html.twig #}
<turbo-frame id="comments">
<ul>
{% for c in comments %}
<li>{{ c.body }} — <em>{{ c.author }}</em></li>
{% endfor %}
</ul>
{{ form(commentForm) }}
</turbo-frame>Turbo Stream — broadcast via Mercure
// config/packages/turbo.yaml
turbo:
mercure:
hub: 'default'
# publishé sur le topic correspondant à l'IRI de l'entité
enabled: true// src/Entity/Message.php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;
#[ORM\Entity]
#[Broadcast] // publie create/update/remove via Mercure automatiquement
class Message
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 500)]
private string $body = '';
#[ORM\Column]
private \DateTimeImmutable $createdAt;
public function __construct(string $body)
{
$this->body = $body;
$this->createdAt = new \DateTimeImmutable();
}
// getters/setters
}{# templates/chat/room.html.twig #}
<turbo-stream-source src="{{ mercure('/messages') }}"></turbo-stream-source>
<div id="messages">
{% for message in messages %}
{{ include('message/_message.html.twig', { message }) }}
{% endfor %}
</div>{# templates/message/_message.html.twig #}
<div id="message-{{ message.id }}" data-controller="message">
<p>{{ message.body }}</p>
<small>{{ message.createdAt|format_datetime }}</small>
</div>{# templates/_broadcast/Message.stream.html.twig — créé automatiquement par UX-Turbo #}
{% block create %}
<turbo-stream action="append" target="messages">
<template>{{ include('message/_message.html.twig', { message }) }}</template>
</turbo-stream>
{% endblock %}
{% block remove %}
<turbo-stream action="remove" target="message-{{ id }}"></turbo-stream>
{% endblock %}Formulaire avec Turbo
#[Route('/comments', methods: ['POST'], name: 'comment_create')]
public function create(Request $request, EntityManagerInterface $em): Response
{
$comment = new Comment();
$form = $this->createForm(CommentType::class, $comment);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->persist($comment);
$em->flush();
// Si le client accepte turbo-stream, on répond en stream
if ($request->getPreferredFormat() === TurboBundle::STREAM_FORMAT) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->render('comment/_stream_created.html.twig', ['comment' => $comment]);
}
return $this->redirectToRoute('post_show', ['id' => $comment->getPost()->getId()]);
}
// Erreur de validation : Turbo Drive sait re-render le form avec status 422
return $this->render('comment/_form.html.twig', ['form' => $form], new Response('', 422));
}Astuce critique : Turbo réagit aux erreurs de form via le status HTTP. Renvoyer un
200avec un form qui contient des erreurs ne fonctionnera pas — Turbo va naviguer vers la nouvelle URL. Toujours renvoyer 422 pour qu'il garde l'URL et remplace le contenu.
🎯 Patterns courants
Table sortable
<table data-controller="sortable-table"
data-action="click->sortable-table#sort">
<thead>
<tr>
<th data-sortable-table-target="header" data-key="name">Nom</th>
<th data-sortable-table-target="header" data-key="email">Email</th>
<th data-sortable-table-target="header" data-key="createdAt">Inscrit le</th>
</tr>
</thead>
<tbody data-sortable-table-target="body">
{% for u in users %}
<tr><td>{{ u.name }}</td><td>{{ u.email }}</td><td>{{ u.createdAt|date('Y-m-d') }}</td></tr>
{% endfor %}
</tbody>
</table>// assets/controllers/sortable-table_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['header', 'body'];
sort(event) {
const th = event.target.closest('th[data-key]');
if (!th) return;
const key = th.dataset.key;
const direction = th.dataset.direction === 'asc' ? 'desc' : 'asc';
this.headerTargets.forEach(h => delete h.dataset.direction);
th.dataset.direction = direction;
const rows = Array.from(this.bodyTarget.querySelectorAll('tr'));
rows.sort((a, b) => {
const av = a.children[this.indexOf(th)].textContent;
const bv = b.children[this.indexOf(th)].textContent;
return direction === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
});
rows.forEach(r => this.bodyTarget.appendChild(r));
}
indexOf(th) { return this.headerTargets.indexOf(th); }
}Modal
<a href="{{ path('product_show', { id: p.id }) }}"
data-turbo-frame="modal">Voir détails</a>
<turbo-frame id="modal" data-controller="modal" data-action="turbo:frame-load->modal#open">
</turbo-frame>// assets/controllers/modal_controller.js
export default class extends Controller {
open() {
if (this.element.children.length > 0) {
this.element.classList.add('modal--visible');
}
}
close() {
this.element.classList.remove('modal--visible');
this.element.innerHTML = '';
}
}Recherche live
Pattern : un <turbo-frame> ciblé + un Stimulus controller qui debounce la saisie et soumet le formulaire.
<form action="{{ path('search') }}"
data-controller="auto-submit"
data-action="input->auto-submit#submit"
data-turbo-frame="search-results">
<input name="q" type="search">
</form>
<turbo-frame id="search-results"></turbo-frame>export default class extends Controller {
static debounces = ['submit'];
connect() { useDebounce(this, { wait: 250 }); }
submit() { this.element.requestSubmit(); }
}Infinite scroll
<turbo-frame id="products-page-{{ page }}">
{% for p in products %}{{ include('product/_card.html.twig', { p }) }}{% endfor %}
{% if hasMore %}
<turbo-frame id="products-page-{{ page + 1 }}"
src="{{ path('products_page', { page: page + 1 }) }}"
loading="lazy"
data-turbo-action="advance">
<p>Chargement…</p>
</turbo-frame>
{% endif %}
</turbo-frame>L'attribut loading="lazy" déclenche le fetch quand le frame entre dans le viewport — pas de JS custom.
Lifecycle Stimulus
Méthodes appelées automatiquement :
| Méthode | Quand |
|---|---|
initialize() | une fois, au premier instancement |
connect() | à chaque attachement au DOM (y compris après Turbo) |
disconnect() | détachement du DOM (avant un swap Turbo) |
<name>TargetConnected(el) | un target apparaît |
<name>TargetDisconnected(el) | un target disparaît |
<name>ValueChanged(new,old) | un data-<id>-<name>-value est modifié |
Le lifecycle est crucial avec Turbo : chaque navigation Drive détache puis rattache les controllers. Toujours libérer les ressources (timers, AbortController, WebSocket) dans disconnect().
🧭 Comment Turbo décide — l'arbre de décision réel
La plupart des bugs Turbo viennent d'une mauvaise compréhension de qui traite la réponse. Voici l'arbre de décision exact que Turbo applique à chaque réponse HTTP :
Réponse reçue
│
├─ Content-Type = text/vnd.turbo-stream.html ?
│ └─ OUI → exécute les <turbo-stream> (append/replace/remove/update/before/after/morph)
│ ⚠️ NE change PAS l'URL, NE remplace PAS <body>. C'est une mutation chirurgicale.
│
├─ Requête issue d'un <turbo-frame> (ou data-turbo-frame) ?
│ └─ OUI → extrait le <turbo-frame id="X"> correspondant, swap CE frame uniquement
│ (l'URL ne change pas, sauf data-turbo-action="advance")
│
├─ Status 2xx sur navigation Drive ?
│ └─ OUI → swap <body>, met à jour l'URL (pushState), restaure scroll
│
├─ Status 4xx / 5xx ?
│ └─ 422 → rend le body SANS changer l'URL (cas form invalide)
│ autres → Turbo affiche la page d'erreur telle quelle
│
└─ Réponse non-HTML / redirection cross-origin / data-turbo="false"
└─ full reload navigateur (Turbo se retire)Trois conséquences que tout senior doit internaliser :
- Le status HTTP est une API.
200= "navigue",422= "reste, re-render".redirect 303après POST = pattern PRG préservé (Turbo suit la redirection en GET). C'est pour ça qu'un form invalide DOIT renvoyer 422, jamais 200. - Turbo Stream ≠ Turbo Frame. Un Frame remplace un conteneur identifié par
idque le serveur re-render entier. Un Stream est une liste d'opérations que le serveur pousse (peut toucher plusieurs cibles, peut venir de Mercure sans requête). Un Frame est pull (déclenché par navigation), un Stream est push (déclenché par le serveur). - Le même endpoint répond différemment selon
Accept. Un seul contrôleur sert HTML complet (navigation directe, SEO), fragment de Frame (navigation interne) et Turbo Stream (mutation). Le content-negotiation est le cœur de l'architecture Hotwire.
Frame vs Stream — la table de décision
| Tu veux… | Outil | Déclencheur | Touche combien de cibles |
|---|---|---|---|
| Recharger une zone au clic/navigation | Turbo Frame | lien/form interne | 1 (le frame) |
| Lazy-load une zone au scroll | Turbo Frame | loading="lazy" | 1 |
| Mettre à jour compteur + toast + liste d'un coup | Turbo Stream | réponse POST | N |
| Pousser un événement temps réel (chat, notif) | Turbo Stream | Mercure (serveur) | N, sans requête client |
| Comportement purement local (toggle, validation) | Stimulus | événement DOM | 0 réseau |
Heuristique : si la mutation vient d'une action utilisateur sur UNE zone → Frame. Si elle touche plusieurs zones OU vient du serveur → Stream. Si elle ne touche pas le réseau → Stimulus.
🏭 En production — perf, sécurité, observabilité, scale
Hotwire déplace de la complexité du client vers le serveur ; les préoccupations prod changent en conséquence.
Performance
- Le serveur rend du HTML à chaque interaction. Une recherche live = un rendu Twig par frappe (après debounce). Mets en cache les fragments coûteux (
{% cache %}duTwigBundle+ tag-based invalidation, oucache.appautour du repo). Un fragment de listing immobilier rendu 2000×/min se cache par bounds arrondis. loading="lazy"est gratuit mais attention au N+1 : un Frame lazy par ligne (preview au survol) = potentiellement des dizaines de requêtes. Cluster-les ou pré-fetch côté serveur.- Turbo Drive garde le JS/CSS en mémoire entre navigations (pas de re-parse) → c'est le gain principal vs reload. Mais le
<head>est réconcilié : tout<script>ajouté dans<head>d'une page persiste et s'accumule. Garde tes scripts hors<head>ou idempotents. - Turbo 8 morphing (
<meta name="turbo-refresh-method" content="morph">) évite le swap brutal : il diffe le DOM (idiomorph). Gain UX énorme sur des pages avec inputs/scroll, mais coûte du CPU client sur de très gros DOM — mesure avant de l'activer globalement.
Sécurité
- ACL côté serveur, toujours. Le DOM est public : ne jamais masquer un élément sensible en CSS en croyant qu'il est protégé. Chaque Frame/Stream repasse par tes voters Symfony. Un attaquant peut requêter
GET /posts/42/commentsdirectement — le contrôleur doitdenyAccessUnlessGranted. - CSRF + Turbo : Turbo soumet les forms via
fetch, le token Symfony fonctionne normalement. Piège : une lib JS qui régénère le token côté client après le rendu — Turbo a déjà sérialisé le form, le token devient invalide. Laisse Symfony gérer le token. - Mercure et autorisation : un
<turbo-stream-source>s'abonne à un topic. Si le topic n'est pas protégé par JWT (claimmercure.subscribe), n'importe qui peut écouter le canal. Pour un chat privé, le topic doit être un IRI non-devinable + JWT signé avec les topics autorisés de l'utilisateur.#[Broadcast]publie sur un topic dérivé de l'IRI de l'entité — vérifie qu'il est protégé. - Injection via Stream : un
<turbo-stream action="replace">contenant des données utilisateur non échappées = XSS stocké qui s'exécute chez tous les abonnés Mercure. Twig auto-échappe, mais|rawdans un fragment broadcasté est une porte ouverte.
Observabilité
- Les interactions Turbo sont des requêtes HTTP normales → elles apparaissent dans tes logs/APM (Sentry, Blackfire, Symfony Profiler). C'est un avantage majeur sur les SPA : pas de tracing client opaque, tu vois chaque rendu de Frame/Stream comme une requête taguée.
- Tague les requêtes Turbo : le header
Turbo-Frame: <id>est envoyé pour les navigations de frame ; logge-le pour distinguer navigation full vs frame vs stream. - Côté client, écoute
turbo:before-fetch-request,turbo:fetch-request-error,turbo:frame-missingpour remonter les erreurs (frame absent dans la réponse = bug fréquent → toast + Sentry).
Scale / temps réel
- Mercure est le point de scale. Un hub Mercure gère les connexions SSE ; il scale horizontalement (plusieurs hubs derrière un LB sticky, ou le hub HA). Une app chat à 50k connexions simultanées = dimensionnement Mercure, pas Symfony.
- Le serveur PHP reste stateless : pas de WebSocket à maintenir côté app, Mercure absorbe le fan-out. Symfony publie un message, Mercure le diffuse à N abonnés. C'est ce découplage qui rend Hotwire scalable sans Node/Socket.io.
- Backpressure : un broadcast
#[Broadcast]synchrone dans la requête HTTP ralentit la réponse. Pour du volume, route la publication via Messenger (messenger://transport) → le hub est alimenté en async.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x + Symfony UX versions
| Composant | 5.4 LTS | 6.4 LTS | 7.x |
|---|---|---|---|
symfony/ux-turbo | 2.x avec Encore | 2.20+ (AssetMapper natif) | 2.x, recommandé |
symfony/stimulus-bundle | n/a | 2.x | 2.x |
@symfony/stimulus-bridge | 3.x (Encore) | 3.x (Encore) | 3.x |
@hotwired/stimulus | 3.x | 3.x | 3.2+ |
@hotwired/turbo | 7.x | 8.x | 8.x (View Transitions API) |
Broadcast attribute | n/a | UX 2.7+ | OK |
mercure integration | OK | OK | OK |
data-turbo-permanent | OK | OK | OK |
| View Transitions | non | non | Turbo 8 + browsers récents |
Turbo 8 (2024+) apporte :
- Page refreshes morphing : au lieu de swap brutal, Turbo morph le DOM (utilise idiomorph en interne). Préserve focus, scroll, état des inputs.
- View Transitions API support natif (Chrome 111+) pour animations fluides entre pages.
Sur Symfony 6.4 LTS, tu peux utiliser Turbo 7 ou 8. Recommandation : 8 pour bénéficier du morphing.
⚠️ Pitfalls — 6-10
- JS qui ne s'exécute qu'au
DOMContentLoaded: ne fonctionnera qu'une fois, jamais après une navigation Turbo. Tout JS doit être un Stimulus controller ou utiliser les évènementsturbo:load/turbo:frame-load. - CSS qui suppose
:hoverou animations CSS sur reload : Turbo n'ajoute pas de classes au body lors d'une nav, à toi de les ajouter via Stimulus. - Sessions flash messages : si tu render un flash via Twig et que la nav suivante est un Turbo Stream, le flash n'apparaît jamais. Solution : injecter le flash dans un
<turbo-stream action="append" target="flashes">lors de la réponse stream. - Erreurs de validation form sans 422 : Turbo navigue au lieu de remplacer. Toujours retourner
new Response($html, 422)en cas d'erreur. - CSRF token et Turbo Drive : Turbo soumet les forms via fetch ; les tokens fonctionnent normalement, mais attention aux meta refresh ou aux libs JS qui régénèrent un token client-side — Turbo ne saura pas qu'il a changé.
- Stimulus controller pas chargé : oubli de
controllers.jsonou nom mal fait (hello_controller.js→data-controller="hello", le suffixe_controllerest obligatoire pour la convention). data-turbo="false"sur un lien : désactive Turbo localement. Pratique pour lien externe ou download. Mais piège classique : on l'oublie sur un lienmailto:qui marche déjà.- Mercure non configuré :
<turbo-stream-source>échoue silencieusement si la variableMERCURE_PUBLIC_URLest mal configurée. Vérifier la console réseau etmercure.yaml. data-controllermultiples : un élément peut avoir plusieurs controllers (data-controller="dropdown analytics"). Chaque controller a son propre namespace de targets/values.- Memory leaks avec listeners globaux : si tu fais
document.addEventListener(...)dansconnect(), retire-le dansdisconnect(). Stimulus auseEventListener(stimulus-use) pour ça.
🧪 Testing
Tests Stimulus côté unit (Jest / Vitest)
// tests/controllers/search.test.js
import { Application } from '@hotwired/stimulus';
import SearchController from '../../assets/controllers/search_controller.js';
describe('search_controller', () => {
let app;
beforeEach(() => {
document.body.innerHTML = `
<div data-controller="search" data-search-url-value="/api/search">
<input data-search-target="input" data-action="input->search#onInput">
<div data-search-target="results"></div>
</div>`;
app = Application.start();
app.register('search', SearchController);
});
afterEach(() => app.stop());
it('vide les résultats si query trop courte', () => {
const input = document.querySelector('input');
input.value = 'a';
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(document.querySelector('[data-search-target="results"]').innerHTML).toBe('');
});
});Tests end-to-end avec Panther
namespace App\Tests\E2E;
use Symfony\Component\Panther\PantherTestCase;
final class SearchTest extends PantherTestCase
{
public function testLiveSearch(): void
{
$client = self::createPantherClient();
$client->request('GET', '/search');
$client->getWebDriver()->findElement(\Facebook\WebDriver\WebDriverBy::cssSelector('input[type=search]'))
->sendKeys('alice');
$client->waitFor('[data-search-target="results"] li');
$crawler = $client->getCrawler();
$this->assertGreaterThan(0, $crawler->filter('[data-search-target="results"] li')->count());
}
public function testTurboFrameLoad(): void
{
$client = self::createPantherClient();
$client->request('GET', '/posts/1');
$client->waitFor('turbo-frame#comments li');
$this->assertSelectorTextContains('turbo-frame#comments', 'commentaire');
}
}Test des Turbo Streams (réponse HTTP)
public function testCommentCreateRespondsWithTurboStream(): void
{
$client = static::createClient();
$client->request('POST', '/comments', ['body' => 'Hello'], [], [
'HTTP_ACCEPT' => 'text/vnd.turbo-stream.html',
]);
$this->assertResponseIsSuccessful();
$this->assertSame('text/vnd.turbo-stream.html; charset=utf-8',
$client->getResponse()->headers->get('Content-Type'));
$this->assertStringContainsString('<turbo-stream action="append"',
$client->getResponse()->getContent());
}🎬 Cas d'usage concrets
E-commerce mode avec drop-down panier via Turbo Frames
Une boutique e-commerce spécialisée dans la mode masculine premium veut une expérience d'ajout au panier qui ne casse jamais le flow de navigation. L'équipe a banni le pattern "ajouter → redirection page panier" : trop de clics, trop de friction sur mobile. La solution choisie : un mini-panier persistant en haut à droite, sous forme de Turbo Frame piloté par un Stimulus controller. Quand le client clique sur "Ajouter au panier" depuis une fiche produit ou un cross-sell, le formulaire POST est intercepté par Turbo, le serveur répond avec un Turbo Stream qui (1) met à jour le compteur du panier, (2) affiche un toast de confirmation, (3) injecte un dropdown détaillé du panier avec animation slide-down. Aucun JS custom écrit pour cela : le Stimulus controller cart-dropdown ne gère que l'ouverture/fermeture au clic et la fermeture sur Escape. Les variations produit (taille, couleur) sont rechargées via Turbo Frame dès qu'une option change, déclenchant un re-fetch du prix et de la dispo stock. Le checkout final reste server-rendered pour des raisons SEO et de fiabilité paiement. Métriques observées après mise en prod : taux d'abandon panier passé de 68% à 52%, temps moyen entre visite et achat divisé par 2, et zéro page de "votre panier" full-reload. Le bundle JS total reste sous 35 Ko gzippé (Stimulus + Turbo + 6 controllers).
Cabinet d'avocats documents avec recherche live
Un cabinet d'avocats de taille moyenne (80 personnes, 40 000 documents archivés) souhaite remplacer son moteur de recherche documentaire interne, lent et nécessitant 4-5 clics pour atteindre un dossier. Le besoin métier : taper trois caractères et voir des résultats apparaître au fil de la frappe, avec preview en hover, et la possibilité d'ouvrir directement le document depuis la liste. La solution combine un Stimulus controller legal-search qui debounce l'input à 250ms, et un Turbo Frame qui charge les résultats par fetch HTML. Côté serveur, ElasticSearch alimente les résultats triés par pertinence, avec facettes (type de dossier, client, date, juridiction). Chaque ligne de résultat est un Turbo Frame imbriqué qui charge à la demande la preview au survol, pour ne pas surcharger le rendu initial. Quand l'utilisateur clique sur "Ouvrir", c'est un lien data-turbo-frame="_top" qui navigue vers la fiche complète. Les avocats remontent un gain de productivité significatif : recherche de jurisprudence dans un dossier ancien passée de 30 secondes à 4 secondes. La sécurité ACL est intégrée côté serveur (chaque résultat repasse par le voter Symfony qui filtre selon les habilitations du collaborateur), donc impossible de voir un document non autorisé même via inspection DOM.
Immobilier carte + listings synchronisés
Une plateforme immobilière B2C permet de chercher des biens à acheter ou louer avec une vue split-screen : carte interactive à gauche, liste de listings à droite. Quand l'utilisateur déplace la carte, la liste se met à jour ; quand il filtre par prix/surface, la carte affiche les pins correspondants. Tout cela sans page reload. L'équipe utilise Leaflet pour la carte (chargée en lazy via importmap), wrappée dans un Stimulus controller map-search. La liste de listings est un Turbo Frame qui écoute les évènements personnalisés émis par le controller carte (changement de bounds, application de filtre). À chaque changement de bounds, un fetch HTML est lancé avec les coordonnées lat/lng en query params, et le frame se remplit avec les listings dans la zone. Les pins sont injectés sur la carte via une réponse JSON parallèle (un seul endpoint Symfony répond aussi bien au format HTML qu'au JSON selon le Accept header). La performance critique : un debounce de 400ms sur le drag de la carte évite de bombarder le serveur, et les pins sont clusterés côté client pour gérer 2000+ biens sans ralentir. L'app a un SEO solide car les pages permaliens par ville (/biens-vendre/lyon) restent server-rendered avec listings en HTML pur, indexables par Google.
🛠️ Exemple end-to-end
Mini-panier e-commerce avec Turbo Frame, Turbo Stream et Stimulus pour le dropdown. Le client ajoute un produit, le compteur se met à jour, un toast apparaît, le dropdown peut être ouvert pour voir le détail.
<?php
// src/Controller/CartController.php
declare(strict_types=1);
namespace App\Controller;
use App\Cart\CartManager;
use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboBundle;
final class CartController extends AbstractController
{
public function __construct(
private readonly CartManager $cartManager,
private readonly ProductRepository $products,
) {}
#[Route('/cart/add/{sku}', name: 'cart_add', methods: ['POST'])]
public function add(string $sku, Request $request): Response
{
$product = $this->products->findOneBy(['sku' => $sku])
?? throw $this->createNotFoundException();
$quantity = max(1, (int) $request->request->get('quantity', 1));
$variant = $request->request->get('variant');
$cart = $this->cartManager->addItem($product, $variant, $quantity);
if ($request->getPreferredFormat() === TurboBundle::STREAM_FORMAT) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
return $this->render('cart/_added.stream.html.twig', [
'cart' => $cart,
'product' => $product,
]);
}
return $this->redirectToRoute('cart_show');
}
#[Route('/cart/_dropdown', name: 'cart_dropdown', methods: ['GET'])]
public function dropdown(): Response
{
return $this->render('cart/_dropdown.html.twig', [
'cart' => $this->cartManager->getCurrent(),
]);
}
}{# templates/cart/_added.stream.html.twig #}
<turbo-stream action="replace" target="cart-counter">
<template>
{{ include('cart/_counter.html.twig', { cart }) }}
</template>
</turbo-stream>
<turbo-stream action="replace" target="cart-dropdown">
<template>
{{ include('cart/_dropdown.html.twig', { cart }) }}
</template>
</turbo-stream>
<turbo-stream action="append" target="toast-zone">
<template>
<div class="toast toast--success"
data-controller="toast"
data-toast-delay-value="3500"
role="status">
<strong>{{ product.name }}</strong> ajouté au panier.
</div>
</template>
</turbo-stream>{# templates/cart/_counter.html.twig #}
<span id="cart-counter"
class="cart-counter {{ cart.totalItems > 0 ? 'has-items' : '' }}"
data-controller="cart-pulse"
data-cart-pulse-count-value="{{ cart.totalItems }}">
{{ cart.totalItems }}
</span>{# templates/cart/_dropdown.html.twig #}
<turbo-frame id="cart-dropdown" class="cart-dropdown">
{% if cart.isEmpty %}
<p class="cart-dropdown__empty">Votre panier est vide.</p>
{% else %}
<ul class="cart-dropdown__items">
{% for item in cart.items %}
<li>
<img src="{{ asset(item.product.thumbnail) }}" alt="">
<div>
<strong>{{ item.product.name }}</strong>
<small>{{ item.variant }} — x{{ item.quantity }}</small>
</div>
<span class="price">{{ item.subtotal|format_currency('EUR') }}</span>
</li>
{% endfor %}
</ul>
<footer class="cart-dropdown__footer">
<span>Total : {{ cart.total|format_currency('EUR') }}</span>
<a href="{{ path('checkout_start') }}" class="btn btn--primary">Commander</a>
</footer>
{% endif %}
</turbo-frame>// assets/controllers/cart-dropdown_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['panel', 'trigger'];
static classes = ['open'];
initialize() {
this.onClickOutside = this.onClickOutside.bind(this);
this.onEscape = this.onEscape.bind(this);
}
toggle(event) {
event.preventDefault();
const isOpen = this.element.classList.toggle(this.openClass);
if (isOpen) {
document.addEventListener('click', this.onClickOutside);
document.addEventListener('keydown', this.onEscape);
} else {
this.cleanup();
}
}
close() {
this.element.classList.remove(this.openClass);
this.cleanup();
}
onClickOutside(event) {
if (!this.element.contains(event.target)) this.close();
}
onEscape(event) {
if (event.key === 'Escape') this.close();
}
disconnect() {
this.cleanup();
}
cleanup() {
document.removeEventListener('click', this.onClickOutside);
document.removeEventListener('keydown', this.onEscape);
}
}// assets/controllers/cart-pulse_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = { count: Number };
countValueChanged(newValue, oldValue) {
if (oldValue !== undefined && newValue > oldValue) {
this.element.classList.add('pulse');
setTimeout(() => this.element.classList.remove('pulse'), 600);
}
}
}Tests Panther de bout en bout : un test ajoute un produit, vérifie que le compteur passe à 1, ouvre le dropdown, voit le produit listé, ferme le dropdown via Escape. La page de navigation parente n'est jamais rechargée, garantissant que le scroll, le focus du formulaire de recherche et les autres états de page sont préservés.
🔁 Quand utiliser / éviter
Utilise Stimulus + Turbo quand :
- App principalement server-rendered (admin, CRUD, dashboards).
- Tu veux un SEO solide sans SSR React.
- Équipe à dominante backend (PHP, Twig).
- Besoin de temps réel modéré (notifications, chat, dashboard live) — Mercure + Turbo Streams font le job.
- Tu veux des perfs natives sans surcoût d'hydratation.
Évite (ou complète) avec React/Vue/Svelte si :
- App ultra-interactive type éditeur graphique, canvas, drag-and-drop massif, 3D. Stimulus peut le faire mais c'est plus naturel en React/Vue.
- État global complexe partagé entre 20+ composants (Redux-like).
- Offline-first / PWA avec gros état local.
- Équipe full-JS qui ne veut pas de Twig.
Comparaison vs React/Vue/Alpine — cas concrets
| Cas | Stimulus + Turbo | React/Vue | Alpine.js |
|---|---|---|---|
| Dropdown menu | 15 lignes JS, 1 fichier | composant React, props, state | x-data="{ open: false }" inline |
| Recherche live filtrée serveur | Turbo Frame + debounce Stimulus | useState + useEffect + fetch, ~50 lignes | difficile (Alpine côté client) |
| Table éditable inline | Turbo Stream sur save | Form library + state | possible mais peu structuré |
| Dashboard temps réel | Turbo Stream + Mercure (push HTML) | WebSocket + Redux + re-render | impossible sans backend custom |
| Editeur de texte riche | wrapper Stimulus autour Tiptap | Tiptap React natif | OK avec wrapper |
| Drag-and-drop kanban | SortableJS + Stimulus + Turbo Stream | react-beautiful-dnd | difficile |
| App offline | non | React + service worker | non |
| SEO-critique landing | excellent (HTML pur) | Next.js SSR/SSG nécessaire | OK |
| Bundle JS | ~30 kB gzip (Stimulus + Turbo) | ~150 kB (React + ReactDOM + bundle) | ~15 kB |
Verdict : pour 80% des apps métier Symfony, Stimulus + Turbo gagne en simplicité, perfs, SEO et coût de maintenance. Pour les 20% restantes (créativité, jeux, éditeurs complexes), introduis un îlot React/Vue dans un controller Stimulus — c'est parfaitement supporté.
🏋️ Exercices
Progression : implémenter → production-grade → casser puis réparer. Fais-les dans l'ordre, chacun construit sur le précédent.
1. Recherche live sans flash (implémenter)
Objectif : transformer une page de recherche full-reload en recherche live qui préserve le focus de l'input et le scroll pendant que les résultats se mettent à jour.
Construis un <form data-turbo-frame="results"> + un Stimulus auto-submit qui debounce à 250ms, et un <turbo-frame id="results">. Le serveur re-render le frame entier à chaque requête.
Indice/Solution :
requestSubmit()(passubmit(), qui ne déclenche pas l'événement form). Le frame doit avoir le mêmeiddans la réponse. Pour préserver le focus de l'input QUI EST HORS du frame, place l'input en dehors du<turbo-frame>. Si tu le mets dedans, Turbo le remplace et le focus saute → c'est exactement le piège à comprendre.
2. Mini-panier multi-cibles (production-grade)
Objectif : un POST "ajouter au panier" qui met à jour, en UNE réponse, le compteur (header), le dropdown panier et affiche un toast auto-dismiss — sans recharger la page.
Utilise un _added.stream.html.twig avec 3 <turbo-stream>. Le toast est un Stimulus controller qui se remove() après data-toast-delay-value. Gère le cas dégradé : si le client n'envoie pas Accept: text/vnd.turbo-stream.html (JS désactivé, crawler), renvoie une redirection 303 vers la page panier.
Indice/Solution :
$request->getPreferredFormat() === TurboBundle::STREAM_FORMATpour brancher. Le toast s'auto-détruit viathis.element.remove()dans unsetTimeoutposé enconnect(), nettoyé endisconnect()(sinon un swap Turbo pendant le délai laisse un timer orphelin). Teste les deux chemins (stream + 303).
3. Chat temps réel autorisé (production-grade)
Objectif : un chat de room privée où seuls les membres reçoivent les nouveaux messages en push, via Mercure + #[Broadcast].
Le <turbo-stream-source> s'abonne à un topic /rooms/{id}. Génère un JWT Mercure côté serveur qui n'autorise QUE les topics des rooms dont l'utilisateur est membre. Un non-membre qui forge l'URL du topic ne doit RIEN recevoir.
Indice/Solution : le cookie Mercure (
Authorization) porte un JWT avec un claimmercure.subscribe: ['/rooms/12', '/rooms/40']. Le hub refuse l'abonnement aux topics absents du claim.#[Broadcast]sur l'entité Message publie sur un topic dérivé de l'IRI — surchargetopicsdans l'attribut pour cibler/rooms/{roomId}. Vérifie côté hub, jamais côté client.
4. Casser puis réparer : la race condition de frappe (break-then-fix)
Objectif : reproduire un bug où, en tapant vite, d'anciens résultats écrasent les récents, puis le corriger proprement.
Pars du search_controller.js SANS AbortController ni annulation. Tape "a", "ab", "abc" rapidement avec un endpoint qui répond lent sur les requêtes courtes (simule sleep). Observe que la réponse de "a" (la plus lente) arrive en dernier et écrase "abc". Puis répare.
Indice/Solution : deux corrections complémentaires — (1)
AbortControllerannule la requête précédente à chaque frappe ; (2) même sans abort, garde un compteur de requête (this.seq++) et ignore toute réponse dont leseqn'est pas le dernier. La 2e technique protège même quand l'abort n'a pas pris effet à temps. Un staff implémente les deux : abort pour économiser le réseau, séquence pour la correction.
5. Casser puis réparer : la fuite mémoire Turbo (break-then-fix)
Objectif : créer un memory leak via un listener global non nettoyé, le diagnostiquer, le corriger.
Écris un controller sticky-header qui fait window.addEventListener('scroll', ...) dans connect() mais ne le retire jamais. Navigue 20× via Turbo Drive entre deux pages contenant ce controller. Observe (DevTools → Performance → heap, ou compte les listeners) l'accumulation.
Indice/Solution : chaque
connect()ajoute un listener ; Turbo n'appelledisconnect()que sur le controller détaché, mais le listener est posé surwindow(global) et n'est jamais retiré → N listeners après N navigations, tous appelant desthissur des éléments détachés. Fix : retirer le listener endisconnect(), ou utiliseruseWindowResize/useEventListenerdestimulus-usequi auto-nettoie. Règle générale : toutaddEventListenersurdocument/windowdansconnect()exige unremoveEventListenerdansdisconnect().
6. Le même endpoint, trois formats (architecture)
Objectif : un seul contrôleur listings() qui sert HTML complet (SEO, accès direct), un fragment de Frame (navigation interne carte) et du JSON (pins de la carte) selon Accept.
Indice/Solution :
match ($request->getPreferredFormat())ou inspection deAccept. HTML complet quand pas de header Turbo niAccept: application/json; fragment de frame quand le headerTurbo-Frameest présent ; JSON quandAccept: application/json. Le test de non-régression doit couvrir les 3 chemins et vérifier que le HTML complet reste indexable (pas de dépendance JS pour le contenu principal).
🎤 En entretien
Q : Pourquoi un formulaire invalide doit-il renvoyer un status 422 avec Turbo, et que se passe-t-il avec un 200 ? R : Turbo interprète le status HTTP comme un signal de navigation : un 2xx lui dit "succès, navigue et change l'URL", il remplace alors le <body> et perd le contexte du form. Un 422 Unprocessable Entity lui dit "reste sur place et re-render le contenu reçu" → l'URL ne bouge pas, le form avec ses erreurs s'affiche. Renvoyer 200 sur un form invalide fait naviguer Turbo vers une page vide ou incohérente. C'est l'application du status HTTP comme API de contrôle de flux.
Q : Quelle est la différence fondamentale entre Turbo Frame et Turbo Stream, et quand choisir l'un plutôt que l'autre ? R : Un Frame est un mécanisme pull : déclenché par une navigation utilisateur, le serveur re-render UN conteneur identifié par id et Turbo swap ce conteneur. Un Stream est un mécanisme push : une liste d'opérations (append, replace, remove…) que le serveur émet, pouvant cibler PLUSIEURS zones et pouvant venir de Mercure sans aucune requête client. Heuristique : action utilisateur sur une zone → Frame ; mutation multi-zones ou poussée serveur (temps réel) → Stream.
Q : Comment Hotwire scale-t-il pour du temps réel à 50k connexions, alors que PHP n'est pas event-driven ? R : Le serveur PHP reste stateless et ne maintient aucune connexion longue. C'est Mercure (un hub SSE écrit en Go) qui absorbe le fan-out : Symfony publie un message au hub, Mercure le diffuse aux N abonnés. On scale en dimensionnant Mercure (hub HA, horizontal), pas l'app PHP. Pour éviter de bloquer la requête HTTP, on route la publication via Messenger en async. Ce découplage publish/subscribe est ce qui rend le temps réel possible sans WebSocket côté PHP.
Q : Un junior masque un bouton "supprimer" en CSS pour les non-admins dans un template rendu par Turbo. Quel est le problème et comment un senior le traite ? R : Le DOM est entièrement public — display:none ne protège rien, et n'importe qui peut requêter directement l'endpoint de suppression (POST /resource/42) ou le Frame qui le contient. La sécurité doit vivre côté serveur : chaque Frame, Stream et action repasse par un voter Symfony (denyAccessUnlessGranted). Le CSS est de l'UX, jamais de la sécurité. Avec Mercure, il faut en plus protéger les topics par JWT, sinon un attaquant s'abonne au canal et reçoit des données qu'il ne devrait pas voir.
🔗 Liens
- Documentation Symfony UX : https://ux.symfony.com/
- UX Turbo : https://ux.symfony.com/turbo
- Stimulus handbook : https://stimulus.hotwired.dev/handbook/introduction
- Turbo handbook : https://turbo.hotwired.dev/handbook/introduction
stimulus-use(composables) : https://stimulus-use.github.io/stimulus-use- Article "Hotwire vs SPA" : https://m.signalvnoise.com/hotwire-html-over-the-wire/
- Mercure protocol : https://mercure.rocks/
- Panther (E2E PHP) : https://github.com/symfony/panther
- Turbo 8 morphing : https://turbo.hotwired.dev/handbook/page_refreshes
- View Transitions API : https://developer.chrome.com/docs/web-platform/view-transitions
- SymfonyCasts Stimulus & Turbo : https://symfonycasts.com/tracks/symfony-7