· TechMeetup Ostrava Conference 2022 · Ostrava · given in Czech

Real-time updates with Mercure and Symfony UX Turbo

Real-time komunikace s Mercure protokolem a Symfony. How to show users what’s happening right now, without polling and without writing JavaScript, on a real example from Peon. I first gave this talk in English at AmsterdamPHP on 21 April 2022, and then in Czech in Ostrava. Both recordings are linked at the bottom.

Update, 2026The symfony/ux-turbo-mercure package used below was deprecated in Symfony UX 2.6.1, a few months after this talk. Its code now ships inside symfony/ux-turbo: install that together with symfony/mercure-bundle. The approach itself hasn’t changed.

The original agenda also had a hello world, installing Mercure, running the Mercure server and an introduction to Hotwire Turbo. For the conference I crossed those out and focused on how Mercure works, when it’s worth it, which Symfony packages to use, and a clean implementation in the Peon codebase.

Mercure, the modern websockets

Mercure is a protocol and a server for pushing updates from the server to browsers. What I like about it:

The Mercure FAQ explains the difference from WebSockets well: the WebSocket API is low level, Mercure is high level and gives you authorization, reconnection and state reconciliation for free. In most cases you can use it as a modern and easier replacement for WebSockets.

The terminology

Diagram: the app server sends updates to the Mercure hub with HTTP POST, and the hub pushes them to a desktop browser, a laptop and a phone over server-sent events (SSE).
The app server POSTs to the hub. The hub fans updates out to every subscriber over SSE.

Publishing is a single request:

curl \
  -d 'topic=my-topic' \
  -d 'data="Some data"' \
  -H 'Authorization: Bearer <TOKEN>' \
  -X POST \
  https://localhost/.well-known/mercure

Topics should be URIs. They can use templates like news/{id} and the * wildcard, and you can subscribe to several at once: https://localhost/.well-known/mercure?topic=X&topic=Y. In the browser it’s the native EventSource:

const url = new URL('https://localhost/.well-known/mercure');
url.searchParams.append('topic', 'topic-a');
url.searchParams.append('topic', 'projects/{id}');

const eventSource = new EventSource(url);

// The callback will be called every time an update is published
eventSource.onmessage = e => console.log(e);

When it’s worth it

Not every application benefits from Mercure. It’s about user experience: giving the user feedback. Good fits are live availability (e-commerce, booking.com), chat, asynchronous jobs and real-time collaboration.

The Symfony pieces

The use case in Peon

Peon runs tasks on your repositories. The domain use case: the user tells Peon to run a task, which creates a pending job. A worker in the background picks the job up and starts working on it. Then the job finishes.

Whenever the status changes, the domain dispatches an event:

final class JobStatusChanged
{
    public function __construct(
        public readonly JobId $jobId,
        public readonly ProjectId $projectId,
    ) {}
}

$this->eventBus->dispatch(
    new JobStatusChanged(
        $job->jobId,
        $job->projectId,
    ),
);

The page itself is ordinary: ProjectOverviewController loads the read jobs for the project and renders project_overview.html.twig, a table that shows “Pending” or “In progress” for each job.

That’s the standard, poor user experience. You open the page, the job says “In progress”, and it keeps saying so until you refresh. The new requirement: as a user, I want to see status updates in real time.

Adding Mercure without touching the domain

Before you start, you need basic knowledge of Mercure and Hotwire Turbo, a running Mercure hub, and symfony/ux-turbo-mercure installed and configured.

Diagram: the client browser calls the controller in the UI layer, which renders a template. In the domain, a worker drives a use case handler that dispatches an event. A subscriber receives the event, renders the template and publishes it to the Mercure hub, which pushes it back to the client browser. The Mercure hub part is circled.
The only new parts are a subscriber and the hub. The domain doesn’t know Mercure exists.

One rule I hold to: the application must behave exactly the same way with Mercure as without it. If the user refreshes the page, they must see exactly the same content. That leads to three types of templates:

First, the page subscribes to a topic from Twig:

<div {{ turbo_stream_listen('project-' ~ activeProject.projectId ~ '-overview') }}></div>

Then the row of the table moves into its own fragment, _recent_job.html.twig, with a stable id, and the controller template just includes it in the loop:

<tbody id="recent-jobs">
    {% for job in jobs %}
        {{ include('project_overview/_recent_job.html.twig') }}
    {% endfor %}
</tbody>

<span id="recent-job-{{ job.jobId }}">
    {% if job.pending %}
        <span class="status-pending">Pending</span>
    {% endif %}
    {# .. more template code .. #}
</span>

The stream template wraps the same fragment in a Turbo Stream that replaces the element with that id:

<turbo-stream action="replace" target="recent-job-{{ job.jobId }}">
    <template>
        {{ include('project_overview/_recent_job.html.twig') }}
    </template>
</turbo-stream>

And the event subscriber renders the final HTML, just like a controller would, and publishes it to the hub:

final class PublishMercureUpdateWhenJobStatusChanged implements EventHandlerInterface
{
    public function __construct(
        private HubInterface $hub,
        private Environment $twig,
        private ProvideReadJobById $provideReadJobById,
    ) {}

    public function __invoke(JobStatusChanged $event): void {
        $topic = 'project-' . $event->projectId->id . '-overview';

        // Render the final HTML, just like you would in controller:
        $data = $this->twig->render('job_status_changed.stream.html.twig', [
            'job' => $this->provideReadJobById->provide($event->jobId),
        ]);

        $this->hub->publish(
            new Update($topic, $data)
        );
    }
}

That’s it. The browser keeps one EventSource connection open, the stream arrives, Turbo swaps the row, and the status changes from “Pending” to “In progress” to “Succeeded” on its own. In the browser’s network tab you can see the single eventsource request, and the Mercure debug UI on /.well-known/mercure/ui/ shows the published Turbo Stream HTML.

Final thoughts

Kudos to Kévin Dunglas, the author of Mercure.

All 35 slides
  1. Slide 1: Title: Real-time updates with Mercure protocol and Symfony UX Turbo, Jan Mikeš, #TechMeetupConf 21.10.2022
  2. Slide 2: Agenda: introducing Mercure, use cases, Mercure + Symfony components, clean implementation in Peon.dev
  3. Slide 3: Mercure, the modern websockets: open source in Go, server-sent events, HTTP/2, JWT auth, updates via POST
  4. Slide 4: Mercure documentation FAQ comparing Mercure with WebSockets, WebSub, and Web Push
  5. Slide 5: Mercure terminology: hub, topic, subscription, subscriber, publication, publisher
  6. Slide 6: Diagram: app server POSTs to the Mercure hub, which pushes updates to clients via SSE
  7. Slide 7: Publishing an update with curl; topics in URI format, templates, and wildcards
  8. Slide 8: JavaScript code subscribing to a topic with EventSource
  9. Slide 9: Not every application benefits from using Mercure
  10. Slide 10: User experience use cases: live availability, chat, asynchronous jobs, real-time collaboration
  11. Slide 11: Mercure + Symfony packages: Mercure, Hotwire Turbo, symfony/ux-turbo, symfony/ux-turbo-mercure
  12. Slide 12: Hotwire website: HTML over the wire, an alternative to building apps with JSON and JavaScript
  13. Slide 13: Peon "run a task" use case: user creates a pending job, worker starts it, job is finished
  14. Slide 14: PHP code: JobStatusChanged event class dispatched to the event bus
  15. Slide 15: Diagram: controller and template in UI, use case handler dispatching events to subscribers
  16. Slide 16: PHP code: ProjectOverviewController rendering the project overview template with jobs
  17. Slide 17: Twig template rendering job status as Pending or In progress
  18. Slide 18: Standard (poor) user experience example
  19. Slide 19: Peon app screenshot: a task shown "In progress" that does not update without refresh
  20. Slide 20: Peon use case again, plus the goal: as a user I want status updates in real time
  21. Slide 21: Before you start: basics of Mercure and Hotwire Turbo, Mercure hub, symfony/ux-turbo-mercure installed
  22. Slide 22: Diagram from before with a Mercure hub added between subscriber and browser client
  23. Slide 23: Peon app screenshot: task in Pending state
  24. Slide 24: Peon app screenshot: project overview with recipes and recent jobs all succeeded
  25. Slide 25: Peon task finished in real time, with browser devtools showing the Mercure event stream
  26. Slide 26: Mercure debugging UI with subscribe and publish forms and received Turbo Stream update
  27. Slide 27: App must behave the same with or without Mercure; 3 template types: fragment, stream, controller
  28. Slide 28: PHP code: event subscriber rendering a Twig stream template and publishing it to the Mercure hub
  29. Slide 29: Twig code subscribing to a topic with turbo_stream_listen, link to Symfony UX Turbo docs
  30. Slide 30: Twig code: extracting the job row into a reusable fragment template
  31. Slide 31: Turbo Stream template replacing the job row with the fragment, link to Turbo handbook
  32. Slide 32: Final thoughts: better performance, use Caddy if possible, don't use it just because it's cool
  33. Slide 33: Kudos to Kévin Dunglas, illustrated with many thumbs-up hands
  34. Slide 34: Useful resources: mercure.rocks, turbo.hotwired.dev, Symfony UX Turbo, MDN server-sent events, peon-dev/peon
  35. Slide 35: Homer Simpson asking "Questions?" and Thank you! @honza_mikes, janmikes.cz

Related talks