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.