Symfony Turbo: SPA Feel Without a JavaScript Framework
AI generated
SF
{ }
Symfony · Turbo · Hotwire · UX · Twig
Symfony Turbo:
SPA Feel Without a JavaScript Framework

Single-page-application feel does not require React, Vue or Angular. With Symfony Turbo and Hotwire, users navigate without full-page reloads, form submits update only the relevant page regions, and real-time updates land directly in the DOM via Turbo Streams, all controlled server side.

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

1. Why Symfony Turbo instead of a JavaScript framework

Choosing a JavaScript framework like React or Vue brings more complexity than many teams initially budget for. A separate frontend codebase, a dedicated build process, two authentication layers (session and API token), duplicated validation logic in PHP and JavaScript, and a noticeably more involved deployment. For many web applications, admin interfaces, content portals, e-commerce backends, this overhead is not justified. Symfony Turbo delivers the essential benefit of an SPA: no full-page reloads, fast navigation, reactive forms, all with a single server-side codebase.

The concept comes from the Hotwire project, developed by Basecamp/37signals and popularized for Rails. The Symfony team integrated it into the Symfony ecosystem as Symfony UX Turbo. The core idea: HTML is the right format for web applications, not JSON that is then converted into HTML on the client. By intercepting link clicks and form submits at the JavaScript level and replacing DOM regions with server-supplied HTML, a fluid user experience emerges without the team needing to learn React or Vue. For PHP teams already familiar with Symfony, that is an enormous productivity advantage.

2. Installing and configuring symfony/ux-turbo

Installing Symfony Turbo happens through Composer and AssetMapper or Webpack Encore. Installing the symfony/ux-turbo package via Composer pulls in the Symfony Flex recipe, which automatically creates the necessary configuration files. AssetMapper (the recommended approach since Symfony 6.3) imports the Turbo package directly from the importmap.php entry that the recipe adds automatically. The JavaScript package @hotwired/turbo is provided via the asset mapper without a separate Node.js build step.

After installation, add {{ importmap('app') }} to the base Twig layout and make sure the app.js file imports Turbo. That is it, Turbo Drive is immediately active and speeds up all internal links without further configuration. For pages or links that should be excluded from Turbo behavior, use the data-turbo="false" attribute. External links and download links are automatically skipped by Turbo. The first visible effect: navigation feels noticeably faster because the browser does not re-render a full page, it only replaces the <body> element.


<?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">Download PDF</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: fast navigation without a reload

Turbo Drive is the most fundamental component of Symfony Turbo. It intercepts all clicks on internal links, loads the target page via a background fetch request, and replaces the <body> element without fully reloading the browser. That means JavaScript resources are not reinitialized, CSS stays cached, and the browser tab does not flicker. The URL bar is updated correctly, the back button behaves as expected, and browser history is maintained correctly.

Important in practice: scripts in the <head> are only loaded on the first page visit. If JavaScript waits for DOMContentLoaded, that event does not fire again on Turbo navigation. Instead, use turbo:load, which fires after every navigation, initial and via Turbo Drive. Alpine.js components declared via x-data reinitialize automatically because Alpine.js correctly subscribes to the Turbo event. That makes Symfony Turbo an ideal complement to Alpine.js for small interactive elements.

4. Turbo Frames: partial rendering for forms and lists

Turbo Frames are named regions of a page that can be updated independently of the rest of the page. A frame is defined with <turbo-frame id="cart">. When a link or form inside that frame is triggered, Turbo loads the response and replaces only the <turbo-frame id="cart"> block of the response, the rest of the page remains unchanged. This is ideal for elements like shopping carts, comment forms, search result lists, and inline editing, where only a portion of the page needs to react.

The server-side code stays completely normal: the route returns a full Twig template response, Turbo Frames automatically extracts the matching frame from the response and replaces it on the current page. That means the same URL can be rendered fully (for direct requests, SEO crawlers, and users without JavaScript) and used as a frame update (for Turbo-enabled browsers). Progressive enhancement comes at no extra effort. For frames that should be lazy loaded on the first page visit, set src="/api/cart-summary" on the frame, the content is then loaded asynchronously.


{# 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 }} items in the cart</p>
    <p>Total: {{ cart.total|format_currency('EUR') }}</p>
    <a href="{{ path('cart_show') }}">Go to cart</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">Add to cart</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>Loading recommendations...</p>
</turbo-frame>

5. Turbo Streams: updating multiple DOM regions

Turbo Streams go a step further than Turbo Frames: they allow multiple independent DOM regions to be updated simultaneously in response to a single server response. A Turbo Stream response contains one or more <turbo-stream> actions, each defining a target element and an action: append (add to the end), prepend (add to the beginning), replace (replace the entire content), update (replace innerHTML), remove (remove the element), and before/after (insert before/after the target).

The classic example: a comment is submitted. The response is a Turbo Stream that prepends the new comment to the list, updates the comment count in the header (update), and resets the form (update with empty content). Three DOM manipulations, one HTTP response, no JavaScript. In Symfony, Turbo Stream responses are conveniently generated via TurboStreamResponse from Symfony\UX\Turbo\TurboBundle or via the Twig helper functions from the UX package. The response Content-Type must be text/vnd.turbo-stream.html, which Turbo recognizes automatically and activates stream mode.

6. Real-time updates with Mercure and Turbo Streams

Combining Symfony Turbo Streams with Mercure enables server-triggered real-time updates without a WebSocket server or long polling. Mercure is a modern Server-Sent Events protocol that Symfony supports natively via symfony/mercure-bundle. When another user adds a comment or stock levels change, the PHP code sends a Mercure message. Every browser subscribed to the corresponding topic receives the message as a Turbo Stream action and updates its DOM automatically.

The subscription on the Twig side is elegant: the {{ turbo_stream_listen('comments') }} tag from the Symfony Turbo bundle subscribes to the Mercure topic and connects it to the Turbo Stream renderer. From that moment on, the browser receives all Server-Sent Events for that topic and applies the contained Turbo Stream actions to the current page. Authentication and topic restriction are handled via Mercure JWT tokens, which Symfony generates automatically. The result: a collaborative real-time application written entirely in PHP and Twig, without its own WebSocket server or a 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. Integrating Symfony forms with Turbo

Symfony forms work with Symfony Turbo out of the box once a few conventions are followed. The form must sit inside a <turbo-frame> element so Turbo knows which region to update after the submit. If the form produces validation errors, the controller must return the form page with status 422, not 200 and not 302. Status 200 would cause Turbo Drive to replace the entire page, status 302 would trigger a full redirect. Status 422 signals to Turbo that the form contains errors and that the frame should be updated with the error rendering.

For better feedback during submission, Turbo offers automatic disable logic: buttons and input fields within forms are automatically disabled during the request when data-turbo-submits-with is set. This prevents double submits without any JavaScript code. After a successful submit, the controller decides: for a Turbo Stream request (text/vnd.turbo-stream.html as the accepted format), it returns multiple stream actions. For a normal Turbo Frame request, it redirects, and Turbo follows the redirect and updates the frame. Both variants can be combined with progressive enhancement.

8. Debugging and common pitfalls

The most common pitfall with Symfony Turbo is a mismatch in frame IDs. If the <turbo-frame id="..."> IDs in the source page and the response page do not match, Turbo does not update the frame and silently aborts. In the browser DevTools network panel you can see that the request was sent and the response received, but nothing happens in the DOM. Solution: frame IDs in the source HTML and the response HTML must match exactly. Turbo's debug mode in the browser DevTools shows frame matches and mismatches in the console.

A second common mistake: JavaScript that waits for DOMContentLoaded does not run again on Turbo navigation. That event only fires on the initial load. For code that should run on every navigation, use the turbo:load event. A third pitfall: forms that upload files (enctype="multipart/form-data") are not intercepted by Turbo by default, they trigger a full reload. Anyone wanting to implement file uploads with Symfony Turbo needs to build a separate upload mechanism or disable Turbo for that specific form with data-turbo="false".

9. Symfony Turbo vs. JavaScript frameworks compared

Choosing between Symfony Turbo and a JavaScript framework depends on the project's requirements. This comparison helps with the decision.

Criterion React / Vue / Angular Symfony Turbo Recommendation
Codebase complexity Frontend and backend separated One PHP codebase Turbo: noticeably simpler
SEO & progressive enhancement SSR required, complex Native, no extra effort Turbo: clear winner
Highly complex UI interaction Full control over Virtual DOM Alpine.js for small parts React/Vue for complex dashboards
Real-time updates WebSocket + state management Mercure + Turbo Streams Turbo: simpler for PHP teams
Team skillset JavaScript expertise required PHP + Twig is enough Turbo: optimal for PHP teams

Symfony Turbo is not a solution for every project. Highly complex interactive dashboards with drag and drop, document-level real-time collaboration, or applications with offline capability need a JavaScript framework. For the majority of web applications, content portals, e-commerce shops, admin interfaces, SaaS backends, Symfony Turbo delivers the needed SPA feel without the complexity cost of a second frontend stack.

Mironsoft

Symfony development, Turbo integration and modern PHP frontend architectures

Want a Symfony application with SPA feel and no JavaScript overhead?

We implement Symfony Turbo in existing and new Symfony projects, from Turbo Drive and Turbo Frames through Turbo Streams to Mercure real-time integration for your production stack.

Turbo integration

Integrate Turbo Drive, Frames and Streams into existing Symfony projects without breaking changes

Real-time updates

Set up a Mercure server and implement Turbo Streams for collaborative real-time features in PHP

Architecture consulting

Turbo vs. JavaScript framework decisions, requirement analysis and architecture recommendations

10. Summary

Symfony Turbo and Hotwire deliver SPA feel for Symfony applications without the overhead of a separate JavaScript framework. Turbo Drive speeds up navigation through partial page updates, Turbo Frames enable independent partial rendering of individual page regions, and Turbo Streams update multiple DOM regions in response to a single HTTP response. Mercure integration brings real-time updates into the equation, triggered by PHP code, received and rendered by the browser without a WebSocket server or state management library.

The decisive advantage for PHP teams: the entire application logic stays in PHP and Twig. There is no duplicate validation, no separate API layer, and no second deployment. Progressive enhancement comes at no extra effort, the application works even without JavaScript because the server always delivers complete HTML pages. Symfony Turbo is the best choice for teams that bring solid Symfony knowledge and do not staff a dedicated frontend team for React or Vue.

Symfony Turbo - The Essentials at a Glance

Turbo Drive

Intercepts link clicks, replaces <body> via fetch. No full-page reloads, correct browser history. Active immediately after installation.

Turbo Frames

<turbo-frame id="..."> defines independent page regions. Forms and links update only their frame, the rest of the page stays unchanged.

Turbo Streams

Update multiple DOM regions with a single HTTP response: append, prepend, replace, update, remove. Content-Type: text/vnd.turbo-stream.html.

Mercure + Streams

Server-Sent Events via Mercure + Turbo Streams equal collaborative real-time updates from PHP code without a WebSocket server or JavaScript state management.

11. FAQ: Symfony Turbo and SPA Feel Without a JavaScript Framework

1What is Symfony Turbo?
Official Symfony UX package for Hotwire/Turbo. Fast navigation (Drive), partial rendering (Frames), and server-side DOM updates (Streams), no React, no Vue needed.
2Do I need Node.js?
Not with Symfony AssetMapper. The Turbo package is provided via importmap, no build step, no npm install. Optionally usable with Webpack Encore too.
3Frames vs. Streams?
Frames: one named region, updated by links/forms within it. Streams: multiple arbitrary DOM regions, as a form response or via Mercure as a real-time push.
4Mercure + Turbo Streams?
PHP publishes Turbo Stream HTML via Mercure. Browsers receive it via Server-Sent Events and apply the actions to the DOM. No WebSocket, no JavaScript state management.
5Why status 422 on form errors?
Status 200 replaces the entire page (Turbo Drive). Status 422 signals to Turbo to update only the affected frame with the error rendering, correct error messages within the frame.
6Functional without JavaScript?
Yes. Progressive enhancement: the server always delivers complete HTML pages. Without JavaScript, links and forms function normally. Turbo enhances but never breaks anything.
7Combination with Alpine.js?
Alpine.js subscribes to turbo:load automatically and reinitializes after Turbo navigation. Turbo for navigation and partial updates, Alpine.js for UI interactions like dropdowns.
8File uploads with Turbo?
Forms with enctype=multipart/form-data are not intercepted, resulting in a full reload. Solution: a separate upload endpoint or data-turbo="false" for the form.
9Alternatives to Symfony Turbo?
HTMX (framework-agnostic), LiveWire (Laravel), Unpoly. For Symfony projects, symfony/ux-turbo is the best integration, officially supported by the Symfony team.
10Which projects fit best?
Content portals, e-commerce, SaaS backends, admin interfaces, anywhere PHP teams work without a dedicated frontend team. Not for highly complex dashboards or offline apps.