Skip to content

Symfony UX Turbo

Edit this page

Screencast

Do you prefer video tutorials? Check out the Symfony UX Turbo screencast series.

Symfony UX Turbo integrates the Hotwire Turbo library in Symfony applications. It is part of the Symfony UX initiative.

Turbo gives your application the fast and fluid feel of Single Page Applications while you keep rendering HTML on the server with controllers and Twig templates. You don't write JavaScript to fetch data or to update the page: Turbo does it for you.

On top of the Turbo library, this bundle provides Twig functions and components to generate Turbo markup, helpers to detect Turbo requests in your controllers and integrations with Symfony Mercure and Doctrine to push page updates to all connected users in real time.

Installation

Install the bundle using Composer and Symfony Flex:

1
$ composer require symfony/ux-turbo

How Turbo Works

HTML Over the Wire

In a traditional application, every click on a link or every form submission makes the browser load a new page: it downloads the HTML, discards the current page, parses the CSS and JavaScript again and renders everything from scratch. Single Page Applications avoid this by loading the page once and then asking the server only for data (usually JSON), which JavaScript code turns into HTML in the browser. This feels faster, but you must build and maintain a second application in JavaScript.

Turbo follows a third approach called HTML over the wire: the server keeps rendering HTML, as usual in Symfony applications, but Turbo requests it in the background and inserts it into the current page instead of loading a new page. Your templates, controllers, forms, security and validation stay the same, and the user doesn't see full page reloads.

Drive, Frames and Streams

Turbo provides three tools. Each one updates a different amount of the page:

Turbo Drive
Handles navigation for the entire page. When the user clicks a link or submits a form, Turbo Drive makes the request in the background and replaces the <body> of the page with the one of the response. It's enabled by default, so you get it for free when installing this bundle.
Turbo Frames
Divide the page into independent sections. Links and forms inside a <turbo-frame> element only replace the contents of that frame with the matching frame of the response. The rest of the page doesn't change.
Turbo Streams
Make precise changes to one or more parts of the page at the same time. The server sends stream messages (append this item to that list, remove that row, update this counter, etc.) either as the response to a form submission or pushed in real time (e.g. with Mercure) to all users who are viewing the page.
Tool What it updates Use it when
Turbo Drive The entire page, without a reload Always; it's enabled by default
Turbo Frames One section, which is fully reloaded Inline editing, pagination, tabs, lazy loading
Turbo Streams Several parts, with precise actions Changes in several places, real-time updates

When choosing between Frames and Streams, think about how you'd describe the change. If it's "this section of the page reloads", use a Turbo Frame. If it's "add this item to the list, update the counter and reset the form", use Turbo Streams.

When Not to Use Turbo

Turbo works best for applications whose pages are mostly rendered on the server: admin backends, CRUD applications, e-commerce sites, dashboards, blogs, etc. Consider other tools in these cases:

  • The interface keeps a lot of state in the browser and changes it on every keystroke or mouse movement (e.g. rich text editors, drawing tools, interactive maps or spreadsheets). Each change would need a request to the server, so it's better to build those parts with JavaScript. Use Stimulus controllers to integrate them in your Turbo pages;
  • The application must work offline, or it's already built as a Single Page Application that consumes a JSON API;
  • You want a component that re-renders itself as the user types (e.g. a form with fields that depend on each other or a live search). It's possible with Turbo, but Symfony UX LiveComponent is designed for this use case.

Usage

Turbo Drive

Turbo Drive watches for link clicks and form submissions, performs them in the background and updates the page without doing a full reload. It's automatically enabled when you install Symfony UX Turbo.

Getting Started with Turbo Drive

Most applications work with Turbo Drive without changes, but there are three things to be aware of:

1. Make sure your JavaScript is Turbo-ready

Because navigation no longer results in full page refreshes, you may need to adjust your JavaScript to work properly. The best solution is to write your JavaScript using Stimulus or something similar.

2. Reloading When a JavaScript/CSS File Changes

Turbo Drive can automatically perform a full refresh if the content of one of your CSS or JS files changes, to ensure that your users always have the latest version.

If you're using AssetMapper (default), this is handled automatically. Symfony UX Turbo configures the data-turbo-track="reload" attribute on your importmap script tags out of the box — no extra configuration needed.

If you're using WebpackEncore, see How to Use Symfony UX Turbo with Webpack Encore. If you're using Reprise, see How to Use Symfony UX Turbo with Reprise.

For more info, see: Turbo Reloading When Assets Change.

3. Form Response Code Changes

Turbo Drive also converts form submissions to AJAX calls. To get it to work, you do need to adjust your code to return a 422 status code on a validation error (instead of a 200).

The render() method takes care of this automatically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/Controller/TaskController.php
namespace App\Controller;

use App\Entity\Task;
use App\Form\TaskType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/task')]
class TaskController extends AbstractController
{
    #[Route('/new', name: 'app_task_new', methods: ['GET', 'POST'])]
    public function new(Request $request): Response
    {
        $task = new Task();
        $form = $this->createForm(TaskType::class, $task);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // ... perform some action, such as saving the task to the database

            return $this->redirectToRoute('app_task_index', [], Response::HTTP_SEE_OTHER);
        }

        return $this->render('task/new.html.twig', [
            'task' => $task,
            'form' => $form,
        ]);
    }
}

This changes the response status code to 422 on validation error, which tells Turbo Drive that the form submit failed and it should re-render with the errors. After a successful submission, Turbo Drive expects a 303 redirect response, hence the use of Response::HTTP_SEE_OTHER.

Meta Tags

Symfony UX Turbo provides a set of Twig functions to configure Turbo Drive via meta tags.

turbo_exempts_page_from_cache
1
{{ turbo_exempts_page_from_cache() }}

Generates a <meta> tag to disable caching of a page.

1
2
3
{{ turbo_exempts_page_from_cache() }}
{# output: #}
<meta name="turbo-cache-control" content="no-cache">
turbo_exempts_page_from_preview
1
{{ turbo_exempts_page_from_preview() }}

Generates a <meta> tag to specify that the cached version of the page should not be shown as a preview on regular navigation visits.

1
2
3
{{ turbo_exempts_page_from_preview() }}
{# output: #}
<meta name="turbo-cache-control" content="no-preview">
turbo_page_requires_reload
1
{{ turbo_page_requires_reload() }}

Generates a <meta> tag to force a full page reload.

1
2
3
{{ turbo_page_requires_reload() }}
{# output: #}
<meta name="turbo-visit-control" content="reload">
turbo_refreshes_with
1
{{ turbo_refreshes_with(method = 'replace', scroll = 'reset') }}
method (optional)
type: string default: replace possible values: replace or morph
scroll (optional)
type: string default: reset possible values: reset or preserve

Generates <meta> tags to configure both the refresh method and scroll behavior for page refreshes.

1
2
3
4
{{ turbo_refreshes_with(method: 'morph', scroll: 'preserve') }}
{# output: #}
<meta name="turbo-refresh-method" content="morph">
<meta name="turbo-refresh-scroll" content="preserve">
turbo_refresh_method
1
{{ turbo_refresh_method(method = 'replace') }}
method (optional)
type: string default: replace possible values: replace or morph

Generates a <meta> tag to configure the refresh method for page refreshes.

1
2
3
{{ turbo_refresh_method(method: 'morph') }}
{# output: #}
<meta name="turbo-refresh-method" content="morph">
turbo_refresh_scroll
1
{{ turbo_refresh_scroll(scroll = 'reset') }}
scroll (optional)
type: string default: reset possible values: reset or preserve

Generates a <meta> tag to configure the scroll behavior for page refreshes.

1
2
3
{{ turbo_refresh_scroll(scroll: 'preserve') }}
{# output: #}
<meta name="turbo-refresh-scroll" content="preserve">

See also

Read the Turbo meta tags reference for the full list of available meta tags.

See also

Read the Turbo Drive documentation to learn about the advanced features offered by Turbo Drive.

Turbo Frames

A Turbo Frame is a section of the page wrapped in a <turbo-frame> element with a unique id. Links and forms inside the frame keep working as usual, but Turbo only replaces the contents of that frame with the frame that has the same id in the response. The rest of the page doesn't change.

Use frames for self-contained parts of the page, such as editing a row of a list without leaving the list, paginating a block or switching tabs:

1
2
3
4
5
6
7
8
9
10
11
12
13
{# templates/task/show.html.twig #}
<twig:Turbo:Frame id="task_{{ task.id }}">
    <p>{{ task.title }}</p>

    <a href="{{ path('app_task_edit', {id: task.id}) }}">Edit this task</a>
</twig:Turbo:Frame>

{# templates/task/edit.html.twig #}
<twig:Turbo:Frame id="task_{{ task.id }}">
    {{ form(form) }}

    <a href="{{ path('app_task_show', {id: task.id}) }}">Cancel</a>
</twig:Turbo:Frame>

When the user clicks on "Edit this task", Turbo requests the edit page, looks for the <turbo-frame id="task_42"> element in it and replaces the contents of the current frame with it. Submitting the form works the same way, as long as the response contains a frame with that id, so the user edits the task without leaving the list. The edit page also works as a regular full page when opened directly.

Frames can also be lazy-loaded: they start empty and Turbo fetches their contents from the URL defined in their src attribute. This is useful to defer slow parts of a page or to cache them independently.

<twig:Turbo:Frame>

The <twig:Turbo:Frame> Twig component renders a <turbo-frame> element. Use it to wrap the contents of a frame or as a lazy-loading container with the src attribute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<twig:Turbo:Frame id="task_{{ task.id }}" src="{{ path('app_task_show', {id: task.id}) }}" />
{# output: <turbo-frame id="task_1" src="http://example.com/task/1"></turbo-frame> #}

<twig:Turbo:Frame id="task_{{ task.id }}" src="{{ path('app_task_show', {id: task.id}) }}" target="_top" />
{# output: <turbo-frame id="task_1" src="http://example.com/task/1" target="_top"></turbo-frame> #}

<twig:Turbo:Frame id="task" target="other_task" />
{# output: <turbo-frame id="task" target="other_task"></turbo-frame> #}

<twig:Turbo:Frame id="task_{{ task.id }}" src="{{ path('app_task_show', {id: task.id}) }}" loading="lazy" />
{# output: <turbo-frame id="task_1" src="http://example.com/task/1" loading="lazy"></turbo-frame> #}

<twig:Turbo:Frame id="task_{{ task.id }}">
    <div>My task frame!</div>
</twig:Turbo:Frame>
{# output: #}
<turbo-frame id="task_1">
    <div>My task frame!</div>
</turbo-frame>

Detecting Turbo Frame Requests

Inject the TurboFrame service to detect whether the current request was triggered by a Turbo Frame and retrieve the frame's ID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/Controller/TaskController.php
namespace App\Controller;

use App\Entity\Task;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\UX\Turbo\TurboFrame;

#[Route('/task')]
class TaskController extends AbstractController
{
    #[Route('/{id}', name: 'app_task_show', methods: ['GET'])]
    public function show(TurboFrame $turboFrame, Task $task): Response
    {
        if ($turboFrame->isFrameRequest()) {
            // The request was triggered by a Turbo Frame.
            // Render a partial response for the frame only.
            $frameId = $turboFrame->getRequestId(); // e.g. "task_details"
        }

        // ...
    }
}

You can also use the turbo_is_frame_request() and turbo_frame_request_id() Twig functions directly in your templates:

1
2
3
4
5
6
{% if turbo_is_frame_request() %}
    {# Render a partial response for the frame #}
    <p>Frame ID: {{ turbo_frame_request_id() }}</p>
{% else %}
    {# Render the full page #}
{% endif %}

See also

Read the Turbo Frames documentation to learn everything you can do using Turbo Frames.

Minimal Frame Layout

When a Turbo Frame request is made, the response only needs to contain the matching <turbo-frame> element — serving the full application layout is wasteful. For this reason, frame responses are typically rendered without any layout at all. However, this optimization has a drawback: it prevents the response from including <head> content such as page-specific meta tags.

To solve this, Symfony UX Turbo provides a minimal layout template that keeps the response lightweight while still allowing you to populate the <head> block. Use it by extending @Turbo/layouts/frame.html.twig in templates that are rendered in response to Turbo Frame requests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{# templates/task/edit.html.twig #}
{% extends '@Turbo/layouts/frame.html.twig' %}

{% block head %}
    {{ turbo_exempts_page_from_cache() }}
{% endblock %}

{% block body %}
    <twig:Turbo:Frame id="task_{{ task.id }}">
        {{ form(form) }}

        <a href="{{ path('app_task_show', {id: task.id}) }}">Cancel</a>
    </twig:Turbo:Frame>
{% endblock %}

This renders a minimal HTML document:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
    <head>
        <meta name="turbo-cache-control" content="no-cache">
    </head>
    <body>
        <turbo-frame id="task_42">
            <!-- form fields -->

            <a href="/task/42">Cancel</a>
        </turbo-frame>
    </body>
</html>

Turbo Streams

With Turbo Streams, the server sends pieces of HTML together with an instruction that tells Turbo what to do with them and where in the page.

Anatomy of a Stream Message

This is a stream message that adds a new task to a list:

1
2
3
4
5
<turbo-stream action="append" targets="#task_list">
    <template>
        <li id="task_42">Buy some milk</li>
    </template>
</turbo-stream>

Each message has three parts:

action
What to do: append, replace, remove, etc.
targets
A CSS selector of the element(s) to change. Instead of targets, you can also use target with the id of a single element, without the # (e.g. target="task_list"). The helpers of Symfony UX Turbo generate targets.
<template>
The new HTML contents, which must always be wrapped by a <template> element. Actions that don't insert contents (remove and refresh) don't include it.

When Turbo receives this message, it looks for the #task_list element in the page and adds the <li> element at the end of it. Nothing else in the page changes. If no element matches the target, Turbo ignores the message without displaying any error, so check the id attributes of your page if an update doesn't show up.

A single response can contain several messages. Turbo applies them in order.

Common Actions

These are the actions you'll use most of the time:

Action What it does to the target Typical use
append Adds the contents inside it, at the end Add a comment or a chat message
prepend Adds the contents inside it, at the beginning Add an item to a "newest first" list
replace Replaces the entire element Update an edited row, show form errors
update Replaces the contents and keeps the element Update a counter or a badge
remove Removes the element Delete a row or close an alert

The difference between replace and update is a common source of errors. If your template includes the element itself, with its id (e.g. <li id="task_42">...</li>), use replace. If it only includes the inner contents (e.g. 42), use update. If you use replace with contents that don't include the id, the following messages won't find the target anymore.

Turbo also provides the before, after and refresh actions and a morphing mode for replace and update. They are all explained in the list of stream actions.

How Stream Messages Reach the Page

Turbo receives stream messages through two different channels:

In the response to a form submission
The user submits a form and the controller responds with stream messages instead of a redirect. Only the user who submitted the form sees the changes. This is explained in the next section.
Broadcast in real time
The server publishes stream messages to a hub, such as Mercure, at any moment, and every browser displaying the page receives them. This is useful for chats, notifications and dashboards. This is explained in the Broadcasting section.

The messages are the same in both cases. Put the HTML of each item in a Twig template fragment (e.g. task/_task.html.twig) and include it from the regular page, from the stream responses and from the broadcast templates, so you don't duplicate any HTML.

Turbo Stream Responses

When a form uses the POST, PUT, PATCH or DELETE method, Turbo adds the text/vnd.turbo-stream.html MIME type to the Accept header of the request. Symfony UX Turbo registers this MIME type as the turbo_stream request format (also available as the TurboBundle::STREAM_FORMAT constant), so controllers can check it with $request->getPreferredFormat() and respond with stream messages. Other requests (e.g. from a browser with JavaScript disabled) keep receiving the usual redirect, so your application keeps working without Turbo.

Tip

Links and GET forms don't accept stream responses by default. Add the data-turbo-stream attribute to them to change this.

There are two ways to return stream messages from a controller:

Option 1: inline with TurboStreamResponse

Use TurboStreamResponse to build stream actions directly from the controller. It sets the text/vnd.turbo-stream.html content type itself, and it needs no template for simple actions like removing an element:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/Controller/TaskController.php
namespace App\Controller;

use App\Entity\Task;
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;

#[Route('/task')]
class TaskController extends AbstractController
{
    #[Route('/{id}', name: 'app_task_delete', methods: ['POST'])]
    public function delete(Request $request, Task $task): Response
    {
        // ... delete the task

        if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
            return (new TurboStreamResponse())
                ->remove('#task_'.$task->getId());
        }

        return $this->redirectToRoute('app_task_index', [], Response::HTTP_SEE_OTHER);
    }
}

Each method (append(), prepend(), replace(), update(), remove(), before(), after(), refresh() and action()) adds a <turbo-stream> element to the response body and returns the same response. Chain the calls to send several actions at once. For example, after a form submit, append the new task to the list, update the task counter and replace the form with an empty one:

1
2
3
4
5
6
7
8
9
10
11
12
// src/Controller/TaskController.php
use Symfony\UX\Turbo\TurboBundle;
use Symfony\UX\Turbo\TurboStreamResponse;

if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
    $emptyForm = $this->createForm(TaskType::class, new Task());

    return (new TurboStreamResponse())
        ->append('#task_list', $this->renderView('task/_task.html.twig', ['task' => $task]))
        ->update('#task_count', (string) $taskRepository->count([]))
        ->replace('form[name=task]', $this->renderView('task/_form.html.twig', ['form' => $emptyForm]));
}

replace() and update() also accept a third $morph argument. Pass true to apply the change with morphing (method="morph").

Caution

The $html argument is inserted into the response as-is: it is not escaped. Render it with renderView(), so that Twig escapes the variables, or escape any user-provided value yourself with htmlspecialchars().

Option 2: dedicated template with renderBlock()

Use renderBlock() to render a specific Twig block from a template as a Turbo Stream response. This is the better fit when the stream markup belongs with the page template it updates, for example when it uses the <twig:Turbo:Stream:*> components. Call $request->setRequestFormat(TurboBundle::STREAM_FORMAT) so that the response gets the Turbo Stream content type. Otherwise, the response is sent as text/html and Turbo doesn't apply the stream messages:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// src/Controller/TaskController.php
namespace App\Controller;

use App\Entity\Task;
use App\Form\TaskType;
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;

#[Route('/task')]
class TaskController extends AbstractController
{
    #[Route('/new', name: 'app_task_new', methods: ['GET', 'POST'])]
    public function new(Request $request): Response
    {
        $task = new Task();
        $form = $this->createForm(TaskType::class, $task);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // ... perform some action, such as saving the task to the database

            if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
                $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

                return $this->renderBlock('task/index.html.twig', 'success_stream', ['task' => $task]);
            }

            return $this->redirectToRoute('app_task_index', [], Response::HTTP_SEE_OTHER);
        }

        return $this->render('task/new.html.twig', [
            'form' => $form,
        ]);
    }
}
1
2
3
4
5
6
{# bottom of task/index.html.twig #}
{% block success_stream %}
    <twig:Turbo:Stream:Append target="#task_list">
        <li id="task_{{ task.id }}">{{ task.title }}</li>
    </twig:Turbo:Stream:Append>
{% endblock %}

Stream Messages and Actions

To render <turbo-stream> elements in templates, Symfony UX Turbo provides a set of <twig:Turbo:Stream:*> Twig components. The target attribute accepts a CSS selector (e.g. #task_42, .tasks).

Remove

Removes the element(s) designated by targets from the DOM.

1
2
3
4
<twig:Turbo:Stream:Remove target="#task_{{ task.id }}" />

{# output: #}
<turbo-stream action="remove" targets="#task_42"></turbo-stream>
Replace

Replaces the element(s) designated by targets with the provided content.

1
2
3
4
5
6
7
8
9
10
<twig:Turbo:Stream:Replace target="#task_{{ task.id }}">
    <li id="task_{{ task.id }}">{{ task.title }}</li>
</twig:Turbo:Stream:Replace>

{# output: #}
<turbo-stream action="replace" targets="#task_42">
    <template>
        <li id="task_42">My task</li>
    </template>
</turbo-stream>
1
2
3
4
5
6
7
8
9
10
11
{# with morphing #}
<twig:Turbo:Stream:Replace target="#task_{{ task.id }}" morph>
    <li id="task_{{ task.id }}">{{ task.title }}</li>
</twig:Turbo:Stream:Replace>

{# output: #}
<turbo-stream action="replace" targets="#task_42" method="morph">
    <template>
        <li id="task_42">My task</li>
    </template>
</turbo-stream>
Update

Replaces the inner content of the element(s) designated by targets, keeping the element itself.

1
2
3
4
5
6
7
8
9
10
<twig:Turbo:Stream:Update target="#task_{{ task.id }}">
    {{ task.title }}
</twig:Turbo:Stream:Update>

{# output: #}
<turbo-stream action="update" targets="#task_42">
    <template>
        My task
    </template>
</turbo-stream>
1
2
3
4
5
6
7
8
9
10
11
{# with morphing #}
<twig:Turbo:Stream:Update target="#task_{{ task.id }}" morph>
    {{ task.title }}
</twig:Turbo:Stream:Update>

{# output: #}
<turbo-stream action="update" targets="#task_42" method="morph">
    <template>
        My task
    </template>
</turbo-stream>
Before

Inserts content immediately before the element(s) designated by targets.

1
2
3
4
5
6
7
8
9
10
<twig:Turbo:Stream:Before target="#task_{{ task.id }}">
    <li id="task_{{ newTask.id }}">{{ newTask.title }}</li>
</twig:Turbo:Stream:Before>

{# output: #}
<turbo-stream action="before" targets="#task_42">
    <template>
        <li id="task_41">Previous task</li>
    </template>
</turbo-stream>
After

Inserts content immediately after the element(s) designated by targets.

1
2
3
4
5
6
7
8
9
10
<twig:Turbo:Stream:After target="#task_{{ task.id }}">
    <li id="task_{{ newTask.id }}">{{ newTask.title }}</li>
</twig:Turbo:Stream:After>

{# output: #}
<turbo-stream action="after" targets="#task_42">
    <template>
        <li id="task_43">Next task</li>
    </template>
</turbo-stream>
Append

Appends content as the last child of the element(s) designated by targets.

1
2
3
4
5
6
7
8
9
10
<twig:Turbo:Stream:Append target="#task_list">
    <li id="task_{{ task.id }}">{{ task.title }}</li>
</twig:Turbo:Stream:Append>

{# output: #}
<turbo-stream action="append" targets="#task_list">
    <template>
        <li id="task_42">My task</li>
    </template>
</turbo-stream>
Prepend

Prepends content as the first child of the element(s) designated by targets.

1
2
3
4
5
6
7
8
9
10
<twig:Turbo:Stream:Prepend target="#task_list">
    <li id="task_{{ task.id }}">{{ task.title }}</li>
</twig:Turbo:Stream:Prepend>

{# output: #}
<turbo-stream action="prepend" targets="#task_list">
    <template>
        <li id="task_42">My task</li>
    </template>
</turbo-stream>
Refresh

Triggers a page refresh. Pass a requestId to debounce multiple refreshes.

1
2
3
4
5
{# without [request-id] #}
<twig:Turbo:Stream:Refresh />

{# output: #}
<turbo-stream action="refresh"></turbo-stream>
1
2
3
4
5
{# debounced with [request-id] #}
<twig:Turbo:Stream:Refresh requestId="abcd-1234" />

{# output: #}
<turbo-stream action="refresh" request-id="abcd-1234"></turbo-stream>
Custom Action

For custom stream actions, use the generic <twig:Turbo:Stream> component or the TurboStreamResponse::action() method.

1
2
3
4
<twig:Turbo:Stream action="my_action" />

{# output: #}
<turbo-stream action="my_action"></turbo-stream>

You can also use the TurboStreamResponse::action() method from a controller. Its fourth argument adds attributes to the <turbo-stream> element. Set a value to null to add a boolean attribute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/Controller/TaskController.php
use Symfony\UX\Turbo\TurboBundle;
use Symfony\UX\Turbo\TurboStreamResponse;

if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
    return (new TurboStreamResponse())
        ->action(
            'my_action',
            '#task_'.$task->getId(),
            $this->renderView('task/_task.html.twig', ['task' => $task]),
            ['data-duration' => 300, 'data-highlight' => null],
        );
}

// output:
// <turbo-stream action="my_action" targets="#task_42" data-duration="300" data-highlight>
//     <template><li id="task_42">My task</li></template>
// </turbo-stream>

Broadcasting

Broadcasting sends stream messages to every browser that displays a page, not only to the user who made the request. Use it to build real-time features such as chats, notifications or dashboards. The messages are the same ones described in Anatomy of a Stream Message; only the channel that delivers them changes. Symfony UX Turbo uses the Mercure protocol by default, but supports any other transport.

Start by installing the Mercure support on your project:

1
$ composer require symfony/mercure-bundle

3.1

Manually enabling the mercure-turbo-stream controller is deprecated since Symfony UX 3.1. Use the <twig:Turbo:Stream:From> Twig component or the turbo_stream_from() Twig function instead.

The easiest way to have a working development (and production-ready) environment is to use Symfony Docker, which comes with a Mercure hub integrated in the web server.

If you use Symfony Flex, the configuration has been generated for you, be sure to update the MERCURE_URL in the .env file to point to a Mercure Hub (it's not necessary if you are using Symfony Docker).

Otherwise, configure Mercure Hub(s) as explained in the documentation:

1
2
3
4
5
6
7
8
9
# config/packages/mercure.yaml
mercure:
    hubs:
        default:
            url: '%env(MERCURE_URL)%'
            public_url: '%env(MERCURE_PUBLIC_URL)%'
            jwt:
                secret: '%env(MERCURE_JWT_SECRET)%'
                publish: '*'

The following example builds a real-time task list. When a user creates a task, it appears in the list of all users who are viewing it. First, publish the stream messages to a Mercure topic after saving the task:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// src/Controller/TaskController.php
namespace App\Controller;

use App\Entity\Task;
use App\Form\TaskType;
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;

#[Route('/task')]
class TaskController extends AbstractController
{
    #[Route('/new', name: 'app_task_new', methods: ['GET', 'POST'])]
    public function new(Request $request, HubInterface $hub): Response
    {
        $form = $this->createForm(TaskType::class, new Task());
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $task = $form->getData();
            // ... save the task

            // the first argument is the topic: every page subscribed to
            // 'tasks' receives the stream messages rendered by the template
            $hub->publish(new Update(
                'tasks',
                $this->renderView('task/stream/new.html.twig', ['task' => $task])
            ));

            return $this->redirectToRoute('app_task_index', [], Response::HTTP_SEE_OTHER);
        }

        return $this->render('task/new.html.twig', [
            'form' => $form,
        ]);
    }
}
1
2
3
4
5
6
{# templates/task/stream/new.html.twig #}
<turbo-stream action="append" targets="#task_list">
    <template>
        {{ include('task/_task.html.twig') }}
    </template>
</turbo-stream>

Then, subscribe the page to that topic with the <twig:Turbo:Stream:From> component (explained in the next section). When Turbo receives the messages, it applies them to the page:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{# templates/task/index.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <h1>Tasks</h1>

    <twig:Turbo:Stream:From topics="tasks" />

    <ul id="task_list">
        {% for task in tasks %}
            {{ include('task/_task.html.twig') }}
        {% endfor %}
    </ul>
{% endblock %}

{# templates/task/_task.html.twig #}
<li id="task_{{ task.id }}">{{ task.title }}</li>

The task/stream/new.html.twig template is the same you'd use to respond to the form submission with stream messages. Only the delivery changes: instead of returning it in the HTTP response to one user, Mercure sends it to all pages subscribed to the tasks topic.

See also

Symfony Mercure provides additional features such as private updates (to ensure that only authorized users will receive the updates) and async dispatching with Symfony Messenger.

<twig:Turbo:Stream:From> Twig Component

3.1

The <twig:Turbo:Stream:From> Twig component was introduced in Symfony UX 3.1.

The <twig:Turbo:Stream:From> Twig component renders a <turbo-mercure-stream-source> custom HTML element that subscribes to a Mercure topic and delivers incoming Turbo Stream messages to the page.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{# subscribe to a string topic #}
<twig:Turbo:Stream:From topics="chat" />

{# subscribe to an entity class (resolves its topic URL automatically) #}
<twig:Turbo:Stream:From topics="App\\Entity\\Book" />

{# subscribe to a private topic (sets withCredentials on the EventSource) #}
<twig:Turbo:Stream:From topics="chat" private />

{# subscribe using a specific transport #}
<twig:Turbo:Stream:From topics="App\\Entity\\Book" transport="hub2" />

{# subscribe to multiple topics #}
<twig:Turbo:Stream:From topics="{{ ['topic_a', 'topic_b'] }}" />

Caution

Double the backslashes when passing a Fully Qualified Class Name. Twig reads \E inside a string literal as an escape sequence: Twig 3 drops the backslash, so 'App\Entity\Book' turns into the topic AppEntityBook, which no broadcast publishes to. The stream then stays silent rather than reporting an error. Twig 4 keeps the backslash and the class name reaches Turbo intact.

You can also use the turbo_stream_from() Twig function directly:

1
2
3
{{ turbo_stream_from('chat') }}
{{ turbo_stream_from('chat', private: true) }}
{{ turbo_stream_from('App\\Entity\\Book', transport: 'hub2') }}

3.6

Support for the Mercure protocol 1.0 was introduced in Symfony UX 3.6.

When the hub speaks the Mercure protocol 1.0 (protocol_version: '1.0' in the MercureBundle configuration, the default since 0.6), turbo_stream_from() subscribes with the query parameters of this protocol, and listening to every entity of a class uses a URL Pattern (https://symfony.com/ux-turbo/App%5CEntity%5CBook/:id). The deprecated turbo_stream_listen() function does not support this protocol.

Broadcast Doctrine Entities Update

Symfony UX Turbo also comes with a convenient integration with Doctrine ORM.

With a single attribute, your clients can subscribe to creations, updates and deletions of entities:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/Entity/Task.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
+use Symfony\UX\Turbo\Attribute\Broadcast;

#[ORM\Entity]
+#[Broadcast] // Broadcast entity changes to all connected clients
class Task
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;
}

To subscribe to updates of an entity, pass it to the turbo_stream_from() Twig function:

1
2
{{ turbo_stream_from(task) }}
<div id="task_{{ task.id }}">{{ task.title }}</div>

Alternatively, you can subscribe to updates made to all entities of a given class by using its Fully Qualified Class Name:

1
2
3
4
{{ turbo_stream_from('App\\Entity\\Task') }}
<div id="tasks">
    {# ... #}
</div>

Finally, create the template that will be rendered when an entity is created, modified or deleted:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{# templates/broadcast/Task.stream.html.twig #}
{% block create %}
    <turbo-stream action="append" targets="#tasks">
        <template>
            <div id="{{ 'task_' ~ id }}">{{ entity.title }} (#{{ id }})</div>
        </template>
    </turbo-stream>
{% endblock %}

{% block update %}
    <turbo-stream action="update" targets="#task_{{ id }}">
        <template>
            {{ entity.title }} (#{{ id }}, updated)
        </template>
    </turbo-stream>
{% endblock %}

{% block remove %}
    <turbo-stream action="remove" targets="#task_{{ id }}"></turbo-stream>
{% endblock %}

By convention, Symfony UX Turbo will look for a template named templates/broadcast/{ClassName}.stream.html.twig. This template must contain at least 3 blocks: create, update and remove (they can be empty, but they must exist).

Every time an entity marked with the Broadcast attribute changes, Symfony UX Turbo will render the associated template and will broadcast the changes to all connected clients.

Each block must contain a list of Turbo Stream actions. These actions will be automatically applied by Turbo to the DOM tree of every connected client. Each template can contain as many actions as needed.

For instance, if the same entity is displayed on different pages, you can include all actions updating these different places in the template. Actions applying to non-existing DOM elements will simply be ignored.

The current entity, the string representation of its identifier(s), the action (create, update or remove) and options set on the Broadcast attribute are passed to the template as variables: entity, id, action and options.

Broadcast Conventions and Configuration

Because Symfony UX Turbo needs access to their identifier, entities have to either be managed by Doctrine ORM, have a public property named id, or have a public method named getId().

Symfony UX Turbo will look for a template named after mapping their Fully Qualified Class Names. For example and by default, if a class marked with the Broadcast attribute is named App\Entity\Foo, the corresponding template will be found in templates/broadcast/Foo.stream.html.twig.

It's possible to configure how namespaces are mapped to templates by using the turbo.broadcast.entity_template_prefixes configuration option. The default is defined as such:

1
2
3
4
5
# config/packages/turbo.yaml
turbo:
    broadcast:
        entity_template_prefixes:
            App\Entity\: broadcast/

Finally, it's also possible to explicitly set the template to use with the template parameter of the Broadcast attribute:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/Entity/Task.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;

#[ORM\Entity]
-#[Broadcast]
+#[Broadcast(template: 'my-template.stream.html.twig')]
class Task
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;
}

Broadcast Options

The Broadcast attribute comes with a set of handy options:

  • transports (string[]): a list of transports to broadcast to
  • topics (string[]): a list of topics to use, the default topic is derived from the FQCN of the entity and from its id
  • template (string): Twig template to render (see above)

The Broadcast attribute can be repeated (e.g. you can have multiple #[Broadcast]. This is convenient to render several templates associated with their own topics for the same change (e.g. the same data is rendered in different way in the list and in the detail pages).

Options are transport-specific. When using Mercure, some extra options are supported:

  • private (bool): marks Mercure updates as private
  • sse_id (string): id field of the SSE
  • sse_type (string): type field of the SSE
  • sse_retry (int): retry field of the SSE

The Mercure broadcaster also supports Expression Language in topics by starting with @=.

Example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/Entity/Task.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\UX\Turbo\Attribute\Broadcast;

#[ORM\Entity]
-#[Broadcast]
+#[Broadcast(
+    topics: ['@="task_detail_" ~ entity.getId()', 'tasks'],
+    template: 'task_detail.stream.html.twig',
+    private: true,
+)]
+#[Broadcast(
+    topics: ['@="task_list_" ~ entity.getId()', 'tasks'],
+    template: 'task_list.stream.html.twig',
+    private: true,
+)]
class Task
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;
}

Backward Compatibility promise

This bundle aims at following the same Backward Compatibility promise as the Symfony framework: https://symfony.com/doc/current/contributing/code/bc.html

Credits

Symfony UX Turbo has been created by Kévin Dunglas. It has been inspired by hotwired/turbo-rails and sroze/live-twig.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version