Laravel Broadcasting with Reverb: Per-Channel Authorization and Presence Channels at Scale
#laravel #reverb #broadcasting #websockets #real-time

Laravel Broadcasting with Reverb: Per-Channel Authorization and Presence Channels at Scale

3 min read Mohamed Said Mohamed Said

Why Channel Authorization Deserves More Attention

Most tutorials stop at Auth::check() inside a channel route. In production, that is rarely enough. A multi-tenant SaaS, a collaborative document editor, or a live support dashboard each needs per-resource authorization that mirrors the same rules enforced in your HTTP layer — not a weaker copy of them.

Laravel Reverb is a first-party WebSocket server, but the authorization model is still driven by routes/channels.php and the BroadcastServiceProvider. Getting that layer right is the real work.

Structuring Channel Routes for Real Authorization

Avoid anonymous closures for anything non-trivial. Register a dedicated channel class instead:

php artisan make:channel OrderChannel
// routes/channels.php
Broadcast::channel('orders.{orderId}', OrderChannel::class);
// app/Broadcasting/OrderChannel.php
final class OrderChannel
{
    public function join(User $user, int $orderId): bool|array
    {
        $order = Order::findOrFail($orderId);

        // Reuse the same policy you use in controllers.
        return $user->can('view', $order);
    }
}

Using $user->can() delegates to your existing OrderPolicy::view() method. One rule, two enforcement points — no drift.

Presence Channels: The join Return Value Matters

For presence channels, returning true is not enough. You must return an array; that array becomes the member metadata broadcast to all subscribers.

public function join(User $user, string $roomId): bool|array
{
    $room = ChatRoom::findOrFail($roomId);

    if (! $user->can('join', $room)) {
        return false;
    }

    return [
        'id'     => $user->id,
        'name'   => $user->display_name,
        'avatar' => $user->avatar_url,
    ];
}

Keep this payload small. Every subscriber receives it on pusher:member_added. Sending eager-loaded relationships here is a common mistake that bloats payloads and slows join acknowledgement.

Scaling Presence Sets

Reverb stores presence membership in Redis by default when you configure the reverb driver with a Redis connection. The key concern at scale is join/leave storms — a deploy or network blip causes hundreds of clients to reconnect simultaneously.

Mitigate this with exponential back-off on the client side (Laravel Echo supports this via the authEndpoint retry config) and by keeping your auth endpoint fast:

// config/broadcasting.php — tune the Reverb connection pool
'reverb' => [
    'driver' => 'reverb',
    'key'    => env('REVERB_APP_KEY'),
    'secret' => env('REVERB_APP_SECRET'),
    'app_id' => env('REVERB_APP_ID'),
    'options' => [
        'host'   => env('REVERB_HOST', '0.0.0.0'),
        'port'   => env('REVERB_PORT', 8080),
        'scheme' => env('REVERB_SCHEME', 'http'),
    ],
],

Cache the authorization result for short-lived presence joins (5–10 seconds is safe) using a tagged cache keyed on user:{id}:channel:{name}:

public function join(User $user, string $roomId): bool|array
{
    return Cache::tags(['channel-auth'])
        ->remember("user:{$user->id}:room:{$roomId}", 8, function () use ($user, $roomId) {
            $room = ChatRoom::findOrFail($roomId);
            if (! $user->can('join', $room)) {
                return false;
            }
            return ['id' => $user->id, 'name' => $user->display_name];
        });
}

Invalidate the tag when room membership rules change (e.g., a user is removed from a room).

Broadcasting Events Only to Authorized Channels

Use broadcastOn to return a typed channel, not a raw string:

final class OrderStatusUpdated implements ShouldBroadcast
{
    public function __construct(private readonly Order $order) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel("orders.{$this->order->id}")];
    }

    public function broadcastWith(): array
    {
        return ['status' => $this->order->status->value];
    }
}

Using PrivateChannel (or PresenceChannel) ensures Reverb enforces the auth handshake before delivering the event.

Key Takeaways

  • Delegate to policies: reuse Gate/Policy inside channel classes — never duplicate authorization logic.
  • Return arrays from presence join: returning true silently breaks presence membership metadata.
  • Keep join payloads minimal: large arrays on pusher:member_added degrade performance for all subscribers.
  • Cache short-lived auth results: reduces DB pressure during reconnect storms without meaningful security trade-offs.
  • Use typed channel classes: PrivateChannel and PresenceChannel make intent explicit and prevent accidental public exposure.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I reuse Laravel policies inside channel authorization classes?
Yes. Call `$user->can('action', $model)` inside your channel's `join` method. It delegates to the same Gate and Policy you use in controllers, keeping authorization logic in one place.
Q02 What happens if a presence channel's `join` method returns `true` instead of an array?
Returning `true` grants access but sends no member metadata. Other subscribers won't receive meaningful data in `pusher:member_added` events, breaking any UI that displays who is online.
Q03 How do I handle reconnect storms in Reverb with many presence channel subscribers?
Cache the authorization result for a few seconds per user/channel pair using a tagged cache. This reduces database load during mass reconnects without weakening security, since the window is too short to be exploitable in practice.

Continue reading

More Articles

View all