Simultaneous editing

Easy with Symfony UX



ConFoo, Montreal, Canada - February 25th, 2026

© David Buchmann







David Buchmann - david@liip.ch

PHP Engineer, Liip SA, Switzerland

Turbo Drive






Turbo Frames

Turbo Frame

<a href="{{ path('evaluation_page_edit', {id: page.id}) }}"
  data-turbo-frame="modal_frame"
>
<div id="modal" class="hidden">
    <div class"modal-content">
        <turbo-frame id="modal_frame"
        </turbo-frame>
    </div>
</div>
<turbo-frame id="modal_frame">
    <h2>{{ title }}</h2>
    {# action must be full path.
       form is rendered in context of different URL #}
    {{ form(form) }}
</turbo-frame>
            
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["frame"]

  open() {
    this.element.classList.remove("hidden")
  }

  close() {
    this.element.classList.add("hidden")
    if (this.hasFrameTarget) {
        this.frameTarget.innerHTML = ""
    }
  }
onSubmit(event) {
  if (!event.detail.success) return

  // on error, we want to keep dialog open
  // can not use data-turbo-frame="_top"
  // server side can't break out of frame
  if (event.detail.fetchResponse.response.redirected) {
    this.close()
    window.Turbo.visit(
      event.detail.fetchResponse.response.url,
      { action: "replace" }
    )
  }
}
          
<div
  id="modal"
  class="hidden"
  {{ stimulus_controller('modal') }}
  {{ stimulus_action('modal', 'open', 'turbo:frame-load') |
     stimulus_action('modal', 'onSubmit', 'turbo:submit-end')
  }}
>
    <div class="modal-overlay"
      {{ stimulus_action('modal', 'close', 'click') }}
    ></div>

    <div class"modal-content">
        <turbo-frame id="modal_frame"
          {{ stimulus_target('modal', 'frame') }}>
        </turbo-frame>
    </div>
</div>

Turbo Streams

Subscribe to Stream

<body
  {{ turbo_stream_listen(
    'evaluation-' ~ evaluation.id
  ) }}
>
<div id="evaluation-score-{{ evaluation.id }}">
    {% include 'evaluation/score.html.twig'
        with {'evaluation': evaluation} only %}
</div>
            

Send Stream Updates

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

... event listener for changes on an evaluation

public function sendUpdate(Evaluation $evaluation): void
{
    $payload = $this->twig->render(
        'evaluation/score.stream.html.twig', [
            'evaluation' => $evaluation,
        ]
    );
    $update = new Update('evaluation-'.$evaluation->id, $payload);
    $this->hub->publish($update);
}
            

Render stream

<turbo-stream
  action="update"
  targets="#evaluation-score-{{ evaluation.id }}"
>
    <template>
        {% include 'evaluation/score.html.twig'
          with {'evaluation': evaluation} only
        %}
    </template>
</turbo-stream>
            

Turbo stream actions

Mercure Hub

Stimulus Controller to autosave

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

export default class extends Controller {
  static values = {
    editUrl: String,
    msgError: String,
  }

  ...
  
updateNumeric(event) {
  ... find value and question from event.target

  const editUrl = this.editUrlValue.replace(
      'answerId',
      question.dataset.questionId
  )
  fetch(editUrl), {
    ... http request to save value
  }).then((response) => {
    if (response.ok) {
      console.log('saved numeric answer')
    } else
    ... error handling - this.msgErrorValue
  

Set up autosave stimulus

<div {{ stimulus_controller('evaluation-edit', {
      'editUrl': path('evaluate_answer', {'id': 'answerId'}),
      'msgError': 'evaluate_category.messages.error'|trans,
    }) }}
>

<input
    type="number"
    value="{{ answer.value }}"
    id="{{ answer.id }}"
    {{ stimulus_action(
        'evaluation-edit',
        'updateNumeric',
        'change'
    ) }}
>
            








Simultaneous Editing

Simultaneous Editing

Send with Mercure in Symfony

#[AsEventListener]
final readonly class AnswerChangePublisher
{
    public function __construct(private HubInterface $hub) {}

    public function __invoke(AnswerHasBeenUpdated $event): void
    {
        $answer = $event->answer;
        $topic = 'evaluation-'.$answer->evaluation->id;
        $payload = json_encode([
            'answers' => [
                $answer->getId() => $answer,
            ],
        ], \JSON_THROW_ON_ERROR);
        $this->hub->publish(new Update($topic, $payload));
    }
}

Receive SSE in Stimulus

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

export default class extends Controller {
  static values = {
    eventSrcUrl: String,
  }
  static targets = ['numericAnswer']

  connect() {
    this.eventSource = new EventSource(this.eventSrcUrlValue)
    this.eventSource.onmessage = this.updateAnswersListener
  }
  disconnect() {
    this.eventSource.close()
  }
  
updateAnswersListener(event) {
  const msg = JSON.parse(event.data)
  if (!msg.hasOwnProperty('answers')) return

  for (const [answerId, answer] of msg.answers.entries()) {
    let input = document.getElementById(answerId)
    if (!input) continue

    switch (answer.questionType) {
      case 'numeric':
        input.value = answer.value
        break
      case 'boolean':
        ...
      default:
        console.log('Unexpected type '+answer.questionType)
    }
  }
}

Configuring the Event Source URL

<div
  {{ stimulus_controller(
       'evaluation-edit',
       {
         'eventSrcUrl':
             mercure('evaluation-'~evaluation.id)
          ...
       }
  }}>
...
</div>

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

Thank you!





https://phpc.social/@dbu

David Buchmann, Liip SA