Skip to content

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 composant

Analogie : 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

bash
composer require symfony/ux-turbo
composer require symfony/stimulus-bundle   # avec AssetMapper
# OU pour Encore :
yarn add @hotwired/turbo @hotwired/stimulus @symfony/stimulus-bridge
js
// 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

js
// 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 = html court-circuite le pipeline Turbo Stream. Si le serveur renvoie de vrais <turbo-stream>, préfère Turbo.renderStreamMessage(html) (import import * as Turbo from '@hotwired/turbo'), qui exécute les actions (append, replace, …) au lieu de les afficher comme du texte inerte. Le Accept: text/vnd.turbo-stream.html n'a de sens que si tu laisses Turbo traiter la réponse — sinon demande text/html.

twig
{# 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

twig
{# 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>
php
#[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']),
    ]);
}
twig
{# 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

php
// config/packages/turbo.yaml
turbo:
    mercure:
        hub: 'default'
        # publishé sur le topic correspondant à l'IRI de l'entité
        enabled: true
php
// 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
}
twig
{# 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>
twig
{# templates/message/_message.html.twig #}
<div id="message-{{ message.id }}" data-controller="message">
    <p>{{ message.body }}</p>
    <small>{{ message.createdAt|format_datetime }}</small>
</div>
twig
{# 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

php
#[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 200 avec 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

twig
<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>
js
// 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); }
}
twig
<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>
js
// 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.

twig
<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>
js
export default class extends Controller {
    static debounces = ['submit'];
    connect() { useDebounce(this, { wait: 250 }); }
    submit() { this.element.requestSubmit(); }
}

Infinite scroll

twig
<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éthodeQuand
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 :

  1. Le status HTTP est une API. 200 = "navigue", 422 = "reste, re-render". redirect 303 aprè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.
  2. Turbo Stream ≠ Turbo Frame. Un Frame remplace un conteneur identifié par id que 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).
  3. 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…OutilDéclencheurTouche combien de cibles
Recharger une zone au clic/navigationTurbo Framelien/form interne1 (le frame)
Lazy-load une zone au scrollTurbo Frameloading="lazy"1
Mettre à jour compteur + toast + liste d'un coupTurbo Streamréponse POSTN
Pousser un événement temps réel (chat, notif)Turbo StreamMercure (serveur)N, sans requête client
Comportement purement local (toggle, validation)Stimulusévénement DOM0 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 %} du TwigBundle + tag-based invalidation, ou cache.app autour 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/comments directement — le contrôleur doit denyAccessUnlessGranted.
  • 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 (claim mercure.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 |raw dans 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-missing pour 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

Composant5.4 LTS6.4 LTS7.x
symfony/ux-turbo2.x avec Encore2.20+ (AssetMapper natif)2.x, recommandé
symfony/stimulus-bundlen/a2.x2.x
@symfony/stimulus-bridge3.x (Encore)3.x (Encore)3.x
@hotwired/stimulus3.x3.x3.2+
@hotwired/turbo7.x8.x8.x (View Transitions API)
Broadcast attributen/aUX 2.7+OK
mercure integrationOKOKOK
data-turbo-permanentOKOKOK
View TransitionsnonnonTurbo 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

  1. 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ènements turbo:load / turbo:frame-load.
  2. CSS qui suppose :hover ou animations CSS sur reload : Turbo n'ajoute pas de classes au body lors d'une nav, à toi de les ajouter via Stimulus.
  3. 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.
  4. Erreurs de validation form sans 422 : Turbo navigue au lieu de remplacer. Toujours retourner new Response($html, 422) en cas d'erreur.
  5. 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é.
  6. Stimulus controller pas chargé : oubli de controllers.json ou nom mal fait (hello_controller.jsdata-controller="hello", le suffixe _controller est obligatoire pour la convention).
  7. 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 lien mailto: qui marche déjà.
  8. Mercure non configuré : <turbo-stream-source> échoue silencieusement si la variable MERCURE_PUBLIC_URL est mal configurée. Vérifier la console réseau et mercure.yaml.
  9. data-controller multiples : un élément peut avoir plusieurs controllers (data-controller="dropdown analytics"). Chaque controller a son propre namespace de targets/values.
  10. Memory leaks avec listeners globaux : si tu fais document.addEventListener(...) dans connect(), retire-le dans disconnect(). Stimulus a useEventListener (stimulus-use) pour ça.

🧪 Testing

Tests Stimulus côté unit (Jest / Vitest)

js
// 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

php
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)

php
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
<?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(),
        ]);
    }
}
twig
{# 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>
twig
{# 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>
twig
{# 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>
js
// 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);
    }
}
js
// 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

CasStimulus + TurboReact/VueAlpine.js
Dropdown menu15 lignes JS, 1 fichiercomposant React, props, statex-data="{ open: false }" inline
Recherche live filtrée serveurTurbo Frame + debounce StimulususeState + useEffect + fetch, ~50 lignesdifficile (Alpine côté client)
Table éditable inlineTurbo Stream sur saveForm library + statepossible mais peu structuré
Dashboard temps réelTurbo Stream + Mercure (push HTML)WebSocket + Redux + re-renderimpossible sans backend custom
Editeur de texte richewrapper Stimulus autour TiptapTiptap React natifOK avec wrapper
Drag-and-drop kanbanSortableJS + Stimulus + Turbo Streamreact-beautiful-dnddifficile
App offlinenonReact + service workernon
SEO-critique landingexcellent (HTML pur)Next.js SSR/SSG nécessaireOK
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() (pas submit(), qui ne déclenche pas l'événement form). Le frame doit avoir le même id dans 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_FORMAT pour brancher. Le toast s'auto-détruit via this.element.remove() dans un setTimeout posé en connect(), nettoyé en disconnect() (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 claim mercure.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 — surcharge topics dans 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) AbortController annule 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 le seq n'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'appelle disconnect() que sur le controller détaché, mais le listener est posé sur window (global) et n'est jamais retiré → N listeners après N navigations, tous appelant des this sur des éléments détachés. Fix : retirer le listener en disconnect(), ou utiliser useWindowResize/useEventListener de stimulus-use qui auto-nettoie. Règle générale : tout addEventListener sur document/window dans connect() exige un removeEventListener dans disconnect().

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 de Accept. HTML complet quand pas de header Turbo ni Accept: application/json ; fragment de frame quand le header Turbo-Frame est présent ; JSON quand Accept: 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

Bibliothèque tech perso — Achref