
Matt MochalkinIn Part 1 of this series, we explored the “HTML-over-the-wire” philosophy and successfully scaffolded...
In Part 1 of this series, we explored the “HTML-over-the-wire” philosophy and successfully scaffolded a beautiful, albeit static, Kanban board using Symfony 7.4, Twig and Tailwind CSS. We avoided the “JavaScript Tax” by relying on AssetMapper instead of Webpack and we leveraged PHP 8.3 Backed Enums to keep our domain model strictly typed.
Now, we face the core challenge - How do we make this static board interactive? How do we allow users to drag and drop cards and crucially, how do we make those changes reflect instantly on the screens of every other user viewing the board?
If we were using React, this is where we would typically reach for a heavy library like react-beautiful-dnd, set up a complex Redux store or context provider to manage the optimistic state and write custom WebSocket connection logic to handle real-time events.
With Symfony UX, we take a radically simpler, standards-based approach:
Let’s dive into the code.
Stimulus is not meant to replace React or Vue. It is a “modest” framework. It doesn’t manage state and it doesn’t render HTML. It works by attaching JavaScript controllers to existing DOM elements via data-controller attributes. When that HTML appears on the screen, the Stimulus controller wakes up and attaches event listeners. When the HTML leaves the screen, the controller disconnects and cleans up.
We need a controller to handle the drag-and-drop mechanics. Demystifying the HTML5 Drag and Drop API is notorious for being slightly finicky, but Stimulus makes it manageable.
Create a new file at assets/controllers/drag_drop_controller.js:
// assets/controllers/drag_drop_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
// We define targets so we can easily reference our columns in the DOM
static targets = ["column"];
// Triggered when a user starts dragging a card (dragstart event)
start(event) {
// Store the ID of the task being dragged in the drag payload
// We get this from a data attribute we will add to the HTML: data-task-id
event.dataTransfer.setData("text/plain", event.currentTarget.dataset.taskId);
event.dataTransfer.effectAllowed = "move";
}
// Triggered constantly as a card is dragged over a valid dropzone (dragover event)
over(event) {
// Crucial HTML5 quirk: We MUST prevent default behavior to allow a drop to occur.
// By default, HTML elements do not accept drops.
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}
// Triggered when the user releases the mouse button over a column (drop event)
drop(event) {
event.preventDefault();
// 1. Get the data we stored during the 'start' event
const taskId = event.dataTransfer.getData("text/plain");
// 2. Identify the target column we dropped it into
const targetColumn = event.currentTarget.closest('[data-drag-drop-target="column"]');
const newStatus = targetColumn.dataset.status;
// 3. The "Optimistic UI" Update
// We move the DOM element instantly on the client side so the user
// feels zero latency. We don't wait for the server response to give visual feedback.
const taskElement = document.getElementById(`task-${taskId}`);
if (taskElement) {
targetColumn.querySelector('.space-y-3').appendChild(taskElement);
}
// 4. Sync with the Server
// We send a lightweight fetch request in the background to persist the change.
fetch(`/task/${taskId}/move`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest' // Identifies this as an AJAX request to Symfony
},
body: JSON.stringify({ status: newStatus })
});
}
}
Now we must tell our HTML to use this controller. We modify the board container and the individual cards.
In templates/board/index.html.twig add the controller to the main wrapper and define the columns as targets:
{# templates/board/index.html.twig #}
...
{# Initialize the drag_drop controller here.
Every element inside this div is now under the controller's purview. #}
<div class="min-h-screen bg-slate-50 p-8" {{ stimulus_controller('drag_drop') }}>
...
{% for status in statuses %}
{#
Mark this div as a column target for Stimulus
and define its status so JS can read it on drop
#}
<div class="flex-1 bg-slate-200 rounded-xl p-4 min-h-[500px]"
data-drag-drop-target="column"
data-status="{{ status.value }}">
...
Next, make the cards draggable and wire up the events in templates/board/_card.html.twig:
{# templates/board/_card.html.twig #}
<turbo-frame id="task-{{ task.id }}">
{#
1. draggable="true" enables the HTML5 API
2. data-task-id stores the ID for the JS to read
3. data-action maps DOM events (dragstart, dragover, drop) to our Stimulus controller methods
#}
<div class="bg-white p-4 rounded shadow mb-3 cursor-move hover:shadow-md transition-shadow"
draggable="true"
data-task-id="{{ task.id }}"
data-action="dragstart->drag_drop#start dragover->drag_drop#over drop->drag_drop#drop">
...
If you refresh your browser now you can pick up a card and drop it into another column! The UI updates instantly. However, if you refresh the page the card snaps back to its original position. We haven’t built the backend endpoint to persist the data yet.
Let’s create the endpoint in our BoardController to handle the POST request sent by our Stimulus controller. We must ensure this endpoint is secure and validates the incoming data.
<?php
// src/Controller/BoardController.php
// ... [previous imports]
use Symfony\Component\HttpFoundation\Request;
use Doctrine\ORM\EntityManagerInterface;
class BoardController extends AbstractController
{
// ... [index method from Part 1]
#[Route('/task/{id}/move', name: 'app_task_move', methods: ['POST'])]
public function moveTask(
Task $task,
Request $request,
EntityManagerInterface $em
): Response {
// Parse the JSON payload sent by fetch()
$data = json_decode($request->getContent(), true);
// Safety First: Attempt to cast the string to our Backed Enum.
// If the client sends an invalid status (e.g., 'deleted'), tryFrom returns null.
$newStatus = \App\Enum\TaskStatus::tryFrom((string)($data['status'] ?? null));
if (!$newStatus) {
return $this->json(['error' => 'Invalid status provided.'], 400);
}
// Update the entity and save to SQLite
$task->setStatus($newStatus);
$em->flush();
return $this->json(['success' => true]);
}
}
Now if you drag a card and refresh the page the change persists! We have a functional, persistent Kanban board.
But we promised real-time collaboration. If User A moves a card, User B (looking at the same board on a different computer) needs to see that card move instantly without refreshing.
To achieve real-time synchronization across different browsers we need two distinct components:
Most developers immediately think of WebSockets for real-time features. However, WebSockets are bidirectional and complex to scale in PHP, requiring persistent daemon processes.
We don’t need bidirectional communication here. The client talks to the server via standard AJAX POST requests. We only need the server to broadcast changes down to the clients. For this Server-Sent Events (SSE) via the Mercure Protocol is far superior.
Mercure is a hub. Your PHP app sends a single HTTP POST request to the Mercure Hub with the payload. The Mercure Hub (which is heavily optimized in Go) maintains the thousands of open SSE connections to the browsers and distributes the payload to them instantly.
A Turbo Stream is simply a small snippet of HTML wrapped in specific tags. It instructs the Turbo library running on the client to perform a specific DOM mutation (append, prepend, replace, remove, update).
For example, to remove task #5 from its old column and append it to the ‘done’ column, the stream we need to broadcast looks like this:
<turbo-stream action="remove" target="task-5"></turbo-stream>
<turbo-stream action="append" target="column-done">
<template>
<!-- The fully rendered HTML of the card goes here -->
</template>
</turbo-stream>
Let’s modify our moveTask method. Once the database is updated successfully we will generate the Turbo Stream HTML using Twig and publish it to the Mercure Hub.
<?php
// src/Controller/BoardController.php
// ... [previous imports]
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Psr\Log\LoggerInterface;
class BoardController extends AbstractController
{
// ... [index method]
#[Route('/task/{id}/move', name: 'app_task_move', methods: ['POST'])]
public function moveTask(
Task $task,
Request $request,
EntityManagerInterface $em,
HubInterface $hub, // Inject the Mercure Hub
LoggerInterface $logger
): Response {
$data = json_decode($request->getContent(), true);
$newStatus = \App\Enum\TaskStatus::tryFrom((string)($data['status'] ?? null));
if (!$newStatus) return $this->json(['error' => 'Invalid status'], 400);
$task->setStatus($newStatus);
$em->flush();
// 1. Render the HTML of the updated card using our existing Twig partial!
// This is the beauty of the stack: we reuse the same templates.
$html = $this->renderView('board/_card.html.twig', [
'task' => $task
]);
// 2. Construct the Turbo Stream payload
$stream = sprintf(
'<turbo-stream action="remove" target="task-%d"></turbo-stream>
<turbo-stream action="append" target="column-%s">
<template>%s</template>
</turbo-stream>',
$task->getId(),
$task->getStatus()->value,
$html
);
// 3. Publish the stream to the Mercure Hub on the 'board' topic
try {
$update = new Update('board', $stream);
$hub->publish($update);
} catch (\Exception $e) {
// Log connection errors (e.g., if the Mercure hub is down in dev)
$logger->warning('Mercure hub not reachable: ' . $e->getMessage());
}
return $this->json(['success' => true]);
}
}
Mercure is secure by design. Browsers cannot just listen to any topic; they must be authorized. Mercure uses JWT (JSON Web Tokens) to handle this.
Before a browser can connect to the Mercure hub, our Symfony app must set a special cookie (mercureAuthorization) containing a JWT that grants permission to subscribe to specific topics. We handle this in our initial index route when the board first loads.
Here is the complete setup in BoardController::index:
<?php
// src/Controller/BoardController.php
...
use Symfony\Component\HttpFoundation\Cookie;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
...
#[Route('/board', name: 'app_board')]
public function index(TaskRepository $taskRepository): Response
{
$response = $this->render('board/index.html.twig', [
'tasks' => $taskRepository->findAll(),
'statuses' => TaskStatus::cases(),
]);
// Generate the JWT authorizing the client to subscribe to the Hub
if (class_exists(Configuration::class)) {
$config = Configuration::forSymmetricSigner(
new Sha256(),
InMemory::plainText($_ENV['MERCURE_JWT_SECRET'])
);
$token = $config->builder()
->withClaim('mercure', ['subscribe' => ['*']]) // Authorize all topics for this demo
->getToken($config->signer(), $config->signingKey())
->toString();
// Set the cookie on the response
$response->headers->setCookie(Cookie::create(
'mercureAuthorization',
$token,
new \DateTime('+1 day'),
'/',
null,
false,
false, // HttpOnly false is required for local debug/Mercure discovery
false,
Cookie::SAMESITE_LAX
));
}
// Inform the client where the Mercure Hub is located
$response->headers->set('Link', sprintf('<%s>; rel="mercure"', $_ENV['MERCURE_PUBLIC_URL']));
return $response;
}
Now, the backend is broadcasting and the browser is authorized. We just need to tell our frontend HTML to establish the connection.
In templates/board/index.html.twig add the turbo_stream_listen Twig helper anywhere inside the body. This helper injects the necessary JavaScript to connect to the Mercure Hub and subscribe to the ‘board’ topic.
{# templates/board/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<div class="min-h-screen bg-slate-50 p-8" {{ stimulus_controller('drag_drop') }}>
{#
Tell Turbo to establish the SSE connection and listen
for Turbo Streams published to the 'board' topic.
#}
<div {{ turbo_stream_listen('board') }}></div>
<div class="max-w-7xl mx-auto">
...
Drag a card in Window A. Watch Window B. The card instantly jumps to the new column, matching Window A.
You have just built a real-time collaborative application.
Let’s review what we have accomplished without writing a single line of a heavy JS framework:
This is the power of the modern Symfony UX ecosystem. It allows developers to build highly interactive, “SPA-like” applications with a fraction of the complexity, maintaining the developer experience, security and rendering speed of a traditional server-side application. The HTML-over-the-wire revolution is here and Symfony is leading the charge in the PHP world.
Source Code: You can find the full implementation and follow the project’s progress on GitHub: [https://github.com/mattleads/symfony-kanban]
If you found this helpful or have questions about the implementation, I’d love to hear from you. Let’s stay in touch and keep the conversation going across these platforms: