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; returnfalseto deny. - Override the broadcasting auth middleware to match your app's guard (
auth:sanctum,auth:api, etc.). - Pass
Authorizationheaders in the Echoauthconfig 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.