Symfony Turbo: SPA-Feeling ohne JavaScript-Framework
AI generated
SF
{ }
Symfony · Turbo · Hotwire · UX · Twig
Symfony Turbo:
SPA-Feeling ohne JavaScript-Framework

Single-Page-Application-Feeling braucht kein React, kein Vue und kein Angular. Mit Symfony Turbo und Hotwire navigieren Benutzer ohne Full-Page-Reloads, Formular-Submits aktualisieren nur relevante Seitenbereiche, und Echtzeit-Updates landen über Turbo Streams direkt im DOM — alles serverseitig gesteuert.

16 Min. Lesezeit Turbo Drive · Turbo Frames · Turbo Streams · Mercure Symfony 7.x · symfony/ux-turbo · PHP 8.3+

1. Warum Symfony Turbo statt JavaScript-Framework

Die Entscheidung für ein JavaScript-Framework wie React oder Vue bringt mehr Komplexität mit als viele Teams anfangs einkalkulieren. Eine separate Frontend-Codebasis, ein dedizierter Build-Prozess, zwei Authentifizierungsschichten (Session und API-Token), doppelte Validierungslogik in PHP und JavaScript, und ein deutlich aufwändigeres Deployment. Für viele Webanwendungen — Admin-Interfaces, Content-Portale, E-Commerce-Backends — ist dieser Overhead nicht gerechtfertigt. Symfony Turbo liefert den wesentlichen Nutzen eines SPAs: keine Full-Page-Reloads, schnelle Navigation, reaktive Formulare — bei einer einzigen serverseitigen Codebasis.

Das Konzept stammt vom Hotwire-Projekt, das von Basecamp/37signals entwickelt und für Rails popularisiert wurde. Das Symfony-Team hat es als Symfony UX Turbo in das Symfony-Ökosystem integriert. Der Kerngedanke: HTML ist das richtige Format für Web-Anwendungen — nicht JSON, der dann clientseitig in HTML umgewandelt wird. Durch das Abfangen von Link-Klicks und Formular-Submits auf JavaScript-Ebene und das Ersetzen von DOM-Bereichen mit vom Server geliefertem HTML entsteht eine flüssige Nutzererfahrung, ohne dass das Team React oder Vue erlernen muss. Für PHP-Teams, die Symfony kennen, ist das ein enormer Produktivitätsvorteil.

2. symfony/ux-turbo installieren und konfigurieren

Die Installation von Symfony Turbo erfolgt über Composer und AssetMapper oder Webpack Encore. Mit Composer installiert man das Paket symfony/ux-turbo, das das Symfony Flex Recipe mitbringt und automatisch die notwendigen Konfigurationsdateien anlegt. AssetMapper (seit Symfony 6.3 der empfohlene Ansatz) importiert das Turbo-Paket direkt aus dem importmap.php-Eintrag, den das Recipe automatisch hinzufügt. Das JavaScript-Paket @hotwired/turbo wird dabei über den Asset-Mapper ohne separaten Node.js-Build-Schritt bereitgestellt.

Nach der Installation fügt man { { importmap('app') } } im Basis-Twig-Layout hinzu und stellt sicher, dass die app.js-Datei Turbo importiert. Das war es — Turbo Drive ist sofort aktiv und beschleunigt alle internen Links ohne weitere Konfiguration. Für Seiten oder Links, die vom Turbo-Verhalten ausgenommen werden sollen, nutzt man das data-turbo="false"-Attribut. Externe Links und Download-Links werden von Turbo automatisch ausgelassen. Der erste sichtbare Effekt: Navigation fühlt sich deutlich schneller an, weil der Browser keine vollständige Seite neu rendert, sondern nur das <body>-Element ersetzt.


<?php
// Installation via Composer (Symfony Flex handles the rest automatically)
// composer require symfony/ux-turbo

// assets/app.js — Turbo is imported automatically by the UX recipe
// import '@hotwired/turbo';

// base.html.twig — required for AssetMapper integration
// {% block javascripts %}
//   { { importmap('app') } }
// {% endblock %}

// Exclude specific links from Turbo Drive:
// <a href="/download/report.pdf" data-turbo="false">PDF herunterladen</a>

// Disable Turbo for an entire form:
// <form method="post" data-turbo="false">...</form>

// Check that Turbo is active — open DevTools Network tab:
// Navigating between pages should show fetch requests, NOT full page loads.
// The response Content-Type should be text/html.

// Enable Turbo debug mode in development:
// Turbo.setProgressBarDelay(0);  // show progress bar immediately

3. Turbo Drive: schnelle Navigation ohne Reload

Turbo Drive ist die grundlegendste Komponente von Symfony Turbo. Es fängt alle Link-Klicks auf interne URLs ab, lädt die Zielseite per Fetch-Request im Hintergrund und ersetzt das <body>-Element, ohne den Browser vollständig neu zu laden. Das bedeutet: JavaScript-Ressourcen werden nicht neu initialisiert, CSS bleibt gecacht, und der Browser-Tab flackert nicht. Die URL-Leiste wird korrekt aktualisiert, der Zurück-Button funktioniert erwartungsgemäß, und der Browser-Verlauf wird korrekt gepflegt.

Wichtig für die Praxis: Scripts im <head> werden nur beim ersten Seitenaufruf geladen. Wenn JavaScript auf DOMContentLoaded wartet, wird dieser Event bei Turbo-Navigation nicht erneut gefeuert. Stattdessen nutzt man turbo:load, das nach jeder Navigation — initial und via Turbo Drive — ausgelöst wird. Alpine.js-Komponenten, die über x-data deklariert werden, initialisieren sich automatisch neu, weil Alpine.js das Turbo-Event korrekt abonniert. Das macht Symfony Turbo ideal als Ergänzung zu Alpine.js für kleine interaktive Elemente.

4. Turbo Frames: Partial-Rendering für Formulare und Listen

Turbo Frames sind benannte Bereiche einer Seite, die unabhängig von der restlichen Seite aktualisiert werden können. Ein Frame wird mit <turbo-frame id="cart"> definiert. Wenn ein Link oder Formular innerhalb dieses Frames betätigt wird, lädt Turbo die Antwort und ersetzt nur den <turbo-frame id="cart">-Block der Antwort — der Rest der Seite bleibt unverändert. Das ist ideal für Elemente wie Warenkörbe, Kommentar­formulare, Such­ergebnis-Listen und Inline-Editing, bei denen nur ein Teilbereich der Seite reagieren soll.

Der Server-seitige Code bleibt dabei vollständig normal: Die Route liefert eine komplette Twig-Template-Antwort, Turbo Frames extrahiert automatisch den passenden Frame aus der Antwort und ersetzt ihn auf der aktuellen Seite. Das bedeutet: Dieselbe URL kann vollständig gerendert werden (für direkte Aufrufe, SEO-Crawler und Nutzer ohne JavaScript) und als Frame-Update verwendet werden (für Turbo-aktivierte Browser). Progressive Enhancement ist damit ohne Mehraufwand gegeben. Für Frames, die beim ersten Seitenaufruf lazy geladen werden sollen, setzt man src="/api/cart-summary" am Frame — der Inhalt wird asynchron nachgeladen.


{# templates/cart/_summary.html.twig #}
{# The turbo-frame ID must match on both the source page and the response page #}
<turbo-frame id="cart-summary">
  <div class="cart-box">
    <p>{ { cart.itemCount } } Artikel im Warenkorb</p>
    <p>Gesamt: { { cart.total|format_currency('EUR') } }</p>
    <a href="{ { path('cart_show') } }">Zum Warenkorb</a>
    {# Links inside the frame target the frame by default #}
    <form method="post" action="{ { path('cart_add') } }">
      <input type="hidden" name="product_id" value="{ { product.id } }">
      <button type="submit">In den Warenkorb</button>
    </form>
  </div>
</turbo-frame>

{# After form submit, the controller redirects and returns the same frame ID.
   Turbo replaces only <turbo-frame id="cart-summary"> — nothing else changes. #}

{# For lazy-loaded frames — content is fetched asynchronously on page load #}
<turbo-frame id="recommendations" src="{ { path('product_recommendations', {id: product.id}) } }" loading="lazy">
  <p>Empfehlungen werden geladen…</p>
</turbo-frame>

5. Turbo Streams: mehrere DOM-Bereiche aktualisieren

Turbo Streams gehen einen Schritt weiter als Turbo Frames: Sie ermöglichen das gleichzeitige Aktualisieren mehrerer unabhängiger DOM-Bereiche als Reaktion auf eine einzige Server-Antwort. Eine Turbo-Stream-Antwort enthält eine oder mehrere <turbo-stream>-Aktionen, die jeweils ein Ziel-Element und eine Aktion definieren: append (ans Ende anfügen), prepend (an den Anfang), replace (kompletten Inhalt ersetzen), update (innerHTML ersetzen), remove (Element entfernen) und before/after (vor/nach dem Ziel einfügen).

Das klassische Beispiel: Ein Kommentar wird abgeschickt. Die Antwort ist ein Turbo Stream, der den neuen Kommentar der Liste prependt, die Kommentar-Zahl im Header aktualisiert (update) und das Formular zurücksetzt (update mit leerem Inhalt). Drei DOM-Manipulationen, eine HTTP-Antwort, kein JavaScript. In Symfony generiert man Turbo-Stream-Antworten komfortabel über den TurboStreamResponse aus Symfony\UX\Turbo\TurboBundle oder über die Twig-Hilfsfunktionen aus dem UX-Paket. Der Content-Type der Antwort muss text/vnd.turbo-stream.html sein — das erkennt Turbo automatisch und aktiviert den Stream-Modus.

6. Echtzeit-Updates mit Mercure und Turbo Streams

Die Kombination von Symfony Turbo Streams und Mercure ermöglicht serverseitig ausgelöste Echtzeit-Updates ohne WebSocket-Server oder Long-Polling. Mercure ist ein modernes Server-Sent-Events-Protokoll, das Symfony nativ über symfony/mercure-bundle unterstützt. Wenn ein anderer Nutzer einen Kommentar hinzufügt oder ein Lagerbestand sich ändert, sendet der PHP-Code eine Mercure-Nachricht. Alle Browser, die den entsprechenden Topic abonniert haben, empfangen die Nachricht als Turbo-Stream-Aktion und aktualisieren ihren DOM automatisch.

Das Abonnement auf der Twig-Seite ist elegant: Das { { turbo_stream_listen('comments') } }-Tag aus dem Symfony Turbo-Bundle abonniert den Mercure-Topic und verbindet ihn mit dem Turbo-Stream-Renderer. Ab diesem Moment empfängt der Browser alle Server-Sent Events für diesen Topic und wendet die enthaltenen Turbo-Stream-Aktionen auf die aktuelle Seite an. Authentifizierung und Topic-Beschränkung werden über Mercure-JWT-Token gesteuert, die Symfony automatisch generiert. Das Ergebnis: eine kollaborative Echtzeit-Anwendung, die vollständig in PHP und Twig geschrieben ist — ohne einen eigenen WebSocket-Server oder ein JavaScript-Framework.


<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Comment;
use App\Repository\CommentRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboBundle;

final class CommentController extends AbstractController
{
    #[Route('/post/{id}/comment', name: 'comment_add', methods: ['POST'])]
    public function add(
        Request $request,
        CommentRepository $commentRepository,
        HubInterface $hub,
    ): Response {
        $comment = new Comment();
        $comment->setContent($request->request->get('content'));
        $comment->setPostId((int) $request->attributes->get('id'));
        $commentRepository->save($comment, flush: true);

        // Publish a Mercure update — all subscribers receive this as a Turbo Stream
        $hub->publish(new Update(
            topics: ['post/' . $comment->getPostId() . '/comments'],
            data: $this->renderView('comment/_stream.html.twig', ['comment' => $comment]),
        ));

        // Return a Turbo Stream response for the submitting browser
        if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
            $request->setRequestFormat(TurboBundle::STREAM_FORMAT);
            return $this->render('comment/_stream.html.twig', ['comment' => $comment]);
        }

        // Fallback for browsers without Turbo (progressive enhancement)
        return $this->redirectToRoute('post_show', ['id' => $comment->getPostId()]);
    }
}

7. Symfony-Formulare mit Turbo integrieren

Symfony-Formulare funktionieren mit Symfony Turbo out of the box, wenn man ein paar Konventionen beachtet. Das Formular muss innerhalb eines <turbo-frame>-Elements liegen, damit Turbo weiß, welcher Bereich nach dem Submit aktualisiert wird. Wenn das Formular Validierungsfehler produziert, muss der Controller die Formular-Seite mit Status 422 zurückgeben — nicht 200 und nicht 302. Status 200 würde Turbo Drive dazu veranlassen, die gesamte Seite zu ersetzen, Status 302 würde einen vollständigen Redirect auslösen. Status 422 signalisiert Turbo, dass das Formular Fehler enthält und der Frame mit dem Fehler-Rendering aktualisiert werden soll.

Für besseres Feedback während des Submits bietet Turbo automatische Disable-Logik: Schaltflächen und Eingabefelder innerhalb von Formularen werden während des Requests automatisch deaktiviert, wenn man data-turbo-submits-with setzt. Das verhindert Doppel-Submits ohne JavaScript-Code. Nach erfolgreichem Submit entscheidet der Controller: Bei einem Turbo-Stream-Request (text/vnd.turbo-stream.html als akzeptiertes Format) liefert er mehrere Stream-Aktionen zurück. Bei einem normalen Turbo-Frame-Request redirectet er, und Turbo folgt dem Redirect und aktualisiert den Frame. Beide Varianten sind mit progressivem Enhancement kombinierbar.

8. Debugging und typische Fallstricke

Der häufigste Fallstrick bei Symfony Turbo ist das Durcheinander mit Frame-IDs. Wenn die <turbo-frame id="...">-IDs in der Quell-Seite und der Antwort-Seite nicht übereinstimmen, aktualisiert Turbo den Frame nicht und bricht still ab. Im Browser-DevTools-Netzwerk sieht man, dass die Anfrage gesendet und die Antwort empfangen wurde — aber im DOM passiert nichts. Lösung: Frame-IDs im Quell-HTML und im Antwort-HTML müssen exakt gleich sein. Der Turbo-Debug-Modus in den Browser-DevTools zeigt Frame-Matches und Mismatches in der Konsole.

Ein zweiter typischer Fehler: JavaScript, das auf DOMContentLoaded wartet, wird bei Turbo-Navigation nicht erneut ausgeführt. Das Event wird nur beim ersten Laden gefeuert. Für Code, der bei jeder Navigation laufen soll, nutzt man das turbo:load-Event. Dritter Fallstrick: Formulare, die Dateien hochladen (enctype="multipart/form-data"), werden von Turbo standardmäßig nicht abgefangen — sie lösen einen vollständigen Reload aus. Wer Datei-Uploads mit Symfony Turbo realisieren will, muss einen separaten Upload-Mechanismus implementieren oder Turbo für das spezifische Formular mit data-turbo="false" deaktivieren.

9. Symfony Turbo vs. JavaScript-Frameworks im Vergleich

Die Wahl zwischen Symfony Turbo und einem JavaScript-Framework hängt von den Anforderungen des Projekts ab. Der Vergleich hilft bei der Entscheidung.

Kriterium React / Vue / Angular Symfony Turbo Empfehlung
Codebase-Komplexität Frontend + Backend getrennt Eine PHP-Codebasis Turbo: deutlich einfacher
SEO & Progressive Enhancement SSR nötig, komplex Nativ, kein Extra-Aufwand Turbo: klarer Gewinner
Hochkomplexe UI-Interaktion Volle Kontrolle über Virtual DOM Alpine.js für kleine Teile React/Vue bei komplexen Dashboards
Echtzeit-Updates WebSocket + State Management Mercure + Turbo Streams Turbo: einfacher für PHP-Teams
Team-Skillset JavaScript-Expertise nötig PHP + Twig reicht Turbo: für PHP-Teams optimal

Symfony Turbo ist keine Lösung für alle Projekte. Hochkomplexe interaktive Dashboards mit Drag-and-Drop, Echtzeit-Kollaboration auf Dokumentebene oder Applikationen mit offline-Fähigkeit brauchen ein JavaScript-Framework. Für die Mehrheit der Webanwendungen — Content-Portale, E-Commerce-Shops, Admin-Interfaces, SaaS-Backends — liefert Symfony Turbo das benötigte SPA-Feeling ohne die Komplexitätskosten eines zweiten Frontend-Stacks.

Mironsoft

Symfony-Entwicklung, Turbo-Integration und moderne PHP-Frontend-Architekturen

Symfony-Applikation mit SPA-Feeling ohne JavaScript-Overhead?

Wir implementieren Symfony Turbo in bestehende und neue Symfony-Projekte — von Turbo Drive und Turbo Frames über Turbo Streams bis zur Mercure-Echtzeit-Integration für euren produktiven Stack.

Turbo-Integration

Turbo Drive, Frames und Streams in bestehende Symfony-Projekte integrieren ohne Breaking Changes

Echtzeit-Updates

Mercure-Server einrichten und Turbo Streams für kollaborative Echtzeit-Features in PHP implementieren

Architektur-Beratung

Entscheidung Turbo vs. JavaScript-Framework — Analyse der Anforderungen und Architektur-Empfehlung

10. Zusammenfassung

Symfony Turbo und Hotwire liefern SPA-Feeling für Symfony-Applikationen ohne den Overhead eines separaten JavaScript-Frameworks. Turbo Drive beschleunigt Navigation durch Partial-Page-Updates, Turbo Frames ermöglichen unabhängiges Partial-Rendering einzelner Seitenbereiche, und Turbo Streams aktualisieren mehrere DOM-Bereiche als Reaktion auf eine einzige HTTP-Antwort. Die Mercure-Integration bringt Echtzeit-Updates in die Gleichung — ausgelöst von PHP-Code, empfangen und gerendert vom Browser ohne WebSocket-Server oder State-Management-Library.

Der entscheidende Vorteil für PHP-Teams: Die gesamte Anwendungslogik bleibt in PHP und Twig. Es gibt keine doppelte Validierung, keine separate API-Schicht und kein zweites Deployment. Progressive Enhancement ist ohne Mehraufwand gegeben — die Applikation funktioniert auch ohne JavaScript, weil der Server immer vollständige HTML-Seiten liefert. Symfony Turbo ist die beste Wahl für Teams, die produktive Symfony-Kenntnisse mitbringen und kein dediziertes Frontend-Team für React oder Vue beschäftigen.

Symfony Turbo — Das Wichtigste auf einen Blick

Turbo Drive

Fängt Link-Klicks ab, ersetzt <body> via Fetch. Keine Full-Page-Reloads, Browser-Verlauf korrekt. Sofort aktiv nach Installation.

Turbo Frames

<turbo-frame id="..."> definiert unabhängige Seitenbereiche. Formulare und Links aktualisieren nur ihren Frame — der Rest der Seite bleibt unverändert.

Turbo Streams

Mehrere DOM-Bereiche per einziger HTTP-Antwort aktualisieren: append, prepend, replace, update, remove. Content-Type: text/vnd.turbo-stream.html.

Mercure + Streams

Server-Sent Events via Mercure + Turbo Streams = kollaborative Echtzeit-Updates aus PHP-Code ohne WebSocket-Server oder JavaScript-State-Management.

11. FAQ: Symfony Turbo und SPA-Feeling ohne JavaScript-Framework

1Was ist Symfony Turbo?
Offizielles Symfony UX-Paket für Hotwire/Turbo. Schnelle Navigation (Drive), Partial-Rendering (Frames) und serverseitige DOM-Updates (Streams) — kein React, kein Vue nötig.
2Brauche ich Node.js?
Mit Symfony AssetMapper nicht. Das Turbo-Paket wird über importmap bereitgestellt — kein Build-Schritt, kein npm install. Optional auch mit Webpack Encore nutzbar.
3Frames vs. Streams?
Frames: ein benannter Bereich, aktualisiert durch Links/Formulare innerhalb. Streams: mehrere beliebige DOM-Bereiche, als Formular-Antwort oder via Mercure als Echtzeit-Push.
4Mercure + Turbo Streams?
PHP publiziert Turbo-Stream-HTML via Mercure. Browser empfangen per Server-Sent Events und wenden Aktionen auf DOM an. Kein WebSocket, kein JavaScript-State-Management.
5Warum Status 422 bei Formularfehlern?
Status 200 ersetzt die gesamte Seite (Turbo Drive). Status 422 signalisiert Turbo, nur den betroffenen Frame mit dem Fehler-Rendering zu aktualisieren — korrekte Fehlermeldungen im Frame.
6Ohne JavaScript funktionsfähig?
Ja. Progressive Enhancement: Server liefert immer vollständige HTML-Seiten. Ohne JavaScript funktionieren Links und Formulare normal. Turbo verbessert, bricht aber nichts.
7Kombination mit Alpine.js?
Alpine.js abonniert turbo:load automatisch und initialisiert sich nach Turbo-Navigation neu. Turbo für Navigation und Partial-Updates, Alpine.js für UI-Interaktionen wie Dropdowns.
8Datei-Uploads mit Turbo?
Formulare mit enctype=multipart/form-data werden nicht abgefangen — vollständiger Reload. Lösung: separater Upload-Endpunkt oder data-turbo="false" für das Formular.
9Alternativen zu Symfony Turbo?
HTMX (framework-agnostisch), LiveWire (Laravel), Unpoly. Für Symfony-Projekte ist symfony/ux-turbo die beste Integration — offiziell vom Symfony-Team unterstützt.
10Für welche Projekte am besten geeignet?
Content-Portale, E-Commerce, SaaS-Backends, Admin-Interfaces — überall, wo PHP-Teams ohne dediziertes Frontend-Team arbeiten. Nicht für hochkomplexe Dashboards oder Offline-Apps.