Laravel Broadcasting with Reverb: Private Channels, Presence, and Auth Guards
#laravel #reverb #broadcasting #websockets #real-time

Laravel Broadcasting with Reverb: Private Channels, Presence, and Auth Guards

4 min read Mohamed Said Mohamed Said

Laravel Reverb: Private Channels, Presence, and Auth Guards

Laravel Reverb ships as a first-party WebSocket server, and getting a public channel broadcasting is trivial. The interesting — and production-critical — work starts when you lock down private and presence channels and integrate them with non-default auth guards.

Channel Authorization Fundamentals

Every private or presence channel subscription triggers a POST to /broadcasting/auth. Laravel resolves the channel class, calls its join (presence) or implicit boolean (private) method, and returns either a 200 or 403.

Register channel classes in routes/channels.php or a dedicated service provider:

// routes/channels.php
use App\Broadcasting\OrderChannel;

Broadcast::channel('orders.{orderId}', OrderChannel::class);
// app/Broadcasting/OrderChannel.php
namespace App\Broadcasting;

use App\Models\Order;
use App\Models\User;

class OrderChannel
{
    public function join(User $user, int $orderId): array|bool
    {
        $order = Order::findOrFail($orderId);

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

        // Returning an array makes this a presence channel payload.
        return [
            'id'   => $user->id,
            'name' => $user->name,
        ];
    }
}

Returning false or throwing an AuthorizationException sends a 403. Returning an array automatically upgrades the channel to presence semantics.

Wiring a Non-Default Auth Guard

The broadcasting auth route uses the web guard by default. API-only apps authenticating via Sanctum tokens need an explicit override.

// app/Providers/BroadcastServiceProvider.php
use Illuminate\Support\Facades\Broadcast;

public function boot(): void
{
    Broadcast::routes(['middleware' => ['auth:sanctum']]);

    require base_path('routes/channels.php');
}

On the JavaScript side, pass the auth headers when constructing the Echo instance:

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

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,
    forceTLS: false,
    auth: {
        headers: {
            Authorization: `Bearer ${yourSanctumToken}`,
        },
    },
});

Without the Authorization header the /broadcasting/auth endpoint returns 401 and the subscription silently fails — a common gotcha.

Presence Channel Member Tracking

Presence channels expose here, joining, and leaving callbacks on the client:

Echo.join(`orders.${orderId}`)
    .here(members  => console.log('Online now:', members))
    .joining(member => console.log('Joined:', member.name))
    .leaving(member => console.log('Left:', member.name))
    .listen('OrderStatusUpdated', e => updateUI(e.order));

Reverb tracks member state in memory per worker process. If you run multiple Reverb workers behind a load balancer, members connected to different workers won't see each other unless you configure a shared Redis presence driver. Set REVERB_SCALING_ENABLED=true and point REVERB_REDIS_* variables at your Redis instance.

Dispatching Events to Specific Channels

use App\Events\OrderStatusUpdated;

broadcast(new OrderStatusUpdated($order))->toOthers();

The toOthers() call suppresses the event for the socket that triggered it, preventing echo loops in collaborative UIs. It relies on the X-Socket-ID header being sent by Echo — verify your frontend sets it.

Testing Channel Authorization

Pest makes channel auth assertions clean:

use App\Models\{Order, User};
use Illuminate\Support\Facades\Broadcast;

it('authorizes the order owner to join the channel', function () {
    $user  = User::factory()->create();
    $order = Order::factory()->for($user)->create();

    $this->actingAs($user);

    $response = $this->postJson('/broadcasting/auth', [
        'channel_name' => "private-orders.{$order->id}",
        'socket_id'    => '123.456',
    ]);

    $response->assertOk();
});

it('rejects unauthorized users', function () {
    $user  = User::factory()->create();
    $order = Order::factory()->create(); // different owner

    $this->actingAs($user);

    $response = $this->postJson('/broadcasting/auth', [
        'channel_name' => "private-orders.{$order->id}",
        'socket_id'    => '123.456',
    ]);

    $response->assertForbidden();
});

No WebSocket connection is needed — the auth endpoint is plain HTTP.

Key Takeaways

  • Return an array from join() to enable presence semantics; return false to deny.
  • Override the broadcasting auth middleware to match your app's guard (auth:sanctum, auth:api, etc.).
  • Pass Authorization headers in the Echo auth config for token-based clients.
  • Enable Redis scaling when running multiple Reverb workers to share presence state.
  • Test channel authorization over HTTP — no live WebSocket required.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Why does my presence channel show no members when running multiple Reverb workers?
Reverb stores presence state in the worker process by default. With multiple workers, each process has its own member list. Enable Redis-backed scaling via REVERB_SCALING_ENABLED=true and configure the shared Redis connection so all workers share a single presence store.
Q02 How do I use a Sanctum token instead of session cookies for broadcasting auth?
Override the broadcasting auth route middleware in BroadcastServiceProvider: Broadcast::routes(['middleware' => ['auth:sanctum']]). Then pass the token as an Authorization header in the Echo auth.headers config on the frontend.
Q03 What is the difference between a private and a presence channel in Reverb?
Both require authorization. A private channel returns true/false from the channel class. A presence channel returns an array of member metadata, which Reverb uses to track who is currently subscribed and expose joining/leaving events to all members.

Continue reading

More Articles

View all