Turbo
How Turbo Drive, Frames and Streams speed up a server-rendered app without breaking it when JavaScript is unavailable.
<turbo-frame id="task_7" src="/tasks/7/edit"></turbo-frame>Turbo is a JavaScript library that makes a server-rendered application feel like a single-page one, without you writing the JavaScript. Install symfony/ux-turbo and it starts working on the pages you already have.
Drive
Turbo Drive intercepts link clicks and form submissions. Instead of letting the browser unload the page, it fetches the new URL in the background, swaps the <body> and merges the <head>. Stylesheets and scripts already loaded are not fetched again, which is where the speed comes from. Forms have one requirement: after a successful POST, redirect with status 303, and when validation fails answer 422 rather than 200. A controller's render() already returns 422 when the form it renders was submitted and is invalid.
Frames
A Turbo Frame is a region of the page with an id. A link or form inside the frame replaces only that frame, using the matching frame found in the response. <turbo-frame id="task_7"> is the plain element; <twig:Turbo:Frame id="task_7" /> is the component that renders the same element. To answer a frame with a smaller template, read the request header Turbo sends: $request->headers->get('Turbo-Frame') is the id of the frame that triggered the request, and null when the request did not come from a frame.
The progressive enhancement rule
Every route must still work on its own. A frame response is a real page and a redirect is a real redirect, so if Turbo's JavaScript fails to load, or a link opts out with data-turbo="false", the browser navigates normally. Build the plain version first and let Turbo accelerate it. An application that only works with Turbo running has no fallback left.
Example
<?php
// src/Controller/TaskController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Task;
use App\Form\TaskType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class TaskController extends AbstractController
{
#[Route('/tasks/{id}/edit', name: 'app_task_edit', methods: ['GET', 'POST'])]
public function edit(
Request $request,
Task $task,
EntityManagerInterface $entityManager,
): Response {
$form = $this->createForm(TaskType::class, $task);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->flush();
// 303 rather than 302: the response to a POST must be followed
// with a GET, which is what Turbo and the browser both do here.
return $this->redirectToRoute(
'app_task_show',
['id' => $task->getId()],
Response::HTTP_SEE_OTHER,
);
}
// Turbo puts the triggering frame's id in the Turbo-Frame header,
// and the header is absent when the request came from a normal
// navigation.
$frameId = $request->headers->get('Turbo-Frame');
// Inside a frame, answer with the fragment. Otherwise answer with
// the whole page, so the URL still works when opened directly.
// render() sets 422 by itself when the submitted form is invalid.
return $this->render(
null !== $frameId ? 'task/_edit_frame.html.twig' : 'task/edit.html.twig',
['task' => $task, 'form' => $form],
);
}
}
When to use it
- Make an edit link turn one table row into a form without reloading the list.
- Keep a form's error messages on screen after an invalid submit without a full repaint.
- Delete a row and have it disappear by answering with a Turbo Stream.
- Opt a PDF download link out of Turbo so the browser saves the file.
More examples
One frame per row
Clicking Edit inside a frame sends a normal GET for that route, and Turbo keeps only the <turbo-frame id="task_7"> found in the response, so one row turns into a form while the rest of the list is untouched. The tasks passed in here are plain entities whose title is already loaded, so the loop issues no extra query. Reach for frames when part of a page changes and the server already knows how to render that part on its own.
{# templates/task/index.html.twig #}
<ul id="task_list">
{% for task in tasks %}
{# Longhand: the custom element Turbo looks for. #}
<turbo-frame id="task_{{ task.id }}">
<li>
{{ task.title }}
<a href="{{ path('app_task_edit', {id: task.id}) }}">Edit</a>
</li>
</turbo-frame>
{% endfor %}
</ul>
{# Shorthand: the component renders the same element. #}
{# <twig:Turbo:Frame id="task_7">...</twig:Turbo:Frame> #}
{# Adding src= makes the frame load its own content after page load: #}
{# <twig:Turbo:Frame id="task_7" src="{{ path('app_task_show', {id: 7}) }}" /> #}
Answering with a Turbo Stream
A Turbo Stream is a response that names an operation and a target instead of a page: remove this element, append to that list, replace that one. It is the right answer when the change is too small to be a navigation and does not map to a single frame. Note the two branches: the stream is an optimisation, and the redirect keeps the route usable on its own. A real delete form should also carry a CSRF token.
<?php
// src/Controller/TaskDeleteController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Task;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboBundle;
use Symfony\UX\Turbo\TurboStreamResponse;
final class TaskDeleteController extends AbstractController
{
#[Route('/tasks/{id}/delete', name: 'app_task_delete', methods: ['POST'])]
public function delete(
Request $request,
Task $task,
EntityManagerInterface $entityManager,
): Response {
$id = $task->getId();
$entityManager->remove($task);
$entityManager->flush(); // remove() schedules the delete, flush() runs it
// Turbo asks for the stream format when it submits a form, so this
// branch is only taken when Turbo is actually running.
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
return (new TurboStreamResponse())->remove('#task_'.$id);
}
// Same action without Turbo: a plain redirect back to the list.
return $this->redirectToRoute('app_task_index', [], Response::HTTP_SEE_OTHER);
}
}
Opting out
data-turbo="false" on a link or form tells Turbo to leave it alone, and the browser does what it always did. Use it for file downloads, for responses that are not HTML, and for anything handing control to another site. If a page misbehaves only when Turbo is running, this attribute is also the quickest way to confirm that Turbo is the cause before you debug further.
{# templates/invoice/show.html.twig #}
{# Turbo cannot swap the page body for a PDF, so let the browser handle it. #}
<a href="{{ path('app_invoice_download', {id: invoice.id}) }}" data-turbo="false">
Download PDF
</a>
{# A link leaving the application also has nothing to gain from Turbo. #}
<a href="https://example.com/docs" data-turbo="false">Documentation</a>
Discussion