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 Package:
composer require symfony/ux-turbo
Ensure symfony/stimulus-bundle is installed (required for Turbo’s JavaScript integration).
Enable the Bundle:
Add to config/bundles.php:
return [
// ...
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
];
Add Turbo to Your Layout:
Include the Turbo script in your base template (e.g., base.html.twig):
<script src="%turbo_entrypoint%"></script>
Or use StimulusBundle’s entrypoint:
{{ stimulus_use('turbo') }}
First Use Case: Turbo Frame Create a frame in your template:
<turbo-frame id="dynamic_content">
{# Content loaded via Turbo #}
</turbo-frame>
Load content via a controller:
use Symfony\UX\Turbo\TurboBundle;
public function showDynamicContent(Request $request): Response
{
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
return $this->render('dynamic_content.stream.html.twig');
}
return $this->render('dynamic_content.html.twig');
}
Verify Turbo is Working:
Network tab) and check for Accept: text/vnd.turbo-stream.html headers.data-turbo-frame="dynamic_content").Pattern: Use Turbo Frames to isolate dynamic components (e.g., modals, partials). Workflow:
<turbo-frame id="user_avatar_{% user.id %}">
{{ include('user/_avatar.html.twig', { user: user }) }}
</turbo-frame>
public function updateAvatar(User $user, Request $request): Response
{
// Update logic...
return $this->render('user/_avatar.html.twig', ['user' => $user]);
}
<a href="{{ path('app_update_avatar', { id: user.id }) }}"
data-turbo-frame="user_avatar_{% user.id %}">
Update Avatar
</a>
Tip: Use turbo_frame_request_id() in Twig to detect frame requests:
{% if turbo_is_frame_request() %}
{# Frame-specific logic #}
{% endif %}
Pattern: Broadcast DOM changes via Turbo Streams (with or without Mercure). Workflow:
@Broadcast Attribute:
use Symfony\UX\Turbo\Attribute\Broadcast;
#[Broadcast]
public function notifyUser(User $user, string $message): Response
{
return $this->render('notification/_stream.html.twig', [
'message' => $message,
]);
}
<div id="notifications">
{# Turbo will inject streams here #}
</div>
<turbo-stream>
<template id="notification_template">
<div>{{ message }}</div>
</template>
</turbo-stream>
Mercure Integration:
<turbo-mercure-stream-source
src="{{ path('mercure_hub') }}"
topics="{{ ['https://example.com/.well-known/mercure'] }}">
</turbo-mercure-stream-source>
Pattern: Add Turbo attributes to existing links/forms for instant transitions. Example:
<a href="{{ path('app_show_post', { id: post.id }) }}"
data-turbo="true"
data-turbo-action="advance">
View Post
</a>
Fallback: If JavaScript is disabled, the link behaves as a normal <a> tag.
Pattern: Use data-turbo-frame to update a frame after submission.
Example:
<form data-turbo-frame="form_result">
{# Form fields #}
<button type="submit">Submit</button>
</form>
<turbo-frame id="form_result"></turbo-frame>
Controller:
public function submitForm(Request $request): Response
{
// Process data...
return $this->render('form/_result.html.twig');
}
Pattern: Extend Turbo’s behavior with custom actions (e.g., data-turbo-action="scroll-to").
JavaScript:
// assets/controllers/turbo_custom_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
this.element.addEventListener('turbo:load', () => {
window.scrollTo(0, 0);
});
}
}
Usage in Twig:
<div data-controller="turbo-custom" data-turbo-action="scroll-to">
{# Content #}
</div>
Pattern: Broadcast updates to all connected clients. Setup:
symfony/mercure-bundle.config/packages/mercure.yaml:
mercure:
hubs:
default:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt: '%kernel.project_dir%/var/mercure.jwt'
turbo_stream_from() in Twig:
{{ turbo_stream_from('https://example.com/.well-known/mercure') }}
Missing Accept Header:
Accept: text/vnd.turbo-stream.html.TurboBundle::STREAM_FORMAT:
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
$request->setRequestFormat(TurboBundle::STREAM_FORMAT);
}
CORS Errors with Mercure:
public_url and ensure the client’s origin is allowed:
mercure:
hubs:
default:
public_url: 'https://mercure.example.com/.well-known/mercure'
allowed_origins: ['https://your-app.com']
Turbo Frame ID Conflicts:
user_avatar_{% user.id %}) may cause conflicts if not scoped properly.user_avatar_123 vs. profile_avatar_123).JavaScript Conflicts:
data-turbo="false" on problematic elements or wrap them in data-turbo-frame with unique IDs.Deprecated turbo_stream_listen:
turbo_stream_listen(), which is deprecated.turbo_stream_from() or <turbo-mercure-stream-source>:
{# Old #}
{{ turbo_stream_listen('https://example.com/updates') }}
{# New #}
{{ turbo_stream_from('https://example.com/updates') }}
Twig Components in Streams:
{{ component('Turbo:Stream', { ... }) }} or inline HTML.Check Network Tab:
text/vnd.turbo-stream.html responses in Chrome DevTools.Turbo-Frame-Options headers for frame requests.Enable Turbo Logs:
Add to config/packages/dev/turbo.yaml:
turbo:
debug: true
Test Frame Updates:
Use data-turbo-action="replace" to force a frame update:
<a href="{{ path('app_update') }}" data-turbo-action="replace">Update</a>
Mercure Debugging:
curl to test the hub:
curl -H "Authorization: Bearer YOUR_JWT" https://merc
How can I help you explore Laravel packages today?