Skip to main content

Command Palette

Search for a command to run...

Building a Real-Time Collaborative Kanban Board with Laravel + Y.js

Updated
15 min read
Building a Real-Time Collaborative Kanban Board with Laravel + Y.js

Building a Real-Time Collaborative Kanban Board with Laravel + Y.js

Series: Local-First Type: Tutorial Meta Description: Build a Trello-like collaborative kanban board with real-time drag-and-drop using Laravel WebSockets and Y.js CRDT sync. Complete tutorial with code. Keywords: kanban board, Laravel, Y.js, WebSockets, real-time collaboration, drag-and-drop


A kanban board is the perfect project to learn real-time collaboration. Multiple users drag cards between columns, rename tasks, reorder priorities, and add comments — all simultaneously. Getting this right with traditional REST APIs means managing race conditions, conflict resolution, and stale state. With Y.js CRDTs, the hard problems disappear into the library.

This tutorial builds a fully collaborative kanban board. Multiple users can drag cards between columns, edit titles, and reorder tasks. Every change syncs instantly across all connected browsers. If two users drag the same card to different columns at the same time, Y.js resolves the conflict automatically. No lost work, no error states, no "someone else modified this" dialogs.

The stack: Laravel 11 as the WebSocket server and persistence layer, Vue 3 for the UI, Y.js for CRDT-based state management, and y-websocket for real-time sync.

How Y.js Collaborative State Works

Before writing code, understand the Y.js mental model. A Y.js document is a shared data structure. Every client holds a replica. Changes are encoded as operations that can be applied in any order and produce the same result.

For a kanban board, the data model looks like this:

  • A Y.Map holds the board metadata (name, settings).
  • A Y.Array holds the columns in order.
  • Each column is a Y.Map with a title (Y.Text) and a Y.Array of card IDs.
  • Each card is a Y.Map with title (Y.Text), description (Y.Text), and metadata.

When User A drags a card from "To Do" to "In Progress," Y.js generates an operation: "remove card X from array at position N, insert card X into array at position M." When User B simultaneously reorders cards within "To Do," Y.js generates a different operation. Both operations commute — applying them in either order produces the same board state.

Step 1: Set Up the Laravel Project

composer create-project laravel/laravel kanban-collab
cd kanban-collab

Install Laravel Reverb for WebSocket support (included by default in Laravel 11+). If not already installed:

php artisan install:broadcasting

Choose Reverb when prompted. This sets up the WebSocket server and the frontend Echo configuration.

Database Migration

// database/migrations/2026_01_01_000000_create_boards_table.php

Schema::create('boards', function (Blueprint $table) {
    $table->id();
    $table->uuid('uuid')->unique();
    $table->string('name');
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->longText('yjs_state')->nullable(); // Stores the binary Y.js document state
    $table->timestamps();
});

Schema::create('board_members', function (Blueprint $table) {
    $table->id();
    $table->foreignId('board_id')->constrained()->onDelete('cascade');
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->enum('role', ['owner', 'editor', 'viewer'])->default('editor');
    $table->timestamps();
    $table->unique(['board_id', 'user_id']);
});

The yjs_state column stores the full Y.js document state as a binary blob. This allows new users to load the current board state without replaying every historical operation.

Board Model

// app/Models/Board.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;

class Board extends Model
{
    protected $fillable = ['uuid', 'name', 'user_id', 'yjs_state'];
    protected $casts = ['yjs_state' => 'binary'];

    protected static function booted()
    {
        static::creating(function (Board $board) {
            $board->uuid = $board->uuid ?? Str::uuid()->toString();
        });
    }

    public function members()
    {
        return $this->belongsToMany(User::class, 'board_members')
            ->withPivot('role')
            ->withTimestamps();
    }

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Broadcasting Authorization

Users should only receive WebSocket events for boards they belong to.

// routes/channels.php

use App\Models\Board;

Broadcast::channel('board.{boardUuid}', function ($user, $boardUuid) {
    $board = Board::where('uuid', $boardUuid)->first();
    return $board && $board->members()->where('user_id', $user->id)->exists();
});

Y.js State Persistence Endpoint

When clients disconnect, the server needs to persist the final Y.js state. Create an endpoint for this.

// app/Http/Controllers/BoardController.php

namespace App\Http\Controllers;

use App\Models\Board;
use Illuminate\Http\Request;

class BoardController extends Controller
{
    public function show(Request $request, $uuid)
    {
        $board = Board::where('uuid', $uuid)
            ->whereHas('members', fn($q) => $q->where('user_id', $request->user()->id))
            ->firstOrFail();

        return response()->json([
            'uuid' => $board->uuid,
            'name' => $board->name,
            'yjs_state' => $board->yjs_state
                ? base64_encode($board->yjs_state)
                : null,
        ]);
    }

    public function storeState(Request $request, $uuid)
    {
        $validated = $request->validate([
            'state' => 'required|string', // Base64-encoded Y.js document state
        ]);

        $board = Board::where('uuid', $uuid)
            ->whereHas('members', fn($q) =>
                $q->where('user_id', $request->user()->id)
                  ->where('role', '!=', 'viewer')
            )
            ->firstOrFail();

        $board->yjs_state = base64_decode($validated['state']);
        $board->save();

        return response()->json(['saved' => true]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'name' => 'required|string|max:255',
        ]);

        $board = Board::create([
            'name' => $validated['name'],
            'user_id' => $request->user()->id,
        ]);

        $board->members()->attach($request->user()->id, ['role' => 'owner']);

        return response()->json([
            'uuid' => $board->uuid,
            'name' => $board->name,
        ], 201);
    }
}
// routes/api.php
use App\Http\Controllers\BoardController;

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/boards', [BoardController::class, 'store']);
    Route::get('/boards/{uuid}', [BoardController::class, 'show']);
    Route::put('/boards/{uuid}/state', [BoardController::class, 'storeState']);
});

Step 2: The Custom Y.js WebSocket Backend

Y.js needs a WebSocket server that understands its sync protocol. The server stores the document state and relays updates between clients. Create a custom WebSocket handler using Laravel Reverb.

Instead of building a full Y.js protocol server from scratch, use the y-websocket server package and run it alongside Laravel.

npm init -y
npm install y-websocket lib0 ws

Create a simple WebSocket server that persists to the Laravel database.

// server.js

import { setupWSConnection } from 'y-websocket/bin/utils.js';
import { WebSocketServer } from 'ws';
import http from 'http';
import { Doc, encodeStateAsUpdate, applyUpdate } from 'yjs';

const wss = new WebSocketServer({ port: 1234 });
const docs = new Map(); // boardUuid -> Y.Doc

// Load existing state from Laravel API
async function loadDoc(boardUuid) {
    if (docs.has(boardUuid)) return docs.get(boardUuid);

    const doc = new Doc();

    try {
        const response = await fetch(`http://localhost:8000/api/boards/${boardUuid}`, {
            headers: { 'Authorization': 'Bearer internal-key' }
        });
        const data = await response.json();
        if (data.yjs_state) {
            const state = Uint8Array.from(atob(data.yjs_state), c => c.charCodeAt(0));
            applyUpdate(doc, state);
        }
    } catch (e) {
        console.error('Failed to load board state:', e);
    }

    docs.set(boardUuid, doc);
    return doc;
}

// Persist state to Laravel periodically
async function persistDoc(boardUuid, doc) {
    const state = Buffer.from(encodeStateAsUpdate(doc)).toString('base64');
    try {
        await fetch(`http://localhost:8000/api/boards/${boardUuid}/state`, {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer internal-key',
            },
            body: JSON.stringify({ state }),
        });
    } catch (e) {
        console.error('Failed to persist board state:', e);
    }
}

wss.on('connection', async (ws, req) => {
    const boardUuid = req.url.slice(1); // URL path = board UUID

    // Authenticate the connection
    const url = new URL(req.url, 'ws://localhost');
    const token = url.searchParams.get('token');
    if (!token) {
        ws.close(4001, 'Unauthorized');
        return;
    }

    const doc = await loadDoc(boardUuid);
    setupWSConnection(ws, doc);

    // Persist on changes (debounced)
    let persistTimeout = null;
    doc.on('update', () => {
        if (persistTimeout) clearTimeout(persistTimeout);
        persistTimeout = setTimeout(() => {
            persistDoc(boardUuid, doc);
        }, 5000); // Persist every 5 seconds when changes occur
    });

    ws.on('close', () => {
        // Persist immediately on disconnect
        persistDoc(boardUuid, doc);
    });
});

console.log('Y.js WebSocket server running on ws://localhost:1234');

Run both servers:

# Terminal 1: Laravel
php artisan serve

# Terminal 2: Y.js WebSocket
node server.js

Step 3: The Vue.js Frontend

Set up the Vue project.

npm create vue@latest kanban-frontend
cd kanban-frontend
npm install yjs y-websocket

The Y.js Board Store

Create a composable that manages the Y.js document and exposes reactive state.

// src/composables/useBoard.js

import { ref, shallowRef, onUnmounted } from 'vue';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

export function useBoard(boardUuid, authToken) {
    const ydoc = new Y.Doc();
    const connected = ref(false);
    const syncing = ref(true);
    const users = ref([]);

    // Connect to the Y.js WebSocket server
    const wsProvider = new WebsocketProvider(
        `ws://localhost:1234`,
        boardUuid,
        ydoc,
        {
            params: { token: authToken },
            connect: true,
        }
    );

    wsProvider.on('status', ({ status }) => {
        connected.value = status === 'connected';
        syncing.value = status !== 'connected';
    });

    wsProvider.awareness.on('change', () => {
        const states = wsProvider.awareness.getStates();
        users.value = [...states.entries()].map(([clientId, state]) => ({
            clientId,
            ...state,
        }));
    });

    // Define the shared data structure
    const yColumns = ydoc.getArray('columns');
    const yCards = ydoc.getMap('cards');

    // Observe changes and trigger Vue reactivity
    const columns = shallowRef([]);
    const cards = shallowRef({});

    function updateColumns() {
        columns.value = yColumns.toArray().map(col => ({
            id: col.get('id'),
            title: col.get('title').toString(),
            cardIds: col.get('cardIds').toArray().map(id => id),
            yMap: col,
        }));
    }

    function updateCards() {
        const result = {};
        yCards.forEach((value, key) => {
            result[key] = {
                id: value.get('id'),
                title: value.get('title').toString(),
                description: value.get('description').toString(),
                color: value.get('color'),
                assignee: value.get('assignee'),
                yMap: value,
            };
        });
        cards.value = result;
    }

    yColumns.observe(() => {
        updateColumns();
        updateCards();
    });
    yCards.observe(() => updateCards());

    // Initialize with default columns if empty
    ydoc.transact(() => {
        if (yColumns.length === 0) {
            createColumn('To Do');
            createColumn('In Progress');
            createColumn('Done');
        }
    });

    // --- Actions ---

    function createColumn(title) {
        const colId = crypto.randomUUID();
        const col = new Y.Map();
        col.set('id', colId);
        col.set('title', new Y.Text(title));
        col.set('cardIds', new Y.Array());
        yColumns.push([col]);
        return colId;
    }

    function deleteColumn(columnId) {
        const index = yColumns.toArray().findIndex(
            col => col.get('id') === columnId
        );
        if (index !== -1) {
            // Remove all cards in this column
            const col = yColumns.get(index);
            const cardIds = col.get('cardIds').toArray();
            ydoc.transact(() => {
                cardIds.forEach(cardId => yCards.delete(cardId));
                yColumns.delete(index);
            });
        }
    }

    function renameColumn(columnId, newTitle) {
        const col = yColumns.toArray().find(c => c.get('id') === columnId);
        if (col) {
            const title = col.get('title');
            title.delete(0, title.length);
            title.insert(0, newTitle);
        }
    }

    function createCard(columnId, title, description = '') {
        const cardId = crypto.randomUUID();
        const col = yColumns.toArray().find(c => c.get('id') === columnId);
        if (!col) return null;

        const card = new Y.Map();
        card.set('id', cardId);
        card.set('title', new Y.Text(title));
        card.set('description', new Y.Text(description));
        card.set('color', '');
        card.set('assignee', '');
        card.set('createdAt', Date.now());

        ydoc.transact(() => {
            yCards.set(cardId, card);
            col.get('cardIds').push([cardId]);
        });

        return cardId;
    }

    function deleteCard(cardId) {
        // Remove from all columns
        yColumns.toArray().forEach(col => {
            const cardIds = col.get('cardIds');
            for (let i = cardIds.length - 1; i >= 0; i--) {
                if (cardIds.get(i) === cardId) {
                    cardIds.delete(i);
                }
            }
        });
        yCards.delete(cardId);
    }

    function moveCard(cardId, fromColumnId, toColumnId, toIndex) {
        const fromCol = yColumns.toArray().find(c => c.get('id') === fromColumnId);
        const toCol = yColumns.toArray().find(c => c.get('id') === toColumnId);
        if (!fromCol || !toCol) return;

        const fromCardIds = fromCol.get('cardIds');
        const toCardIds = toCol.get('cardIds');

        ydoc.transact(() => {
            // Remove from source
            const fromIndex = fromCardIds.toArray().indexOf(cardId);
            if (fromIndex !== -1) {
                fromCardIds.delete(fromIndex);
            }

            // Insert at destination
            const clampedIndex = Math.min(toIndex, toCardIds.length);
            toCardIds.insert(clampedIndex, [cardId]);
        });
    }

    function updateCardTitle(cardId, newTitle) {
        const card = yCards.get(cardId);
        if (!card) return;
        const title = card.get('title');
        title.delete(0, title.length);
        title.insert(0, newTitle);
    }

    function updateCardDescription(cardId, newDescription) {
        const card = yCards.get(cardId);
        if (!card) return;
        const desc = card.get('description');
        desc.delete(0, desc.length);
        desc.insert(0, newDescription);
    }

    function setAwarenessUser(user) {
        wsProvider.awareness.setLocalStateField('user', {
            name: user.name,
            color: user.color,
        });
    }

    // Cleanup
    onUnmounted(() => {
        wsProvider.destroy();
        ydoc.destroy();
    });

    return {
        connected,
        syncing,
        users,
        columns,
        cards,
        createColumn,
        deleteColumn,
        renameColumn,
        createCard,
        deleteCard,
        moveCard,
        updateCardTitle,
        updateCardDescription,
        setAwarenessUser,
    };
}

Step 4: The Kanban Board Component

Build the main board component with drag-and-drop support.

<!-- src/components/KanbanBoard.vue -->

<template>
  <div class="kanban-board">
    <!-- Connection status indicator -->
    <div class="status-bar">
      <span :class="['status-dot', connected ? 'online' : 'offline']"></span>
      <span>{{ connected ? 'Connected' : 'Offline — changes saved locally' }}</span>
      <span v-if="users.length > 1" class="user-count">
        {{ users.length }} users online
      </span>
    </div>

    <!-- Board columns -->
    <div class="columns-container">
      <div
        v-for="column in columns"
        :key="column.id"
        class="column"
        @dragover.prevent="onDragOver($event, column.id)"
        @drop="onDrop($event, column.id)"
      >
        <div class="column-header">
          <input
            class="column-title"
            :value="column.title"
            @change="renameColumn(column.id, $event.target.value)"
          />
          <button class="add-card-btn" @click="addCard(column.id)">+</button>
          <button class="delete-column-btn" @click="deleteColumn(column.id)">x</button>
        </div>

        <div class="cards-list">
          <div
            v-for="cardId in column.cardIds"
            :key="cardId"
            class="card"
            draggable="true"
            @dragstart="onDragStart($event, cardId, column.id)"
          >
            <template v-if="cards[cardId]">
              <input
                class="card-title"
                :value="cards[cardId].title"
                @change="updateCardTitle(cardId, $event.target.value)"
              />
              <textarea
                class="card-description"
                :value="cards[cardId].description"
                @change="updateCardDescription(cardId, $event.target.value)"
                rows="2"
              ></textarea>
              <button class="delete-card-btn" @click="deleteCard(cardId)">
                Delete
              </button>
            </template>
          </div>
        </div>
      </div>

      <!-- Add column button -->
      <div class="add-column" @click="addColumn">
        + Add Column
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useBoard } from '../composables/useBoard.js';

const props = defineProps({
  boardUuid: String,
  authToken: String,
  userName: String,
});

const {
  connected,
  syncing,
  users,
  columns,
  cards,
  createColumn,
  deleteColumn,
  renameColumn,
  createCard,
  deleteCard,
  moveCard,
  updateCardTitle,
  updateCardDescription,
  setAwarenessUser,
} = useBoard(props.boardUuid, props.authToken);

// Set the current user's awareness info
setAwarenessUser({
  name: props.userName,
  color: `hsl(${Math.random() * 360}, 70%, 50%)`,
});

// Drag and drop state
const draggedCardId = ref(null);
const draggedFromColumnId = ref(null);

function onDragStart(event, cardId, columnId) {
  draggedCardId.value = cardId;
  draggedFromColumnId.value = columnId;
  event.dataTransfer.effectAllowed = 'move';
}

function onDragOver(event, columnId) {
  event.dataTransfer.dropEffect = 'move';
}

function onDrop(event, toColumnId) {
  const cardId = draggedCardId.value;
  const fromColumnId = draggedFromColumnId.value;
  if (!cardId || !fromColumnId) return;

  // Determine the insert index based on drop position
  const toColumn = columns.value.find(c => c.id === toColumnId);
  const toIndex = toColumn ? toColumn.cardIds.length : 0;

  moveCard(cardId, fromColumnId, toColumnId, toIndex);
  draggedCardId.value = null;
  draggedFromColumnId.value = null;
}

function addCard(columnId) {
  const title = prompt('Card title:');
  if (title) {
    createCard(columnId, title);
  }
}

function addColumn() {
  const title = prompt('Column name:');
  if (title) {
    createColumn(title);
  }
}
</script>

<style scoped>
.kanban-board {
  min-height: 100vh;
  background: #1a1a2e;
  color: #e0e0e0;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

.status-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 20px;
  font-size: 14px;
  color: #888;
}

.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
}
.status-dot.online { background: #4caf50; }
.status-dot.offline { background: #ff9800; }

.user-count {
  margin-left: auto;
  background: #2a2a4e;
  padding: 4px 10px;
  border-radius: 12px;
  font-size: 12px;
}

.columns-container {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  padding-bottom: 20px;
}

.column {
  min-width: 280px;
  max-width: 320px;
  background: #16213e;
  border-radius: 8px;
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.column-header {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 4px;
}

.column-title {
  flex: 1;
  background: transparent;
  border: none;
  color: #fff;
  font-size: 16px;
  font-weight: 600;
  padding: 4px;
  outline: none;
}
.column-title:hover { background: rgba(255,255,255,0.05); }
.column-title:focus { background: rgba(255,255,255,0.1); }

.add-card-btn, .delete-column-btn {
  background: none;
  border: none;
  color: #888;
  cursor: pointer;
  font-size: 16px;
  padding: 4px 8px;
  border-radius: 4px;
}
.add-card-btn:hover, .delete-column-btn:hover {
  background: rgba(255,255,255,0.1);
  color: #fff;
}

.cards-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  min-height: 40px;
}

.card {
  background: #0f3460;
  border-radius: 6px;
  padding: 10px;
  cursor: grab;
  transition: transform 0.15s, box-shadow 0.15s;
}
.card:active { cursor: grabbing; }
.card:hover {
  transform: translateY(-1px);
  box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}

.card-title {
  width: 100%;
  background: transparent;
  border: none;
  color: #fff;
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 4px;
  outline: none;
}
.card-title:focus {
  background: rgba(255,255,255,0.05);
  border-radius: 3px;
}

.card-description {
  width: 100%;
  background: transparent;
  border: none;
  color: #aaa;
  font-size: 12px;
  resize: none;
  outline: none;
}

.delete-card-btn {
  background: none;
  border: none;
  color: #666;
  font-size: 11px;
  cursor: pointer;
  margin-top: 4px;
}
.delete-card-btn:hover { color: #e74c3c; }

.add-column {
  min-width: 280px;
  height: 60px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255,255,255,0.05);
  border: 2px dashed #333;
  border-radius: 8px;
  cursor: pointer;
  color: #888;
  font-size: 14px;
}
.add-column:hover {
  background: rgba(255,255,255,0.08);
  color: #bbb;
}
</style>

Step 5: User Presence with Awareness

Y.js Awareness lets you show who else is on the board and what they are looking at. Extend the composable to track cursor-like indicators.

// Add to useBoard.js

function setAwarenessField(key, value) {
    const current = wsProvider.awareness.getLocalState() || {};
    wsProvider.awareness.setLocalStateField(key, value);
}

// Track which card the user is currently editing
function setFocusedCard(cardId) {
    setAwarenessField('focusedCard', cardId);
}

function clearFocusedCard() {
    setAwarenessField('focusedCard', null);
}

In the template, show presence indicators on cards:

<!-- Add inside the card div, after the textarea -->
<div class="presence-indicators" v-if="getCardUsers(cardId).length > 0">
  <span
    v-for="user in getCardUsers(cardId)"
    :key="user.clientId"
    class="presence-dot"
    :style="{ backgroundColor: user.user.color }"
    :title="user.user.name"
  ></span>
</div>
// In the script section
function getCardUsers(cardId) {
  return users.value.filter(u =>
    u.focusedCard === cardId && u.clientId !== wsProvider.awareness.clientID
  );
}

This shows colored dots on cards that other users are currently editing. The dots update in real time as users click between cards.

Step 6: Handling Concurrent Drag-and-Drop

The most critical test: two users drag the same card to different columns simultaneously.

Y.js handles this through its array CRDT internals. When two move operations conflict, Y.js uses a deterministic ordering based on the operation's Lamport timestamp and client ID. One move wins, and both users see the card in the same column.

The result might not match either user's intention (the card ends up in one column, not both). But it is consistent — both screens show the same state. For a kanban board, this is the right trade-off. The alternative (splitting the card into two copies) would be more confusing.

If you want to handle the "my drag lost" case, observe the card's position and notify the user:

// Track a card being dragged locally
const pendingMove = ref(null);

function moveCard(cardId, fromColumnId, toColumnId, toIndex) {
    pendingMove.value = { cardId, expectedColumn: toColumnId };

    const fromCol = yColumns.toArray().find(c => c.get('id') === fromColumnId);
    const toCol = yColumns.toArray().find(c => c.get('id') === toColumnId);
    if (!fromCol || !toCol) return;

    const fromCardIds = fromCol.get('cardIds');
    const toCardIds = toCol.get('cardIds');

    ydoc.transact(() => {
        const fromIndex = fromCardIds.toArray().indexOf(cardId);
        if (fromIndex !== -1) fromCardIds.delete(fromIndex);
        const clampedIndex = Math.min(toIndex, toCardIds.length);
        toCardIds.insert(clampedIndex, [cardId]);
    });
}

// After a sync, check if the card ended up where expected
yColumns.observe(() => {
    if (!pendingMove.value) return;

    const { cardId, expectedColumn } = pendingMove.value;
    const actualColumn = yColumns.toArray().find(col =>
        col.get('cardIds').toArray().includes(cardId)
    );

    if (actualColumn && actualColumn.get('id') !== expectedColumn) {
        console.info(`Card ${cardId} was moved by another user.`);
        // Optionally show a toast notification
    }
    pendingMove.value = null;
});

Step 7: Persistence and Recovery

The WebSocket server persists Y.js state to the Laravel database every 5 seconds and on client disconnect. For additional safety, add periodic persistence from the client side:

// In useBoard.js, add periodic save
let saveInterval = null;

wsProvider.on('synced', () => {
    syncing.value = false;
    saveInterval = setInterval(async () => {
        const state = Y.encodeStateAsUpdate(ydoc);
        const base64 = btoa(String.fromCharCode(...state));
        await fetch(`/api/boards/${boardUuid}/state`, {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
                'Authorization': `Bearer ${authToken}`,
            },
            body: JSON.stringify({ state: base64 }),
        });
    }, 30000); // Every 30 seconds
});

onUnmounted(() => {
    if (saveInterval) clearInterval(saveInterval);
    wsProvider.destroy();
    ydoc.destroy();
});

Running the Application

# Start Laravel
php artisan serve

# Start Reverb (if using Laravel's built-in broadcaster)
php artisan reverb:start

# Start the Y.js WebSocket server
node server.js

# Start the Vue frontend
cd kanban-frontend && npm run dev

Open two browser tabs to the same board URL. Drag cards between columns in one tab and watch them move in the other. Edit a card title in both tabs simultaneously — both edits merge. Disconnect one tab's network, make changes, reconnect, and watch the state converge.

Performance Considerations

For boards with many cards (hundreds), Y.js operations can get slow because array operations on large Y.Arrays require scanning. Two optimizations help:

Paginate visible cards. Only render cards in the viewport. Use a virtual scroll library for columns with many cards.

Compact the document periodically. Y.js accumulates history. Use Y.encodeStateAsUpdate to get the current state and create a fresh document from it, discarding the history. Do this server-side during idle periods.

// Compact: create a fresh doc from current state
function compactDocument(ydoc) {
    const state = Y.encodeStateAsUpdate(ydoc);
    const newDoc = new Y.Doc();
    Y.applyUpdate(newDoc, state);
    return newDoc;
}

What You Built

A fully collaborative kanban board with these capabilities:

  • Real-time card moves, edits, and creation across multiple users.
  • Automatic conflict resolution when users edit the same card or drag the same card.
  • User presence indicators showing who is viewing or editing which card.
  • Offline support through Y.js's local-first design — changes queue when disconnected and sync on reconnect.
  • Server-side persistence of the full board state in your Laravel database.

The Y.js CRDT layer eliminates the need for custom conflict resolution code. You define the data model, apply mutations, and Y.js guarantees convergence. The Laravel backend handles authentication, authorization, and persistence. The WebSocket layer relays operations between clients in real time.

This pattern — Y.js for collaborative state, Laravel for backend logic — scales to any collaborative application: document editors, whiteboards, spreadsheets, form builders. The data model changes, but the sync architecture remains the same.

More from this blog

M

Masud Rana

51 posts

I am highly skilled full-stack software engineer specializing in Laravel, PHP, JS, React, Vue, Inertia.js, and Shopify, with strong experience in Filament Frontend and prompt engineering.