Beyond the Hello-World: Laravel Reverb in Production
Laravel Reverb ships as a first-party WebSocket server, but most tutorials stop at php artisan reverb:start and a public channel. Real applications need private channels with proper authorization, presence channels for collaborative UIs, and a deployment story that survives more than one server.
Channel Types and When to Use Each
| Channel | Auth required | Presence data | Typical use | |---------|--------------|---------------|-------------| | Public | No | No | Public feeds, announcements | | Private | Yes | No | Per-user notifications, order updates | | Presence| Yes | Yes | Collaborative editors, live cursors |
Defining Private and Presence Channels
Channel routes live in routes/channels.php. Return true/false for private channels; return an array of user metadata for presence channels — Reverb forwards that payload to every subscriber.
// routes/channels.php
use App\Models\Project;
use Illuminate\Support\Facades\Broadcast;
// Private: only the owning user may subscribe
Broadcast::channel('orders.{orderId}', function ($user, int $orderId) {
return $user->orders()->where('id', $orderId)->exists();
});
// Presence: return metadata so the UI knows who is online
Broadcast::channel('projects.{projectId}', function ($user, int $projectId) {
$project = Project::find($projectId);
if (! $project?->members()->where('user_id', $user->id)->exists()) {
return false;
}
return [
'id' => $user->id,
'name' => $user->name,
'avatar' => $user->avatar_url,
];
});
Broadcasting an Event on a Presence Channel
// app/Events/CursorMoved.php
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class CursorMoved implements ShouldBroadcast
{
public function __construct(
public readonly int $projectId,
public readonly int $userId,
public readonly float $x,
public readonly float $y,
) {}
public function broadcastOn(): array
{
return [new PresenceChannel("projects.{$this->projectId}")];
}
// Only broadcast the payload the client actually needs
public function broadcastWith(): array
{
return ['user_id' => $this->userId, 'x' => $this->x, 'y' => $this->y];
}
// Throttle: drop duplicate events within the same second
public function broadcastWhen(): bool
{
return true; // add cache-based throttle here if needed
}
}
Tuning the Reverb Server
Reverb's config lives in config/reverb.php. The most impactful knobs for production:
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'options' => [
// Raise open-file limits before touching this
'max_connections' => 10_000,
// Keep idle connections alive; lower = faster cleanup
'max_request_size' => 10_000, // bytes
],
],
],
Pair this with a systemd unit that sets LimitNOFILE=65535 so the OS doesn't cap you first.
Horizontal Scaling with Redis Pub/Sub
A single Reverb process is a single point of failure. Run multiple workers behind a load balancer and let Redis fan out messages between them:
// config/reverb.php (scaling section)
'scaling' => [
'enabled' => true,
'driver' => 'redis',
'connection' => env('REVERB_SCALING_CONNECTION', 'default'),
],
Each Reverb worker subscribes to the same Redis channel. When worker A receives a broadcast, Redis delivers it to workers B and C so their connected clients all receive the event — no sticky sessions required.
Tip: Use a dedicated Redis connection for Reverb scaling, separate from your cache and queue connections, to avoid head-of-line blocking under load.
Client-Side Authentication Flow
Echo's auth endpoint hits /broadcasting/auth by default. Ensure your API middleware group includes auth:sanctum (or your guard of choice) and that CORS allows the origin your frontend runs on:
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,
enabledTransports: ['ws'],
authEndpoint: '/broadcasting/auth',
});
window.Echo
.join(`projects.${projectId}`)
.here(members => console.log('online:', members))
.joining(member => console.log('joined:', member))
.leaving(member => console.log('left:', member))
.listen('CursorMoved', e => moveCursor(e));
Key Takeaways
- Private channels return a boolean; presence channels return a metadata array — both go through
routes/channels.php. - Keep
broadcastWith()lean; every connected client receives the full payload. - Enable Redis scaling before you need it — retrofitting under load is painful.
- Separate the Reverb Redis connection from cache/queue to prevent contention.
- Set OS-level file descriptor limits (
LimitNOFILE) before raisingmax_connections.