Installation
composer require symfony/ux-turbo
Add the bundle to config/bundles.php:
return [
// ...
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
];
Enable Turbo in Twig
Add to your base template (templates/base.html.twig):
{% block stylesheets %}
{{ parent() }}
{{ encore_entry_link_tags('app') }}
{{ turbo_stylesheets() }}
{% endblock %}
{% block javascripts %}
{{ parent() }}
{{ encore_entry_link_tags('app') }}
{{ turbo_scripts() }}
{% endblock %}
First Turbo Drive Use Case Replace a traditional form submission with Turbo:
<form turbo-frame="result-frame">
<input type="text" name="query">
<button type="submit">Search</button>
</form>
<turbo-frame id="result-frame">
{% block result %}{% endblock %}
</turbo-frame>
Controller:
public function search(Request $request, Response $response): Response
{
$query = $request->request->get('query');
return $this->render('partials/_search_results.html.twig', [
'results' => $this->searchService->find($query),
]);
}
Progressive Enhancement
<!-- Before -->
<a href="/dashboard">Dashboard</a>
<!-- After -->
<a href="/dashboard" data-turbo-frame="_top">Dashboard</a>
Frame-Based Navigation
turbo-frame for partial updates:
<turbo-frame id="sidebar">
{% include 'partials/_sidebar.html.twig' %}
</turbo-frame>
return $this->render('partials/_sidebar.html.twig', [...]);
Streaming Responses
$response = new StreamingResponse();
$response->setCallback(function () use ($task) {
while (!$task->isComplete()) {
sleep(1);
echo $this->renderView('partials/_progress.html.twig', [
'progress' => $task->getProgress(),
]);
}
});
return $response;
Integration with Symfony UX
// assets/controllers/auto_submit_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect() {
this.element.addEventListener('submit', (e) => {
e.preventDefault();
this.element.requestSubmit();
});
}
}
<form data-controller="auto-submit" turbo-frame="result">
<!-- ... -->
</form>
Fallback Handling
data-turbo-permanent for critical frames:
<turbo-frame id="header" data-turbo-permanent>
{% include 'partials/_header.html.twig' %}
</turbo-frame>
Asset Management
turbo_scripts() in the right block).webpack.config.js:
Encore
.addEntry('turbo', 'turbo')
.splitEntry('turbo');
Routing
_ to avoid conflicts:
# config/routes.yaml
_turbo:
path: /turbo/{action}
controller: App\Controller\TurboController::index
Caching
Vary: Turbo-Frame headers for frame-specific caching:
$response->headers->set('Vary', 'Turbo-Frame');
Testing
Turbo::drive() in PHPUnit:
public function testTurboFrame(): void
{
$client = static::createClient();
$client->request('GET', '/');
$client->followRedirects();
$crawler = Turbo::drive($client)->clickLink('Dashboard');
$this->assertSelectorTextContains('turbo-frame#result', 'Welcome');
}
Double Submissions
<form data-turbo="false">
<!-- or -->
<form data-turbo-action="replace">
CSRF Tokens
<turbo-frame id="form-frame">
{{ form_start(form, {'attr': {'data-turbo-frame': '_top'}}) }}
History API Conflicts
document.addEventListener('turbo:before-visit', (event) => {
if (event.detail.url.includes('/spa')) {
event.preventDefault();
}
});
Asset Fingerprinting
{{ turbo_stylesheets({ 'filter': 'ignore' }) }}
Server-Side Rendering (SSR) Issues
data-turbo="false" on SSR components.Turbo Logs
config/packages/symfony_ux_turbo.yaml:
turbo:
debug: true
turbo:... events.Network Tab
X-Turbo-Frame headers in responses. Missing headers often indicate routing issues.Frame Isolation
data-turbo-frame="external" for cross-origin frames (requires CORS headers).Custom Events
document.addEventListener('turbo:load', () => {
console.log('Page loaded via Turbo');
});
Middleware
public function handle(Request $request, Closure $next): Response
{
if ($request->headers->has('Turbo-Frame')) {
// Custom frame logic
}
return $next($request);
}
Adapters
use Symfony\UX\Turbo\Adapter\TurboAdapterInterface;
class ApiTurboAdapter implements TurboAdapterInterface
{
public function render(Response $response): string
{
return json_encode($response->getContent());
}
}
Stimulus Integration
turbo:submit-start to show loaders:
document.addEventListener('turbo:submit-start', (event) => {
event.target.querySelector('.spinner').classList.remove('hidden');
});
Progressive Hydration
<div id="app" data-turbo="false">
{{ include('partials/_turbo_hydrated.html.twig') }}
</div>
How can I help you explore Laravel packages today?