How to build a reactive SPA without writing a single line of React or Vue. Part #2

How to build a reactive SPA without writing a single line of React or Vue. Part #2

# symfony# php# fullstack# productivity
How to build a reactive SPA without writing a single line of React or Vue. Part #2Matt Mochalkin

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

  1. Stimulus: A tiny JavaScript framework designed to augment your HTML. We will use it to interact with the native HTML5 drag-and-drop API and perform an “Optimistic UI” update.
  2. Symfony Controller: A standard PHP endpoint to receive the move request and update the SQLite database.
  3. Turbo Streams & Mercure: The magic bullet. The server will render the updated HTML and broadcast it via Server-Sent Events (SSE) to all connected clients, instructing their DOMs to update automatically.

Let’s dive into the code.

Sprinkling Interactivity with Stimulus

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 })
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Hooking Stimulus into Twig

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 }}">
                     ...
Enter fullscreen mode Exit fullscreen mode

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">
        ...
Enter fullscreen mode Exit fullscreen mode

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.

The Backend - Processing the Move Safely

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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Turbo Streams and Mercure - The Real-time Magic

To achieve real-time synchronization across different browsers we need two distinct components:

  1. A transport protocol: A way to push data from the server to the browser without the browser asking for it.
  2. A payload format: A standardized way for the browser to understand how to update the DOM when it receives that pushed data.

Transport - Server-Sent Events (SSE) vs WebSockets

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.

Payload - The Anatomy of a Turbo Stream

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>
Enter fullscreen mode Exit fullscreen mode

Publishing the Stream to the Hub

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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Securing the Connection and Listening on the Client

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;
    }
Enter fullscreen mode Exit fullscreen mode

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">
    ...
Enter fullscreen mode Exit fullscreen mode

The Magic Moment

  1. Open your browser to http://127.0.0.1:8000/board.
  2. Open an incognito window or a different browser and navigate to the same URL positioning the windows side-by-side.

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.

Conclusion

Let’s review what we have accomplished without writing a single line of a heavy JS framework:

  1. Zero-Build Frontend: We used AssetMapper to serve native ES modules and Tailwind CSS. No node_modules, no Webpack configurations, no build times.
  2. Optimistic UI: We used ~40 lines of modest Stimulus code to handle the notoriously tricky HTML5 drag-and-drop API providing a completely latency-free experience for the active user.
  3. Single Source of Truth: Our logic (Task Status validation, rendering, data models) lives entirely in PHP. We don’t have a duplicated Task model in TypeScript on the frontend nor do we duplicate validation logic.
  4. Real-time Collaboration: We used Mercure and Turbo Streams to broadcast DOM mutations over highly efficient Server-Sent Events. The browser automatically applies these mutations without writing any custom WebSocket handling logic.

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]

Let’s Connect!

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: