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.
Table of Contents
- 1. Why Symfony Turbo instead of a JavaScript framework
- 2. Installing and configuring symfony/ux-turbo
- 3. Turbo Drive: fast navigation without a reload
- 4. Turbo Frames: partial rendering for forms and lists
- 5. Turbo Streams: updating multiple DOM regions
- 6. Real-time updates with Mercure and Turbo Streams
- 7. Integrating Symfony forms with Turbo
- 8. Debugging and common pitfalls
- 9. Symfony Turbo vs. JavaScript frameworks compared
- 10. Summary
- 11. FAQ
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?
2Do I need Node.js?
3Frames vs. Streams?
4Mercure + Turbo Streams?
5Why status 422 on form errors?
6Functional without JavaScript?
7Combination with Alpine.js?
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?
enctype=multipart/form-data are not intercepted, resulting in a full reload. Solution: a separate upload endpoint or data-turbo="false" for the form.