Laravel Reverb: Building Presence Channels with Per-User State and Typed Events
#laravel #reverb #websockets #broadcasting

Laravel Reverb: Building Presence Channels with Per-User State and Typed Events

3 min read Mohamed Said Mohamed Said

Presence Channels in Laravel Reverb: Per-User State and Typed Events

Most Reverb tutorials stop at public and private channels. Presence channels are where real collaborative features live — shared cursors, live user lists, typing indicators — and they demand a more disciplined approach to state, typing, and reconnection.

Authenticating and Enriching Channel Membership

Presence channels authenticate via BroadcastServiceProvider just like private channels, but the return value matters: whatever you return from the closure becomes the member's metadata broadcast to every subscriber.

// routes/channels.php
Broadcast::channel('document.{documentId}', function (User $user, int $documentId): array|false {
    $document = Document::find($documentId);

    if (! $document || ! $user->can('view', $document)) {
        return false;
    }

    return [
        'id'     => $user->id,
        'name'   => $user->name,
        'avatar' => $user->avatar_url,
        'role'   => $document->roleFor($user),
    ];
});

Reverb stores this payload in its internal channel registry (backed by Redis in multi-worker deployments). Every here, joining, and leaving event on the client receives this shape — so keep it lean and serialisable.

Typed Broadcast Events

Avoid stringly-typed payloads. Define a dedicated event class per domain action:

final class CursorMoved implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets;

    public function __construct(
        public readonly int    $userId,
        public readonly string $documentId,
        public readonly float  $x,
        public readonly float  $y,
    ) {}

    public function broadcastOn(): array
    {
        return [new PresenceChannel("document.{$this->documentId}")];
    }

    public function broadcastAs(): string
    {
        return 'cursor.moved';
    }

    /** Exclude the sender from receiving their own event. */
    public function broadcastToEveryoneElse(): static
    {
        return $this->dontBroadcastToCurrentUser();
    }
}

Using broadcastAs() gives you a clean contract between server and client. On the Echo side:

window.Echo.join(`document.${documentId}`)
    .here(members  => store.setMembers(members))
    .joining(member => store.addMember(member))
    .leaving(member => store.removeMember(member))
    .listen('.cursor.moved', ({ userId, x, y }) => {
        cursors.update(userId, x, y);
    });

Note the leading dot in .cursor.moved — required when you override broadcastAs().

Handling Reconnects Without Ghost Members

When a client drops and reconnects, Reverb fires leaving then joining in sequence. If your server-side state (e.g., a Redis hash of active collaborators) is only updated via those events, a crash loop creates ghost entries.

Mitigate this with a heartbeat approach:

// In a Livewire component or dedicated endpoint
public function heartbeat(string $documentId): void
{
    $key = "presence:document:{$documentId}:user:{$this->userId}";
    Redis::setex($key, 30, now()->timestamp);
}

A scheduled command (every minute) prunes keys older than the TTL, keeping your membership list consistent independently of WebSocket events.

Scaling Across Multiple Reverb Workers

Reverb uses Redis pub/sub to synchronise presence state across workers. Ensure your config/reverb.php points to a dedicated Redis connection — do not share it with your queue connection:

'servers' => [
    'reverb' => [
        'driver'     => 'reverb',
        'connection' => env('REVERB_REDIS_CONNECTION', 'reverb'),
    ],
],
// config/database.php  (redis connections)
'reverb' => [
    'url'      => env('REVERB_REDIS_URL'),
    'host'     => env('REVERB_REDIS_HOST', '127.0.0.1'),
    'password' => env('REVERB_REDIS_PASSWORD'),
    'port'     => env('REVERB_REDIS_PORT', '6379'),
    'database' => env('REVERB_REDIS_DB', '1'),
],

Isolating the connection prevents queue-heavy workloads from starving the pub/sub pipeline.

Key Takeaways

  • Return a structured array from channel auth closures — it becomes the member metadata shape for all clients.
  • Use dedicated event classes with broadcastAs() for a typed, versioned contract between PHP and JavaScript.
  • Call dontBroadcastToCurrentUser() on high-frequency events (cursors, keystrokes) to halve unnecessary traffic.
  • Supplement WebSocket membership events with a Redis TTL heartbeat to survive reconnect storms.
  • Isolate Reverb's Redis connection from your queue connection to prevent throughput contention at scale.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does Laravel Reverb support presence channels out of the box, or do I need extra packages?
Reverb supports presence channels natively via Laravel Echo and the standard `PresenceChannel` class. No additional packages are required beyond `laravel/reverb` and `laravel-echo`.
Q02 Why use `ShouldBroadcastNow` instead of `ShouldBroadcast` for cursor events?
`ShouldBroadcastNow` bypasses the queue and broadcasts synchronously, which is critical for low-latency events like cursor movements. Queued broadcasting adds unpredictable delay that makes real-time UI feel broken.
Q03 How do I prevent a user's own cursor event from echoing back to them?
Call `$this->dontBroadcastToCurrentUser()` inside the event or expose a `broadcastToEveryoneElse()` helper that returns it. Reverb will exclude the socket that triggered the broadcast from receiving the event.

Continue reading

More Articles

View all