Collaboration

The Collaboration addon enables real-time multi-user editing of WireFlow diagrams using Yjs conflict-free replicated data types (CRDTs). All node and edge changes sync automatically across connected clients.

Installation

Install the required peer dependencies and the AlpineFlow npm package:

npm install @getartisanflow/alpineflow yjs y-websocket y-protocols

Register the plugin in your resources/js/app.js:

// Core from WireFlow vendor bundle
import AlpineFlow from '../../vendor/getartisanflow/wireflow/dist/alpineflow.bundle.esm.js';
// Collaboration addon from npm
import AlpineFlowCollab from '@getartisanflow/alpineflow/collab';

document.addEventListener('alpine:init', () => {
    window.Alpine.plugin(AlpineFlow);
    window.Alpine.plugin(AlpineFlowCollab);
});

Rebuild after adding the import:

npm run build

Configuration

Pass a collab object via the :config prop:

<x-flow
    :nodes="$nodes"
    :edges="$edges"
    :config="[
        'collab' => [
            'provider' => 'websocket',
            'url' => 'ws://localhost:1234',
            'room' => 'my-room',
            'user' => [
                'name' => auth()->user()->name,
                'color' => '#3b82f6',
            ],
        ],
    ]"
>
    <x-slot:node>
        <x-flow-handle type="target" position="top" />
        <span x-text="node.data.label"></span>
        <x-flow-handle type="source" position="bottom" />
    </x-slot:node>
</x-flow>

Config options

Option Type Default Description
provider string Provider type: 'websocket' or 'reverb'
url string WebSocket server URL
room string Room/document name for sync
user object { name: string, color: string }
cursors boolean true Show remote user cursors
selections boolean true Show remote user selections
throttle number 20 Cursor broadcast throttle in ms

Providers

Three provider types are available:

WebSocket provider

Standard y-websocket connection. Requires a running y-websocket server.

<x-flow
    :nodes="$nodes"
    :edges="$edges"
    :config="[
        'collab' => [
            'provider' => 'websocket',
            'url' => 'ws://localhost:1234',
            'room' => 'diagram-1',
            'user' => ['name' => 'Alice', 'color' => '#3b82f6'],
        ],
    ]"
>
    <x-slot:node>
        <span x-text="node.data.label"></span>
    </x-slot:node>
</x-flow>

Laravel Reverb provider

Connects via a running Laravel Reverb server.

<x-flow
    :nodes="$nodes"
    :edges="$edges"
    :config="[
        'collab' => [
            'provider' => 'reverb',
            'url' => 'ws://localhost:8080',
            'room' => 'diagram-1',
            'user' => ['name' => auth()->user()->name, 'color' => '#3b82f6'],
        ],
    ]"
>
    <x-slot:node>
        <span x-text="node.data.label"></span>
    </x-slot:node>
</x-flow>

InMemoryProvider

For testing and demos, InMemoryProvider requires no server. Use linkProviders() to synchronize two instances client-side. Import these from the collab addon:

import { InMemoryProvider, linkProviders } from '@getartisanflow/alpineflow/collab';

This is used internally for documentation demos. See the complete example below for a two-canvas demo setup.

Remote cursors

Use the x-flow-cursors directive on <x-flow> to render remote user cursors:

<x-flow
    :nodes="$nodes"
    :edges="$edges"
    :config="[
        'collab' => [
            'provider' => 'websocket',
            'url' => 'ws://localhost:1234',
            'room' => 'my-room',
            'user' => ['name' => auth()->user()->name, 'color' => '#3b82f6'],
        ],
    ]"
    x-flow-cursors
>
    <x-slot:node>
        <x-flow-handle type="target" position="top" />
        <span x-text="node.data.label"></span>
        <x-flow-handle type="source" position="bottom" />
    </x-slot:node>
</x-flow>

Each remote cursor renders as:

  • An SVG arrow pointer filled with the user's color
  • A name label badge positioned beside the arrow
  • Smooth CSS transitions (100ms ease-out) as the cursor moves

Cursor CSS customization

The cursor elements have the class .flow-collab-cursor with children .flow-collab-cursor-arrow (SVG path) and .flow-collab-cursor-label (name badge). Override these to customize:

/* Larger name labels */
.flow-collab-cursor-label {
    font-size: 13px;
    padding: 3px 10px;
}

/* Hide name labels entirely */
.flow-collab-cursor-label {
    display: none;
}

/* Custom cursor animation */
.flow-collab-cursor {
    transition: transform 200ms ease-out;
}

User presence via $flow.collab

When collaboration is active, $flow.collab exposes reactive presence data:

Property Type Description
users CollabUser[] All connected users (reactive)
userCount number Number of connected users
me CollabUser Local user info { name, color }
connected boolean Whether the provider is currently connected
status string 'connecting', 'connected', or 'disconnected'

Awareness state shape

Each connected user broadcasts this state:

{
    user: { name: 'Alice', color: '#3b82f6' },
    cursor: { x: 150, y: 200 },           // pointer position in flow coords, or null
    selectedNodes: ['node-1', 'node-3'],   // currently selected node IDs
    viewport: { x: 0, y: 0, zoom: 1 },    // user's viewport
}

User presence list example

<x-flow
    :nodes="$nodes"
    :edges="$edges"
    :config="$collabConfig"
    x-flow-cursors
>
    <x-slot:node>
        <x-flow-handle type="target" position="top" />
        <span x-text="node.data.label"></span>
        <x-flow-handle type="source" position="bottom" />
    </x-slot:node>

    <x-flow-panel position="top-right">
        <div class="text-sm font-medium" x-text="'Online: ' + ($flow.collab?.userCount ?? 0)"></div>
        <template x-for="user in ($flow.collab?.users ?? [])" :key="user.name">
            <div class="flex items-center gap-2 text-xs py-0.5">
                <span class="w-2.5 h-2.5 rounded-full" :style="'background:' + user.color"></span>
                <span x-text="user.name"></span>
            </div>
        </template>
    </x-flow-panel>
</x-flow>

Connection status indicator example

<x-flow-panel position="bottom-left">
    <div class="flex items-center gap-1 text-xs">
        <span class="w-2 h-2 rounded-full"
              :class="{
                  'bg-green-500': $flow.collab?.status === 'connected',
                  'bg-yellow-500': $flow.collab?.status === 'connecting',
                  'bg-red-500': $flow.collab?.status === 'disconnected',
              }"></span>
        <span x-text="$flow.collab?.status ?? 'offline'"></span>
    </div>
</x-flow-panel>

Complete example

A full collaborative WireFlow canvas with remote cursors, user presence list, and connection status indicator.

Livewire component

<?php

namespace App\Livewire;

use ArtisanFlow\WireFlow\Concerns\WithWireFlow;
use Livewire\Component;

class CollaborativeFlow extends Component
{
    use WithWireFlow;

    public array $nodes = [
        ['id' => 'a', 'position' => ['x' => 50, 'y' => 50], 'data' => ['label' => 'Node A']],
        ['id' => 'b', 'position' => ['x' => 300, 'y' => 50], 'data' => ['label' => 'Node B']],
        ['id' => 'c', 'position' => ['x' => 175, 'y' => 200], 'data' => ['label' => 'Node C']],
    ];

    public array $edges = [
        ['id' => 'e1', 'source' => 'a', 'target' => 'c', 'markerEnd' => 'arrowclosed'],
        ['id' => 'e2', 'source' => 'b', 'target' => 'c', 'markerEnd' => 'arrowclosed'],
    ];

    public function getCollabConfigProperty(): array
    {
        return [
            'collab' => [
                'provider' => 'websocket',
                'url' => config('services.yjs.url', 'ws://localhost:1234'),
                'room' => 'flow-demo-room',
                'user' => [
                    'name' => auth()->user()->name ?? 'Anonymous',
                    'color' => $this->getUserColor(),
                ],
                'cursors' => true,
                'selections' => true,
            ],
        ];
    }

    private function getUserColor(): string
    {
        $colors = ['#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#ec4899'];

        return $colors[auth()->id() % count($colors)];
    }

    public function render(): \Illuminate\View\View
    {
        return view('livewire.collaborative-flow');
    }
}

Blade template

{{-- resources/views/livewire/collaborative-flow.blade.php --}}
<div>
    <x-flow
        :nodes="$nodes"
        :edges="$edges"
        background="dots"
        controls
        :config="$this->collabConfig"
        style="height: 500px;"
        x-flow-cursors
    >
        <x-slot:node>
            <x-flow-handle type="target" position="top" />
            <span x-text="node.data.label"></span>
            <x-flow-handle type="source" position="bottom" />
        </x-slot:node>

        {{-- User presence panel --}}
        <x-flow-panel position="top-right">
            <div class="p-2 text-xs">
                {{-- Connection status --}}
                <div class="flex items-center gap-1 mb-2">
                    <span class="w-2 h-2 rounded-full"
                          :class="{
                              'bg-green-500': $flow.collab?.status === 'connected',
                              'bg-yellow-500': $flow.collab?.status === 'connecting',
                              'bg-red-500': $flow.collab?.status === 'disconnected',
                          }"></span>
                    <span x-text="($flow.collab?.userCount ?? 0) + ' online'"></span>
                </div>

                {{-- User list --}}
                <template x-for="user in ($flow.collab?.users ?? [])" :key="user.name">
                    <div class="flex items-center gap-2 py-0.5">
                        <span class="w-2.5 h-2.5 rounded-full" :style="'background:' + user.color"></span>
                        <span x-text="user.name"></span>
                    </div>
                </template>
            </div>
        </x-flow-panel>
    </x-flow>
</div>

Live demos: See the AlpineFlow collab example (simulated with InMemoryProvider) and the WireFlow collab example (real multi-user via Reverb).

Sync behavior

All node and edge changes automatically sync across connected clients via Yjs shared types:

  • Adding, removing, and updating nodes
  • Adding, removing, and updating edges
  • Node position changes (drag)
  • Annotation drawing (when used with the Whiteboard addon)
  • Undo/redo operations

Production gotchas

These apply to both AlpineFlow and WireFlow when using ReverbProvider in production. See the AlpineFlow collab docs for detailed coverage.

Use stateUrl for initial state

Without stateUrl, clients that load simultaneously create independent Yjs Y.Map instances for the same nodes. The CRDT resolves duplicates, but modifications to the "losing" Y.Maps are invisible to other clients — causing one-directional sync.

Always provide a stateUrl endpoint that serves a pre-encoded Yjs state. All clients start from the same CRDT baseline. The ReverbProvider also saves state back to this URL every 5 seconds (debounced) so late joiners get the current graph.

:config="[
    'collab' => [
        'provider' => WireFlow::js('new window.ReverbProvider({
            roomId: \'' . $roomId . '\',
            channel: \'collab-room.' . $roomId . '\',
            user: { ... },
            stateUrl: \'/api/collab/{roomId}/state\',
        })'),
    ],
]"

Reverb: accept_client_events_from

Reverb's accept_client_events_from setting defaults to 'members' (presence channels only). For private channel collab, set it to 'all' in config/reverb.php. Only 'all' and 'members' are valid — any other value silently blocks all whispers. Restart Reverb after changing.

Reverb: message size limits

Yjs updates can exceed Reverb's default 10KB limit. Set max_message_size and max_request_size to at least 500KB in config/reverb.php.

Cursor positioning in WireFlow

The x-flow-cursors element renders in WireFlow's default slot, which is outside the viewport div. Remote cursors use flow-space coordinates and need the viewport's CSS transform to position correctly. Move the element into the viewport on mount:

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

Anonymous broadcasting auth

Private channels require authentication. For anonymous collab (no user model), register a custom /broadcasting/auth route with manual Pusher HMAC signing that uses session-based identity instead of Laravel's auth middleware.