mykiwi / ux-turbo

Hotwire Turbo integration for Symfony

Home Page:https://symfony.com/ux

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Symfony UX Turbo

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 Apps 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!

You're in a hurry? Take a look at the chat example to discover the full potential of Symfony UX Turbo.

Installation

Symfony UX Turbo requires PHP 7.2+ and Symfony 5.2+.

Install this bundle using Composer and Symfony Flex:

composer require symfony/ux-turbo

# Don't forget to install the JavaScript dependencies as well and compile
yarn install --force
yarn encore dev

Usage

Accelerating Navigation with 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. And while you don't need to make major changes to get things to work smoothly, 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.

We also recommend that you place your script tags live inside your head tag so that they aren't reloaded on every navigation (Turbo re-executes any script tags inside body on every navigation). Add a defer attribute to each script tag to prevent it from blocking the page load. See Moving <script> inside and the "defer" Attribute for more info.

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.

To enable this, first verify that you have versioning enabled in Encore so that your filenames change when the file contents change:

// webpack.config.js

Encore.
    // ...
    .enableVersioning(Encore.isProduction())

Then add a data-turbo-track="reload" attribute to all of your script and link tags:

# config/packages/webpack_encore.yaml
webpack_encore:
    # ...

    script_attributes:
        defer: true
        'data-turbo-track': reload
    link_attributes:
        'data-turbo-track': reload

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).

If you're using Symfony 5.3, the new handleForm() shortcut takes care of this automatically:

/**
 * @Route("/product/new", name="product_new")
 */
public function newProduct(Request $request): Response
{
    return $this->handleForm(
        $this->createForm(ProductFormType::class, null, [
            'action' => $this->generateUrl('product_new'),
        ]),
        $request,
        function (FormInterface $form) {
            // save...

            return $this->redirectToRoute(
                'product_list',
                [],
                Response::HTTP_SEE_OTHER
            );
        },
        function (FormInterface $form) {
            return $this->render('product/new.html.twig', [
                'form' => $form->createView(),
            ]);
        }
    );
}

If you're not using the handleForm() shortcut, adjust your code manually:

/**
 * @Route("/product/new")
 */
public function newProduct(Request $request): Response
{
    $form = $this->createForm(ProductFormType::class);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        // save...

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

+    $response = new Response(null, $form->isSubmitted() ? 422 : 200);

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

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. This also changes the redirect status code from 302 (the default) to 303 (HTTP_SEE_OTHER). That's not required for Turbo Drive, but 303 is "more correct" for this situation.

NOTE: When your form contains more than one submit button and, you want to check which of the buttons was clicked to adapt the program flow in your controller. You need to add a value to each button because Turbo Drive doesn't send element with empty value:

$builder
    // ...
    ->add('save', SubmitType::class, [
        'label' => 'Create Task',
        'attr' => [
            'value' => 'create-task'
        ]
    ])
    ->add('saveAndAdd', SubmitType::class, [
        'label' => 'Save and Add',
        'attr' => [
            'value' => 'save-and-add'
        ]
    ]);

More Turbo Drive Info

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

Decomposing Complex Pages with Turbo Frames

Once Symfony UX Turbo is installed, you can also leverage Turbo Frames:

{# home.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <turbo-frame id="the_frame_id">
        <a href="{{ path('another-page') }}">This block is scoped, the rest of the page will not change if you click here!</a>
    </turbo-frame>
{% endblock %}
{# another-page.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <div>This will be discarded</div>

    <turbo-frame id="the_frame_id">
        The content of this block will replace the content of the Turbo Frame!
        The rest of the HTML generated by this template (outside of the Turbo Frame) will be ignored.
    </turbo-frame>
{% endblock %}

The content of a frame can be lazy loaded:

{# home.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <turbo-frame id="the_frame_id" src="{{ path('block') }}">
        A placeholder.
    </turbo-frame>
{% endblock %}

In your controller, you can detect if the request has been triggered by a Turbo Frame, and retrieve the ID of this frame:

// src/Controller/MyController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;

class MyController
{
    #[Route('/')]
    public function home(Request $request): Response
    {
        // Get the frame ID (will be null if the request hasn't been triggered by a Turbo Frame)
        $frameId = $request->headers->get('Turbo-Frame');

        // ...
    }
}

Writing Tests

Under the hood, Symfony UX Turbo relies on JavaScript to update the HTML page. To test if your website works properly, you will have to write UI tests.

Fortunately, we've got you covered! Symfony Panther is a convenient testing tool using real browsers to test your Symfony application. It shares the same API as BrowserKit, the functional testing tool shipped with Symfony.

Install Symfony Panther, and write a test for our Turbo Frame:

// tests/TurboFrameTest.php
namespace App\Tests;

use Symfony\Component\Panther\PantherTestCase;

class TurboFrameTest extends PantherTestCase
{
    public function testFrame(): void
    {
        $client = self::createPantherClient();
        $client->request('GET', '/');

        $client->clickLink('This block is scoped, the rest of the page will not change if you click here!');
        $this->assertSelectorTextContains('body', 'This will replace the content of the Turbo Frame!');
    }
}

Run bin/phpunit to execute the test! Symfony Panther automatically starts your application with a web server and tests it using Google Chrome or Firefox!

You can even watch changes happening in the browser by using: PANTHER_NO_HEADLESS=1 bin/phpunit --debug

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

Coming Alive with Turbo Streams

Turbo Streams are a way for the server to send partial page updates to clients. There are two main ways to receive the updates:

  • in response to a user action, for instance when the user submits a form;
  • asynchronously, by sending updates to clients using Mercure, WebSocket and similar protocols.

Forms

Let's discover how to use Turbo Streams to enhance your Symfony forms:

// src/Controller/TaskController.php
namespace App\Controller;

// ...
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\UX\Turbo\Stream\TurboStreamResponse;

class TaskController extends AbstractController
{
    public function new(Request $request): Response
    {
        $task = new Task();

        $form = $this->createForm(TaskType::class, $task);
        $form->handleRequest($request);

        $submitted = $form->isSubmitted();
        $valid = $submitted && $form->isValid();

        if ($valid) {
            $task = $form->getData();
            // ... perform some action, such as saving the task to the database

            // 🔥 The magic happens here! 🔥
            if (TurboStreamResponse::STREAM_FORMAT === $request->getPreferredFormat()) {
                // If the request comes from Turbo, only send the HTML to update using a TurboStreamResponse
                return $this->render('task/success.stream.html.twig', ['task' => $task], new TurboStreamResponse());
            }

            // If the client doesn't support JavaScript, or isn't using Turbo, the form still works as usual.
            // Symfony UX Turbo is all about progressively enhancing your apps!
            return $this->redirectToRoute('task_success', [], Response::HTTP_SEE_OTHER);
        }

        // Symfony 5.3+
        return $this->renderForm('task/new.html.twig', $form);

        // Older versions
        $response = $this->render('task/new.html.twig', [
            'form' => $form->createView(),
        ]);
        if ($submitted && !$valid) {
            $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
        }

        return $response;
    }
}
{# success.stream.html.twig #}

<turbo-stream action="replace" target="my_div_id">
    <template>
        The element having the id "my_div_id" will be replace by this block, without a full page reload!

        <div>The task "{{ task }}" has been created!</div>
    </template>
</turbo-stream>

Supported actions are append, prepend, replace, update and remove. Read the Turbo Streams documentation for more details.

Sending Async Changes using Mercure: a Chat

Symfony UX Turbo also supports broadcasting HTML updates to all currently connected clients, using the Mercure protocol or any other.

To illustrate this, let's build a chat system with 0 lines of JavaScript!

Start by installing the Mercure support on your project:

composer require symfony/ux-turbo-mercure
yarn install --force
yarn encore dev

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) to use:

# config/packages/turbo.yaml
turbo:
    mercure:
        hubs: [default]

Let's create our chat:

// src/Controller/ChatController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\PublisherInterface;

class ChatController extends AbstractController
{
    public function chat(Request $request, PublisherInterface $mercure): Response
    {
        $form = $this->createFormBuilder()
            ->add('message', TextType::class, ['attr' => ['autocomplete' => 'off']])
            ->add('send', SubmitType::class)
            ->getForm();

        $emptyForm = clone $form; // Used to display an empty form after a POST request
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $data = $form->getData();

            // 🔥 The magic happens here! 🔥
            // The HTML update is pushed to the client using Mercure
            $mercure->publish(new Update(
                'chat',
                $this->renderView('chat/message.stream.html.twig', ['message' => $data['message']])
            ));

            // Force an empty form to be rendered below
            // It will replace the content of the Turbo Frame after a post
            $form = $emptyForm;
        }

        return $this->render('chat/index.html.twig', [
            'form' => $form->createView(),
         ]);
    }
}
{# chat/index.html.twig #}
{% extends 'base.html.twig' %}

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

    <div id="messages" {{ turbo_stream_listen('chat') }}>
        {#
            The messages will be displayed here.
            "turbo_stream_listen()" automatically registers a Stimulus controller that subscribes to the "chat" topic as managed by the transport.
            All connected users will receive the new messages!
         #}
    </div>

    <turbo-frame id="message_form">
        {{ form(form) }}

        {#
            The form is displayed in a Turbo Frame, with this trick a new empty form is displayed after every post,
            but the rest of the page will not change.
        #}
    </turbo-frame>
{% endblock %}
{# chat/message.stream.html.twig #}

{# New messages received through the Mercure connection are appended to the div with the "messages" ID. #}
<turbo-stream action="append" target="messages">
    <template>
        <div>{{ message }}</div>
    </template>
</turbo-stream>

Keep in mind that you can use all features provided by Symfony Mercure, including private updates (to ensure that only authorized users will receive the updates) and async dispatching with Symfony Messenger.

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:

// src/Entity/Book.php
namespace App\Entity;

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

/**
 * @ORM\Entity
 */
#[Broadcast] // 🔥 The magic happens here
class Book
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    public ?int $id = null;

    /**
     * @ORM\Column
     */
    public string $title = '';
}

To subscribe to updates of an entity, pass it as parameter of the turbo_stream_listen() Twig helper:

<div id="book_{{ book.id }}" {{ turbo_stream_listen(book) }}></div>

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

<div id="books" {{ turbo_stream_listen('App\\Entity\\Book') }}></div>

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

{# templates/broadcast/Book.stream.html.twig #}

{% block create %}
    <turbo-stream action="append" target="books">
        <template>
            <div id="{{ 'book_' ~ id }}">{{ entity.title }} (#{{ id }})</div>
        </template>
    </turbo-stream>
{% endblock %}

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

{% block remove %}
    <turbo-stream action="remove" target="book_{{ 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 own namespaces are mapped to templates by using the turbo.broadcast.entity_template_prefixes configuration options. The default is defined as such:

# 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:

#[Broadcast(template: 'my-template.stream.html.twig')]
class Book { /* ... */ }

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)

Options are transport-sepcific. 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

Example:

// src/Entity/Book.php
namespace App\Entity;

use Symfony\UX\Turbo\Attribute\Broadcast;

#[Broadcast(template: 'foo.stream.html.twig', private: true)]
class Book
{
    // ...
}

Using Multiple Transports

Symfony UX Turbo allows sending Turbo Streams updates using multiple transports. For instance, it's possible to use several Mercure hubs with the following configuration:

# config/packages/mercure.yaml
mercure:
    hubs:
        hub1:
            url: https://hub1.example.net/.well-known/mercure
            jwt: snip
        hub2:
            url: https://hub2.example.net/.well-known/mercure
            jwt: snip
# config/packages/turbo.yaml
turbo:
    mercure:
        hubs: [hub1, hub2]

Use the appropriate Mercure HubInterface service to send a change using a specific transport:

// src/Controller/MyController.php
namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

class MyController extends AbstractController
{
    public function publish(HubInterface $hub1): Response
    {
        $id = $hub1->publish(new Update('topic', 'content'));

        return new Response("Update #{$id} published.");
    }
}

Changes made to entities marked with the #[Broadcast] attribute will be sent using all configured transport by default. You can specify the list of transports to use for a specific entity class using the transports parameter:

// src/Entity/Book.php
namespace App\Entity;

use Symfony\UX\Turbo\Attribute\Broadcast;

#[Broadcast(transports: ['hub1', 'hub2'])]
/** ... */
class Book
{
    // ...
}

Finally, generate the HTML attributes registering the Stimulus controller corresponding to your transport by passing an extra argument to turbo_stream_listen():

<div id="messages" {{ turbo_stream_listen('App\Entity\Book', 'hub2') }}></div>

Registering a Custom Transport

If you prefer using another protocol than Mercure, you can create custom transports:

// src/Turbo/Broadcaster.php
namespace App\Turbo;

use Symfony\UX\Turbo\Attribute\Broadcast;
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;

class Broadcaster implements BroadcasterInterface
{
    public function broadcast(object $entity, string $action): void
    {
        // This method will be called everytime an object marked with the #[Broadcast] attribute is changed
        $attribute = (new \ReflectionClass($entity))->getAttributes(Broadcast::class)[0] ?? null;
        // ...
    }
}
// src/Turbo/TurboStreamListenRenderer.php
namespace App\Turbo;

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface;
use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension;
use Twig\Environment;

#[AsTaggedItem(index: 'my-transport')]
class TurboStreamListenRenderer implements TurboStreamListenRendererInterface
{
    public function __construct(
        private StimulusTwigExtension $stimulusTwigExtension,
    ) {}

    /**
     * @param string|object $topic
     */
    public function renderTurboStreamListen(Environment $env, $topic): string
    {
        return $this->stimulusTwigExtension->renderStimulusController(
            $env,
            'your_stimulus_controller',
            [/* controller values such as topic */]
        );
    }
}

The broadcaster must be registered as a service tagged with turbo.broadcaster and the renderer must be tagged with turbo.renderer.stream_listen. If you enabled autoconfigure option (it's the case by default), these tags will be added automatically because these classes implement the BroadcasterInterface and TurboStreamListenRendererInterface interfaces, the related services will be.

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

However, it is currently considered experimental, meaning it is not bound to Symfony's BC policy for the moment.

Credits

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

About

Hotwire Turbo integration for Symfony

https://symfony.com/ux

License:MIT License


Languages

Language:PHP 84.9%Language:Twig 7.6%Language:JavaScript 7.5%