symfony/ux-turbo
Symfony UX Turbo integrates Hotwire Turbo into Symfony apps, enabling faster navigation, Turbo Frames/Streams updates, and smoother UX with minimal custom JavaScript. Includes Stimulus integration and tools to progressively enhance pages and forms.
Install the Bundle:
composer require symfony/ux-turbo
Ensure StimulusBundle is installed (dependency) and assets/ is configured in package.json.
Enable in config/bundles.php:
return [
// ...
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
];
Add Turbo to Your Layout:
Include the Turbo JS in your base template (e.g., base.html.twig):
{{ encore_entry_script_tags('app') }} {# Assumes Turbo is included via StimulusBundle #}
Or manually:
<script src="https://unpkg.com/@hotwired/turbo@latest"></script>
First Use Case: Turbo Frame Create a frame in a template:
<turbo-frame id="dynamic_content">
{% include 'partials/content.html.twig' %}
</turbo-frame>
Target it from a controller:
use Symfony\UX\Turbo\TurboBundle;
return $this->render('partials/content.html.twig', [
// Data for the frame
])->setTurboFrame('dynamic_content');
<twig:Turbo:Frame>, <twig:Turbo:Stream>, and <turbo-mercure-stream-source> for common patterns.TurboBundle::STREAM_FORMAT for content negotiation and $this->render()->setTurboFrame() for targeted updates.Replace standard links with Turbo-enhanced ones:
<a href="{{ path('app_home') }}" data-turbo="true">Home</a>
This enables instant navigation without full page reloads.
Pattern: Use frames to update specific parts of the page without reloading the entire DOM. Workflow:
<turbo-frame id="notifications">
{% include 'components/notifications.html.twig' %}
</turbo-frame>
return $this->render('components/notifications.html.twig', [
'notifications' => $newNotifications,
])->setTurboFrame('notifications');
Tip: Use turbo_frame_request_id() in Twig to customize responses for frame requests:
{% if turbo_is_frame_request() %}
<div data-turbo-frame="{{ turbo_frame_request_id() }}">...</div>
{% endif %}
Pattern: Broadcast DOM changes to clients via Turbo Streams (e.g., real-time notifications). Workflow:
Set Up Mercure (if using real-time):
composer require symfony/mercure-bundle
Configure in config/packages/mercure.yaml.
Broadcast a Stream in a Controller:
use Symfony\UX\Turbo\Attribute\Broadcast;
#[Broadcast]
public function addNotification(Notification $notification): TurboStreamResponse
{
return $this->render('components/notification.stream.html.twig', [
'notification' => $notification,
]);
}
Twig template (notification.stream.html.twig):
{{ turbo_stream_append('notifications') }}
<div>{{ notification.message }}</div>
{{ end_turbo_stream_append }}
Listen for Streams in the Client:
<turbo-mercure-stream-source src="{{ path('mercure') }}" topic="notifications">
<turbo-stream-action>append</turbo-stream-action>
</turbo-mercure-stream-source>
Or via Twig:
{{ turbo_stream_from('notifications') }}
Pattern: Ensure functionality works with/without JavaScript. Workflow:
data-turbo="false" for Critical Links:
<a href="{{ path('checkout') }}" data-turbo="false">Checkout</a>
{% if not turbo_is_frame_request() %}
<div class="fallback-content">
{{ include('partials/content.html.twig') }}
</div>
{% endif %}
Pattern: Handle form submissions without full page reloads. Workflow:
{{ form_start(form, { attr: { 'data-turbo': 'true' } }) }}
{{ form_widget(form) }}
{{ form_end(form) }}
public function submitForm(Request $request): TurboStreamResponse
{
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
return $this->render('components/flash.stream.html.twig', [
'message' => 'Success!',
]);
}
return $this->render('form.html.twig', ['form' => $form]);
}
Pattern: Use Turbo’s visit action for modals or external pages.
Workflow:
<button data-turbo-action="visit" data-turbo-frame="_modal">Open Modal</button>
<turbo-frame id="_modal" src="{{ path('modal_page') }}"></turbo-frame>
public function modalPage(): Response
{
return $this->render('modal.html.twig');
}
Pattern: Combine Turbo with Stimulus for enhanced interactivity. Workflow:
// assets/controllers/hello_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
console.log('Hello from Stimulus!');
}
}
<div data-controller="hello">
<button data-action="click->hello#greet">Click Me</button>
</div>
<turbo-frame id="dynamic_content" data-controller="hello">
{% include 'partials/content.html.twig' %}
</turbo-frame>
Missing Accept: text/vnd.turbo-stream Header:
Accept header is incorrect.TurboStreamResponse or set the format explicitly:
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
Frame ID Mismatches:
setTurboFrame() must match the id in the Twig template.turbo_frame_request_id() in Twig to debug:
{{ dump(turbo_frame_request_id()) }}
Mercure Authentication:
mercure.yaml:
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
jwt: '%kernel.project_dir%/var/mercure.jwt'
StimulusBridge Conflicts:
assets/controllers.json is updated and StimulusBundle is configured:
php bin/console assets:install
npm run build
Caching Headers:
Cache-Control: no-cache headers for dynamic responses:
$response->setPublic();
$response->setMaxAge(0);
Doctrine Proxy Issues:
#[Broadcast] with fetch: 'EAGER' or manually hydrate proxies:
$entity->initializeLazyCollections();
How can I help you explore Laravel packages today?