Symfony UX Turbo
Screencast
Do you prefer video tutorials? Check out the Symfony UX Turbo screencast series.
Symfony UX Turbo is a Symfony bundle integrating the Hotwire Turbo library in Symfony applications. It is part of the Symfony UX initiative.
Symfony UX Turbo allows having the same user experience as with Single Page Applications but without having to write a single line of JavaScript!
Symfony UX Turbo also integrates with Symfony Mercure or any other transports to broadcast DOM changes to all currently connected users!
Installation
Install the bundle using Composer and Symfony Flex:
1
$ composer require symfony/ux-turbo
Usage
Turbo Drive
Turbo Drive enhances page-level navigation. It watches for link clicks and form submissions, performs them in the background, and updates the page without doing a full reload. This gives you the "single-page-app" experience without major changes to your code!
Turbo Drive is automatically enabled when you install Symfony UX Turbo.
Getting Started with Turbo Drive
There are 3 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 WebpackEncore. 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:
stringdefault:replacepossible values:replaceormorph scroll(optional)-
type:
stringdefault:resetpossible values:resetorpreserve
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:
stringdefault:replacepossible values:replaceormorph
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:
stringdefault:resetpossible values:resetorpreserve
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
Turbo Frames let you treat any subset of a page as its own component: links and form submissions within a frame replace only that part, without any custom JavaScript. Frames can also be lazy-loaded, making it easy to split a page into independently cached pieces.
<twig:Turbo:Frame>
Returns a <turbo-frame> element that can either be used to encapsulate
frame content or as a lazy-loading container that starts empty but fetches
the URL supplied in the src attribute.
Examples
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>
Navigation within a Frame
When a link inside a Turbo Frame is clicked, Turbo automatically replaces the frame content with the matching frame from the linked page.
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", the Turbo Frame will be
automatically replaced with the matching frame from task/edit.html.twig.
Detecting Turbo Frame Requests
nject 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
Symfony UX Turbo registers text/vnd.turbo-stream.html as the
TurboBundle::STREAM_FORMAT format, which can be detected in controllers
using $request->getPreferredFormat().
Turbo Stream Responses
When a user submits a form or triggers an action, the controller can detect the Turbo Stream format and return a partial page update instead of a full redirect. There are two ways to do this:
Option 1 — dedicated template with renderBlock:
Use renderBlock() to render a specific Twig block from a template as a
Turbo Stream response. This keeps the stream markup close to the page template
it updates:
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 %}
Option 2 — inline with TurboStreamResponse:
Use TurboStreamResponse to build stream actions directly from the
controller, without a dedicated template. This is convenient 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);
}
}
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:
1 2 3 4 5 6 7
// src/Controller/TaskController.php
use Symfony\UX\Turbo\TurboStreamResponse;
if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
return (new TurboStreamResponse())
->action('my_action', '#task_'.$task->getId(), '<li>'.$task->getTitle().'</li>');
}
Broadcasting
Symfony UX Turbo also supports broadcasting HTML updates to all currently connected clients, using the Mercure protocol or any other.
Start by installing the Mercure support on your project:
1
$ composer require symfony/mercure-bundle
Then, enable the "mercure stream" controller in assets/controllers.json:
1 2 3 4 5 6 7
"@symfony/ux-turbo": {
"mercure-turbo-stream": {
- "enabled": false,
+ "enabled": true,
"fetch": "eager"
}
},
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: '*'
To illustrate this, here is how to build a real-time task list with 0 lines of JavaScript. When a task is created, all connected clients will automatically see it appear in the list:
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
// 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
// Push the update to all connected clients via Mercure
$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 7 8 9 10 11 12 13
{# task/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>Tasks</h1>
<div id="tasks" {{ turbo_stream_listen('tasks') }}>
{# New tasks will automatically appear here for all connected users #}
{% for task in tasks %}
<div id="task_{{ task.id }}">{{ task.title }}</div>
{% endfor %}
</div>
{% endblock %}
1 2 3 4 5 6
{# task/stream/new.html.twig #}
<turbo-stream action="append" targets="#tasks">
<template>
<div id="task_{{ task.id }}">{{ task.title }}</div>
</template>
</turbo-stream>
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'] }}" />
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') }}
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 as parameter of the
turbo_stream_listen() Twig helper:
1
<div id="task_{{ task.id }}" {{ turbo_stream_listen(task) }}></div>
Alternatively, you can subscribe to updates made to all entities of a given class by using its Fully Qualified Class Name:
1
<div id="tasks" {{ turbo_stream_listen('App\\Entity\\Task') }}></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 totopics(string[]): a list of topics to use, the default topic is derived from the FQCN of the entity and from its idtemplate(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 privatesse_id(string):idfield of the SSEsse_type(string):typefield of the SSEsse_retry(int):retryfield 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.