DEV Community

Cover image for How to build a reactive SPA without writing a single line of React or Vue. Part #1
Matt Mochalkin
Matt Mochalkin

Posted on

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

For the last decade, the web development industry has been dominated by a singular architectural pattern - the Single Page Application (SPA). The recipe was standard — build a JSON API (perhaps with API Platform, Laravel or Express) and consume it with a heavy JavaScript framework like React, Vue or Angular.

This approach brought undeniable benefits. It enabled highly interactive app-like experiences in the browser. It decoupled the frontend from the backend, theoretically allowing different teams to work independently. However, as the dust settles on the “Golden Age of JS Frameworks” a growing segment of the developer community is waking up to what we call the “JavaScript Tax”.

Understanding the Burden of the SPA

Building SPAs requires duplicating logic. You define your routing in PHP and then again in React Router. You write data validation rules in your Symfony Form or Entity attributes and then you write them again in your frontend using Yup or Zod. You define your data models in Doctrine and then you define TypeScript interfaces that perfectly mirror them.

Furthermore, it demands complex build pipelines. We spent years fighting Webpack, Babel, Rollup and now Vite configurations. It introduces state management nightmares — deciding between Redux, Vuex, Context API or Zustand to manage data that already exists perfectly well in our database. And it often results in bloated JavaScript bundles that degrade performance on mobile devices, leading to the invention of complex workarounds like Server-Side Rendering (SSR) meta-frameworks (Next.js, Nuxt) just to get SEO and initial load times back to where they were in 2010.

But what if we could have the best of both worlds? What if we could deliver the snappy, instantaneous, page-refresh-free experience of a modern SPA, while keeping all our business logic firmly rooted in our backend language of choice, maintaining a Single Source of Truth?

Enter the “HTML-over-the-wire” approach, pioneered by Hotwire (from the creators of Basecamp and Ruby on Rails) and beautifully integrated into the Symfony ecosystem via Symfony UX.

In this comprehensive, two-part series, we are going to build a fully functional, real-time, collaborative Kanban Board (think Trello) using Symfony 7.4. By the end of this guide, you will have a highly interactive application with native drag-and-drop and real-time WebSocket-like syncing across multiple browsers.

And the catch? We will not write a single line of React or Vue. We will rely entirely on PHP, Twig and a few sprinkles of lightweight JavaScript via Stimulus.

The Architecture — HTML over the Wire Explained

Before we write code, we must fundamentally understand the paradigm shift. In a traditional SPA, the server sends raw data (usually JSON) and the client (the JavaScript framework) is responsible for parsing that data, combining it with templates and turning it into HTML.

In the HTML-over-the-wire paradigm, the server sends fully rendered HTML.

When a user interacts with the application (e.g., clicks a button, submits a form), an AJAX request is sent to the server. The server processes the request, runs the business logic, renders a tiny snippet of Twig (a “partial”) and sends that HTML snippet back over the wire.

A lightweight, invisible JavaScript library on the client (Turbo) intercepts this response, looks at the HTML tags and seamlessly swaps out the relevant part of the DOM, without refreshing the entire page.

The mental model is liberating - you build your app almost exactly like a traditional server-rendered PHP application and the UX components magically upgrade it to an SPA.

The Modern Symfony UX Stack

Our stack for this project represents the absolute cutting edge of the Symfony ecosystem. We are leaving the old tools behind:

  1. Symfony 7.4: The robust foundation, handling routing, database interactions (Doctrine) and dependency injection.
  2. Symfony AssetMapper: The death of Webpack Encore. AssetMapper allows us to use modern JavaScript (ES Modules) and CSS directly in the browser without any Node.js build step. It relies on modern browser features like HTTP/2 multiplexing and import maps.
  3. Symfony UX Turbo: The heart of our SPA feel. It provides:
  • Turbo Drive - Intercepts standard links and forms to prevent full page reloads.
  • Turbo Frames - Isolates parts of the page for independent, lazy-loaded updates.
  • Turbo Streams - Pushes targeted DOM mutations (append, prepend, remove, replace) directly from the server.
  1. Stimulus: A modest JavaScript framework for the HTML you already have. We use it when we must have client-side interactivity (like the HTML5 Drag and Drop API) that doesn’t require a server trip.
  2. Mercure: An open-source protocol built on Server-Sent Events (SSE) for real-time communications. This will allow our board to sync across multiple users instantly without the massive overhead of managing WebSockets in PHP.
  3. Tailwind CSS: For rapid, utility-first styling, integrated via the Symfony Tailwind bundle — meaning, once again, zero Node.js dependencies.

Let’s start building.

Prerequisites and Environment

We begin by scaffolding a new Symfony web application. Ensure you have PHP 8.3+ and the Symfony CLI installed. The Symfony CLI is highly recommended here because it comes with a built-in Mercure Hub for local development.

symfony new symfony-kanban --webapp --version="7.4.*"
cd symfony-kanban
Enter fullscreen mode Exit fullscreen mode

The “-- webapp” flag gives us a complete web stack, including Twig, Doctrine and the basic structural boilerplate we need. Next, we install the crucial UX and real-time components:

composer require symfony/ux-turbo symfony/stimulus-bundle symfony/mercure-bundle symfony/asset-mapper
Enter fullscreen mode Exit fullscreen mode

Symfony Flex will automatically configure these bundles, setting up importmap.php for AssetMapper and the b*asic configuration for Mercure in config/packages/mercure.yaml*.

Styling with Tailwind CSS

To make our Kanban board look modern without writing endless custom CSS files, we’ll use Tailwind. Thanks to the symfonycasts/tailwind-bundle, we can use the standalone Tailwind CLI binary. This means we don’t need npm, yarn or a package.json file.

composer require symfonycasts/tailwind-bundle
php bin/console tailwind:init
Enter fullscreen mode Exit fullscreen mode

This generates an assets/styles/app.css and a tailwind.config.js.

To compile the CSS during development, you simply run a console command in a separate terminal tab:

php bin/console tailwind:build --watch
Enter fullscreen mode Exit fullscreen mode

Database Configuration (SQLite)

For the sake of simplicity, zero-configuration setup and immediate gratification we will use SQLite. Edit your .env file to comment out the default PostgreSQL line and uncomment the SQLite line:

# .env
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
Enter fullscreen mode Exit fullscreen mode

Create the database file and initial schema structures:

php bin/console doctrine:database:create
Enter fullscreen mode Exit fullscreen mode

Domain Modeling — The Power of Enums

A Kanban board is essentially a visual state machine for tasks. A task has a title and a state (its current column/status).

One of the most powerful features introduced in recent PHP versions (8.1+) is Backed Enums. Enums allow us to strictly type the status of our tasks, preventing “magic strings” (e.g., misspelling ‘in_progress’ as ‘in-progress’ in one part of the codebase) and making our code incredibly robust, readable and refactor-friendly.

Let’s create our TaskStatus enum first.

// src/Enum/TaskStatus.php

namespace App\Enum;

enum TaskStatus: string
{
    case TODO = 'todo';
    case IN_PROGRESS = 'in_progress';
    case DONE = 'done';

    /**
     * A helper method to get a human-readable label for the UI
     * This keeps presentation logic close to the data, but out of Twig.
     */
    public function getLabel(): string
    {
        return match($this) {
            self::TODO => 'To Do',
            self::IN_PROGRESS => 'In Progress',
            self::DONE => 'Done',
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let’s generate the Task entity that will represent the cards on our board:

php bin/console make:entity Task
Enter fullscreen mode Exit fullscreen mode

Follow the interactive prompts:

  1. Property name: title, Type: string, Length: 255, Nullable: no.
  2. Property name: status, Type: string, Length: 50, Nullable: no.

Now, we need to manually update the generated Task.php to use our new Enum. Doctrine ORM natively supports mapping database columns directly to PHP Enums.

// src/Entity/Task.php

namespace App\Entity;

use App\Enum\TaskStatus;
use App\Repository\TaskRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: TaskRepository::class)]
class Task
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $title = null;

    // Use the Enum type here! Doctrine handles the serialization.
    #[ORM\Column(length: 50, enumType: TaskStatus::class)]
    private TaskStatus $status = TaskStatus::TODO;

    // ... getters and setters ...

    public function getStatus(): TaskStatus
    {
        return $this->status;
    }

    public function setStatus(TaskStatus $status): static
    {
        $this->status = $status;
        return $this;
    }
}
Enter fullscreen mode Exit fullscreen mode

By mapping the database column directly to TaskStatus::class, Doctrine automatically handles the serialization (converting TaskStatus::TODO to the string “todo” for SQLite) and deserialization (converting the string “todo” back to the TaskStatus::TODO object when querying the database).

Run the migrations to create the actual table in your SQLite database:

php bin/console make:migration
php bin/console doctrine:migrations:migrate -n
Enter fullscreen mode Exit fullscreen mode

Seeding Dummy Data

To visualize our board and test our layouts, we need some initial data. Let’s install the Doctrine fixtures bundle:

composer require --dev orm-fixtures
Enter fullscreen mode Exit fullscreen mode

Edit src/DataFixtures/AppFixtures.php to generate a few starter tasks:

// src/DataFixtures/AppFixtures.php

namespace App\DataFixtures;

use App\Entity\Task;
use App\Enum\TaskStatus;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $tasks = [
            ['title' => 'Learn Symfony UX', 'status' => TaskStatus::DONE],
            ['title' => 'Setup AssetMapper', 'status' => TaskStatus::DONE],
            ['title' => 'Write Drag & Drop logic', 'status' => TaskStatus::IN_PROGRESS],
            ['title' => 'Configure Mercure Hub', 'status' => TaskStatus::TODO],
            ['title' => 'Deploy to Production', 'status' => TaskStatus::TODO],
        ];

        foreach ($tasks as $data) {
            $task = new Task();
            $task->setTitle($data['title']);
            $task->setStatus($data['status']);
            $manager->persist($task);
        }

        $manager->flush();
    }
}
Enter fullscreen mode Exit fullscreen mode

Load the data into the database:

php bin/console doctrine:fixtures:load -n
Enter fullscreen mode Exit fullscreen mode

The Board Controller and Static UI

We have our data model secured. Now we need to fetch it and display it.

We will create a standard Symfony controller that fetches all tasks and passes them to a Twig template. Crucially, we will also pass the TaskStatus::cases() array to the template.

This is a vital architectural decision - by iterating over the Enum cases in our Twig template our board dynamically generates its columns based on the PHP code. If your product manager asks you to add a “In Review” status next month you simply add case REVIEW = ‘review’ to your Enum. The UI updates automatically, adding a new column, without you needing to touch a single line of HTML or JavaScript!

// src/Controller/BoardController.php

namespace App\Controller;

use App\Enum\TaskStatus;
use App\Repository\TaskRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class BoardController extends AbstractController
{
    #[Route('/board', name: 'app_board')]
    public function index(TaskRepository $taskRepository): Response
    {
        return $this->render('board/index.html.twig', [
            'tasks' => $taskRepository->findAll(),
            'statuses' => TaskStatus::cases(), // Pass the enum cases to dynamically build columns
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Building the Twig Templates

We will embrace component-based design by splitting our UI into two files: the main board layout (index.html.twig) and a reusable partial for the individual task cards (_card.html.twig).

First, let’s look at templates/board/_card.html.twig. We encapsulate the card’s markup in a tag. While we aren’t heavily using frames for navigation in this specific app, wrapping individual, atomic components in Turbo Frames is a fantastic habit. It provides a unique ID that Turbo can target later to replace or remove this specific chunk of HTML seamlessly.

{# templates/board/_card.html.twig #}
<turbo-frame id="task-{{ task.id }}">
    <div class="bg-white p-4 rounded shadow mb-3 border border-slate-200"
         id="card-{{ task.id }}">

        <div class="flex justify-between items-center">
            <span class="text-slate-800 font-medium">{{ task.title }}</span>
            <span class="text-xs text-slate-400 font-mono">#{{ task.id }}</span>
        </div>

    </div>
</turbo-frame>
Enter fullscreen mode Exit fullscreen mode

Now, the main board layout templates/board/index.html.twig. Notice how we loop through the statuses array to build the columns and then use Twig’s filter to find the tasks belonging to that column.

{# templates/board/index.html.twig #}
{% extends 'base.html.twig' %}

{% block title %}Kanban Board{% endblock %}

{% block body %}
<div class="min-h-screen bg-slate-100 p-8">
    <div class="max-w-7xl mx-auto">

        <h1 class="text-3xl font-bold mb-8 text-slate-800">Collaborative Kanban</h1>

        {# The Flexbox Board Container #}
        <div class="flex space-x-6 items-start">

            {# Dynamically generate columns based on the PHP Enum #}
            {% for status in statuses %}
                <div class="flex-1 bg-slate-200/60 rounded-xl p-4 min-h-[500px] border border-slate-300 shadow-inner">

                    {# Column Header calling the getLabel() method on our Enum #}
                    <h2 class="text-sm font-bold mb-4 uppercase text-slate-600 tracking-wider flex justify-between items-center">
                        {{ status.label }}
                        <span class="bg-slate-300 text-slate-700 py-1 px-2 rounded-full text-xs">
                            {{ tasks|filter(t => t.status == status)|length }}
                        </span>
                    </h2>

                    {# The dropzone for cards #}
                    <div class="space-y-3 min-h-[100px]" id="column-{{ status.value }}">

                        {# Filter tasks for this specific column and render the partial #}
                        {% for task in tasks|filter(t => t.status == status) %}
                            {% include 'board/_card.html.twig' with {task: task} %}
                        {% endfor %}

                    </div>
                </div>
            {% endfor %}

        </div>
    </div>
</div>
{% endblock %}
Enter fullscreen mode Exit fullscreen mode

Reviewing the Static State

If you run symfony serve -d (which spins up the PHP web server and the Mercure hub simultaneously) and visit http://127.0.0.1:8000/board, you will see a beautifully styled Kanban board. The tasks from our fixtures are perfectly distributed into the “To Do”, “In Progress” and “Done” columns.

However, right now, it is completely static. It is a traditional Multi-Page Application (MPA) view. You cannot interact with it. You cannot drag the cards. If you want to move a task, you’d have to edit the database manually.

Looking Ahead to Part 2

We have laid an incredibly solid foundation. We have a robust data model utilizing PHP 8.3 Enums, ensuring strict type safety. We have a clean, dynamic Twig layout styled with Tailwind CSS, delivered efficiently without a massive Webpack build chain or NPM dependencies.

We have successfully avoided writing complex client-side models, separate validation logic or API endpoints. Our backend is our frontend.

But a Kanban board is useless if you can’t move the cards.

In Part 2, we will bring this board to life. We will write a tiny, ~40-line Stimulus controller to tap into the native HTML5 drag-and-drop API. We will learn how to intercept drops, make background AJAX requests to Symfony and crucially, how to use Turbo Streams and Mercure to instantly broadcast that movement to every other user looking at the board, creating a truly collaborative, reactive SPA experience.

And I’ll link the GitHub repo, of course, so you can test out the app on your own.

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:

Top comments (0)