Simultaneous editing

Easy with Symfony UX



Symfony Live Berlin, Germany - April 24th, 2026

© David Buchmann







David Buchmann - @dbu@phpc.social

PHP Engineer, Switzerland

Turbo Drive

Demo

ux.davidbu.ch





Source: github.com/dbu/symfony-ux-demo






Turbo Frames

Turbo Frame Frontend

<turbo-frame id="variable-content">
    <p>Prompt for:
        <a href="{{ path('my-route') }}">link</a>.
    </p>
</turbo-frame>
    
By default, links inside the frame stay in the frame.
To link from outside the frame:
<a href="{{ path('my-route') }}"
  data-turbo-frame="variable-content"
>
If you have a form, the action must be with full path. The URL is not updated when loading a frame.

Turbo Frame Backend

#[Route('/frames/target', name: 'my-route')]
public function target(Request $request): Response
{
    $turboFrameId = $request->headers->get('Turbo-Frame');

    if ('variable-content' === $turboFrameId) {
        // optimization: render only the frame but not the
        // rest of the page as it would be discarded anyways.
        return $this->render('variable-content.html.twig');
    }

    return $this->render('full-page.html.twig');
}

        

Turbo Streams

Demo

ux.davidbu.ch

Sending a message to the backend

<form method="post">
    <label for="message">Message</label>
    <input id="message" name="message"
        type="text" required autofocus>
    <button type="submit">Send</button>
</form>
            
If the backend answers 204 "No Content" to the form submission, Turbo will just stay on the page.

Subscribe to Stream

<body
  {{ turbo_stream_listen(topic) }}
>
<section id="messages"></section>
            

Send Stream Updates

Twig\Environment $twig
Symfony\Component\Mercure\HubInterface $hub

#[Route('/streams', name: 'app_streams', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
    if ($request->isMethod('POST')) {
        $message = $request->request->get('message', ''));
        if ('' !== $message) {
            // send message to all connected clients
            ...
            

Send Stream Updates

...
// render HTML in backend
$html = $this->twig->render('_message.html.twig', [
    'message' => $message,
]);

// add message to the beginning of the list
$payload = TurboStream::prepend('#messages', $html)
    // and update this independent section
    . TurboStream::update('#last-message',
    'Last message received at '.date('H:i:s'))
;
$hub->publish(new Update(self::TOPIC, $payload));

...

Turbo stream actions

Mercure Hub

Mercure




Stimulus

Stimulus

Stimulus Controller to reset form

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    reset(event) {
        if (event.detail.success) {
            this.element.reset();
        }
    }
}
  

Set up reset controller

<form
    method="post"
    data-controller="reset-form"
    data-action="turbo:submit-end->reset-form#reset"
>
            
Or with Twig functions:
<form method="post"
    {{ stimulus_controller('reset-form') }}
    {{ stimulus_action('reset-form', 'reset', 'turbo:submit-end') }}
>
            

Server Sent Events - SSE

Server Sent Events - SSE

Demo

ux.davidbu.ch

SSE subscriber: Setup

import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['messages'];
    static values = {url: String};

    connect() {
        this.eventSource = new EventSource(this.urlValue);
        this.eventSource.onmessage =
            (event) => this.appendMessage(event);
    }

    disconnect() {
        this.eventSource?.close();
    }
    ...
            

SSE subscriber: Handle Message

...

appendMessage(event) {
    const payload = JSON.parse(event.data);
    ... build HTML element with payload ...

    this.messagesTarget.append(article);
    this.messagesTarget.scrollTop =
        this.messagesTarget.scrollHeight;
}
        

Wire the SSE subscriber

<div {{ stimulus_controller('subscribe-messages',
            { url: mercure(topic) })
}}>
...
<section class="messages"
    {{ stimulus_target('subscribe-messages', 'messages') }}
></section>
            

Send with Mercure in Symfony

$hub->publish(new Update(self::TOPIC, json_encode([
    'timestamp' => date('H:i:s'),
    'message' => $message,
], \JSON_THROW_ON_ERROR)));








Simultaneous Editing

Simultaneous Editing


What about Security?

Security Concerns

Private Updates

$update = new Update(
    'topic-123',
    json_encode(['status' => 'OutOfStock']),
    private: true,
);
{{ 'eventSrcUrl' : mercure('topic-123', {
      subscribe: 'topic-123'
}) }}
new EventSource(this.eventSrcUrlValue, {
    withCredentials: true
})






Recap

Turbo, Turbo

Stimulus

Not perfect either, fails silently on naming mismatch

Hotwire Benefits


Alternative: HTMX

Symfony UX

ux.symfony.com

Thank you!





https://phpc.social/@dbu

David Buchmann