Live Collaboration WireFlow
Real-time multi-user editing powered by Laravel Reverb — every visitor is a collaborator.
This is a REAL collaboration demo — you're editing alongside actual visitors to the site. The canvas syncs via Laravel Reverb WebSockets. Up to 5 users share a room.
The graph starts with three Laravel ecosystem nodes. Right-click the canvas to add more (Reverb, Horizon, Forge, etc.).
The Laravel node is locked — it can't be moved or deleted,
and it fires red particles along all its connected edges.
Try it: add nodes, drag them, connect them, watch others edit in real time.
<div>
<x-flow
:nodes="$nodes"
:edges="$edges"
:viewport="['x' => 20, 'y' => 40, 'zoom' => 1]"
background="dots"
:controls="true"
:config="[
'fitViewOnInit' => false,
'collab' => [
'provider' => WireFlow::js('new window.ReverbProvider({
roomId: \'' . $roomId . '\',
channel: \'collab-room.' . $roomId . '\',
user: { name: \'' . $identity[\'name\'] . '\',
color: \'' . $identity[\'color\'] . '\' },
stateUrl: \'/api/collab/{roomId}/state\',
})'),
'user' => $identity,
'cursors' => true,
'selections' => true,
],
]"
@connect="onConnect"
>
<x-slot:node>
<template x-if="node.data?.type === 'laravel'">
<div>
<div x-flow-handle:source.top="'top'" style="left: 50%;"></div>
<div x-flow-handle:source.bottom="'bottom'" style="left: 50%;"></div>
<div x-flow-handle:source.left="'left'" style="top: 50%;"></div>
<div x-flow-handle:source.right="'right'" style="top: 50%;"></div>
<div class="text-[13px] font-medium" x-text="node.data.label"></div>
</div>
</template>
<template x-if="node.data?.type && node.data.type !== 'laravel'">
<div>
<x-flow-handle type="target" position="left" />
<div class="text-[13px] font-medium" x-text="node.data.label"></div>
<x-flow-handle type="source" position="right" />
</div>
</template>
</x-slot:node>
<div x-flow-cursors x-init="$nextTick(() => {
const vp = $el.closest('.flow-container')
?.querySelector('.flow-viewport');
if (vp && !vp.contains($el)) vp.appendChild($el);
})"></div>
<div x-flow-context-menu.node class="...">
<template x-if="contextMenu.node?.id !== 'laravel'">
<button @click="removeNodes([contextMenu.node.id])">
Delete Node
</button>
</template>
</div>
<div x-flow-context-menu.pane class="...">
<button @click="addNodes([{ id: 'reverb-'+Date.now(),
position: $flow.screenToFlowPosition(...),
class: 'flow-node-reverb',
data: { type: 'reverb', label: 'Reverb' }
}])">Add Reverb</button>
</div>
</x-flow>
</div>
How it works
This is a production-grade collaboration setup using Laravel Reverb. Here's how each piece fits together.
Echo & JavaScript setup
Before any collaboration can happen, you need Laravel Echo connected to Reverb and the
AlpineFlow collaboration addon registered. This goes in your
resources/js/app.js.
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 8080,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
import AlpineFlow from '@getartisanflow/alpineflow';
import AlpineFlowCollab, {
ReverbProvider,
} from '@getartisanflow/alpineflow/collab';
// Expose on window so WireFlow::js() can reference it in Blade
window.ReverbProvider = ReverbProvider;
document.addEventListener('alpine:init', () => {
window.Alpine.plugin(AlpineFlow);
window.Alpine.plugin(AlpineFlowCollab);
});
The key detail is window.ReverbProvider — WireFlow's
WireFlow::js() emits raw JavaScript that runs in the
browser, so it needs ReverbProvider available on
window. Without this, the
new window.ReverbProvider(...) call in your Blade
config will throw a ReferenceError.
ReverbProvider setup
The ReverbProvider connects to a Laravel Reverb
WebSocket server via a private Echo channel. It's passed to
<x-flow> through the
:config prop using
WireFlow::js() to emit raw JavaScript.
:config="[
'collab' => [
'provider' => WireFlow::js('new window.ReverbProvider({
roomId: \'' . \$roomId . '\',
channel: \'collab-room.' . \$roomId . '\',
user: { name: \'' . \$identity[\'name\'] . '\',
color: \'' . \$identity[\'color\'] . '\' },
stateUrl: \'/api/collab/{roomId}/state\',
})'),
'user' => \$identity,
'cursors' => true,
'selections' => true,
],
]"
The stateUrl is critical — it provides
the initial Yjs state from the server so all clients start from the same CRDT baseline.
Without it, clients that load simultaneously create duplicate Y.Map instances that cause
one-directional sync failures. The provider also saves state back to this URL every 5
seconds (debounced) so late joiners get the current graph, not just the starter nodes.
Broadcasting auth without authentication
Private channels require authorization, but this demo has no authenticated users. We register a
custom /broadcasting/auth route with manual
Pusher HMAC signing, bypassing Laravel's default auth middleware.
// routes/web.php
Route::post('broadcasting/auth', function (Request $request) {
$identity = session('collab_identity');
if (! $identity) {
return response()->json(['error' => 'No identity'], 403);
}
$channelName = str_replace('private-', '', $request->input('channel_name'));
$socketId = $request->input('socket_id');
if (! str_starts_with($channelName, 'collab-room.')) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$key = config('broadcasting.connections.reverb.key');
$secret = config('broadcasting.connections.reverb.secret');
$signature = hash_hmac('sha256',
$socketId . ':' . $request->input('channel_name'), $secret);
return response()->json(['auth' => $key . ':' . $signature]);
});
The identity is generated on first visit and stored in the session — a random name and color.
The auth route only allows collab-room.* channels.
Broadcast channel definition
The custom auth route above handles the Pusher signature, but you still need to define
the channel in routes/channels.php.
This tells Laravel what user data to return when a client subscribes.
// routes/channels.php
Broadcast::channel('collab-room.{roomId}', function ($user, $roomId) {
$identity = session('collab_identity');
if (! $identity) {
return false;
}
// Return user info — this becomes the awareness data
// other clients see (name label, cursor color)
return [
'id' => $identity['id'],
'name' => $identity['name'],
'color' => $identity['color'],
];
});
The returned array is what the ReverbProvider uses for awareness — cursor labels,
colors, and user identification. If the channel callback returns
false, the subscription is rejected.
Note that for anonymous users the
$user parameter will be
null, which is fine —
we pull identity from the session instead.
Room management
Rooms are tracked in Laravel's cache with a 5-minute TTL. When a visitor arrives, the
CollabRoomManager finds a room with space
or creates a new one. When the TTL expires (no activity), the room vanishes automatically.
// app/Services/CollabRoomManager.php
public function findOrCreateRoom(): ?string
{
$rooms = Cache::get('collab:rooms', []);
$totalUsers = 0;
foreach ($rooms as $roomId) {
// Stale room? Cache expired — clean it up
if (! Cache::has("collab:room:{$roomId}:count")) {
continue;
}
$count = Cache::get("collab:room:{$roomId}:count", 0);
$totalUsers += $count;
if ($count < config('collab.max_per_room')) {
$this->refreshTtl($roomId); // Keep it alive
return $roomId;
}
}
if ($totalUsers >= config('collab.max_total')) {
return null; // All rooms full
}
// Create new room with 5-minute TTL
$roomId = 'room-' . Str::random(8);
Cache::put("collab:room:{$roomId}:count", 0,
now()->addMinutes(10));
return $roomId;
}
Room limits are configurable via env:
COLLAB_MAX_PER_ROOM=5,
COLLAB_MAX_TOTAL=100,
COLLAB_NODE_CAP=20.
When all rooms are full, visitors see a "try again later" message.
Reverb configuration
Two Reverb settings are critical for Yjs collaboration. Without these, whispers are silently dropped or large Yjs updates crash the server.
// config/reverb.php → apps
// Allow whispers (client events) from private channels
// Valid values: 'all' or 'members' (presence only)
// Default 'members' silently drops whispers on private channels
'accept_client_events_from' => 'all',
// Yjs updates can be large — default 10KB is too small
'max_message_size' => 500_000, // 500KB
'max_request_size' => 500_000, // 500KB
The accept_client_events_from setting defaults
to 'members' which only allows whispers on
presence channels. For private channel collab, set it to
'all'. The Reverb server must be
restarted after changing this.
Ecosystem node classes
Each Laravel ecosystem tool has its own CSS class in the artisanflow theme. Nodes use the
class property instead of inline styles —
the class sets the border color and top accent stripe.
/* theme-artisanflow.css */
.flow-node.flow-node-laravel {
--flow-node-border-top: 2.5px solid #FF2D20;
border-color: #FF2D20;
}
.flow-node.flow-node-alpine {
--flow-node-border-top: 2.5px solid #77C1D2;
border-color: #77C1D2;
}
/* ... reverb, horizon, forge, vapor, nova,
pulse, pennant, sanctum, breeze, pint */
/* Usage — pass class on node creation */
$this->nodes[] = [
'id' => 'reverb-123',
'class' => 'flow-node-reverb',
'data' => ['type' => 'reverb', 'label' => 'Reverb'],
];
The connection status bar
The status bar above the canvas is a standalone Alpine component that polls Echo's internal state and binds to whisper events. It shows connection health without touching the flow canvas or Livewire at all.
<div x-data="{ awareness: 0, updates: 0, echoState: 'loading' }"
x-init="
setInterval(() => {
// Poll Echo connection state
echoState = Echo?.connector?.pusher?.connection?.state;
// Bind to whisper events (once)
const ch = Object.values(
Echo?.connector?.pusher?.channels?.channels || {}
)[0];
if (ch && !ch.__debugBound) {
ch.bind('client-yjs-awareness', () => awareness++);
ch.bind('client-yjs-update', () => updates++);
ch.__debugBound = true;
}
}, 500);
">
<span>Echo: <span x-text="echoState"></span></span>
<span>Aware: <span x-text="awareness"></span></span>
<span>Sync: <span x-text="updates"></span></span>
</div>
Aware counts cursor/presence events.
Sync counts document updates (node/edge changes).
If Echo shows connected but Sync stays at 0,
check that accept_client_events_from is set to
'all' in your Reverb config.
Server-side state persistence
The stateUrl endpoint serves and stores the
Yjs document state. The initial state is a pre-encoded Yjs binary containing the 3 starter nodes.
As clients edit, the ReverbProvider saves the current state every 5 seconds (debounced).
// app/Http/Controllers/CollabStateController.php
// Pre-encoded Yjs state for the starter graph
private const INITIAL_STATE = 'ARyQ+IphACcB...'; // base64
public function show(string $roomId): JsonResponse
{
// Return saved state if room has been edited, otherwise initial
$saved = Cache::get("collab:room:{$roomId}:yjs_state");
return response()->json([
'state' => $saved ?? self::INITIAL_STATE,
]);
}
public function store(string $roomId): JsonResponse
{
// ReverbProvider POSTs here every 5 seconds (debounced)
Cache::put(
"collab:room:{$roomId}:yjs_state",
request()->input('state'),
now()->addMinutes(5)
);
return response()->json(['ok' => true]);
}
This solves the CRDT conflict problem: without stateUrl,
clients that load simultaneously each create their own Yjs Y.Map instances for the same nodes.
The CRDT picks one "winner" — the other client's modifications become invisible.
With stateUrl, all clients start from the
exact same Yjs binary, sharing the same Y.Map instances.
Cursor positioning in WireFlow
Remote cursors use flow-space coordinates and must be inside the viewport div for the viewport's CSS transform (translate + scale) to position them correctly. In WireFlow, the default slot renders outside the viewport — so the cursor element needs to move itself inside on mount.
<!-- WireFlow: cursor element must move into the viewport -->
<div x-flow-cursors x-init="$nextTick(() => {
const viewport = $el.closest('.flow-container')
?.querySelector('.flow-viewport');
if (viewport && !viewport.contains($el))
viewport.appendChild($el);
})"></div>
<!-- AlpineFlow: no issue — you place it directly -->
<div x-flow-viewport>
<div x-flow-cursors></div>
<!-- nodes... -->
</div>
Without this fix, cursors appear offset because the viewport's pan/zoom transform
doesn't apply to elements outside it. This only affects WireFlow — in AlpineFlow,
you control the viewport div directly and can place
x-flow-cursors inside it.
Locked nodes & particles
The Laravel node is the anchor — draggable: false
prevents movement, and the delete context menu checks for it. Particles fire continuously from
the Laravel node along all its connected edges.
// Starter node — locked
['id' => 'laravel', 'draggable' => false, ...]
// Prevent deletion
public function deleteNode(string $nodeId): void
{
if ($nodeId === 'laravel') return;
// ...
}
// Particles (client-side, in x-init)
x-init="$nextTick(() => {
const fire = () => {
edges.filter(e => e.source === 'laravel').forEach(e => {
$flow.sendParticle(e.id, {
color: '#FF2D20', size: 4, duration: '1s'
});
});
};
fire();
setInterval(fire, 1500);
})"
Particles run independently on each client — they're visual-only and don't sync through
Yjs. Because edges is reactive,
new connections from the Laravel node automatically get particles too.
Compare: Open the AlpineFlow collab example to see the same collaboration patterns with InMemoryProvider (simulated, no server).