Laravel Reverb in production: live dashboards without a third-party socket service

Running Laravel Reverb for live dashboards: queued broadcasts, private channel auth, the reverse proxy, open-file limits, Redis scaling and monitoring.

live
On this page

When Reverb is the right choice

Laravel Reverb is a first-party WebSocket server, open source under the MIT licence. You start it with php artisan reverb:start next to your app, and it speaks the Pusher protocol, so Laravel’s broadcasting layer and Laravel Echo work with it unchanged. There is no per-connection or per-message bill, and your event data never leaves your own servers.

The trade-off is operational. A hosted service handles scaling, limits and uptime for you. With Reverb, those are your job. For live dashboards inside a Laravel product that is usually a good deal, because the traffic is predictable: a known number of signed-in users watching a known set of screens. It is the default transport we reach for on real-time projects built on Laravel.

Broadcasting events: queued or immediate

An event that implements ShouldBroadcast is pushed onto your queue, and a worker sends it to Reverb. That keeps the request fast and means a slow socket server never blocks a user. ShouldBroadcastNow sends it on the sync queue instead, inside the current request.

app/Events/DashboardUpdated.phpPHP
class DashboardUpdated implements ShouldBroadcast, ShouldRescue
{
    public function __construct(
        public int $teamId,
        public array $totals,
    ) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('dashboard.'.$this->teamId)];
    }

    public function broadcastQueue(): string
    {
        return 'broadcasts';
    }
}

Three details matter in production:

  • A dedicated queue. broadcastQueue() puts broadcasts on their own queue, so a backlog of exports or emails cannot delay the dashboard by minutes.
  • After commit. If an event is dispatched inside a database transaction, add ShouldDispatchAfterCommit (or enable after_commit on the queue connection). Otherwise a client can receive an update and fetch data that is not committed yet.
  • Failures stay quiet. ShouldRescue reports a broadcasting exception instead of throwing it. A live dashboard is supplementary, so a queue or Reverb outage should never turn a saved order into an error page.

By default every public property is sent to the client. Add broadcastWith() when the event carries a model or anything the browser should not see.

Authorising private and presence channels

Dashboard data belongs on private channels. When Echo subscribes, Laravel calls the matching callback in routes/channels.php through the /broadcasting/auth route, with the signed-in user and the wildcard values from the channel name.

routes/channels.phpPHP
Broadcast::channel('dashboard.{teamId}', function (User $user, int $teamId) {
    return $user->team_id === $teamId;
});

Broadcast::channel('dashboard-viewers.{teamId}', function (User $user, int $teamId) {
    if ($user->team_id === $teamId) {
        return ['id' => $user->id, 'name' => $user->name];
    }
});

A private channel callback returns true or false. A presence channel callback returns an array of user data instead, which is how the second channel can show who else is looking at the dashboard. Keep that array small, because every member receives it.

Treat these callbacks like any other authorisation rule: use the same policies as your HTTP routes, and write a feature test that proves a user from one team is refused another team’s channel. On the Reverb side, config/reverb.php ships with allowed_origins set to ['*']. Narrow it to your own domains.

Listening with Laravel Echo

On the client, Echo’s React, Vue and Svelte packages provide hooks. useEcho subscribes to a private channel and leaves it when the component unmounts. useConnectionStatus reports whether the socket is connected, connecting, reconnecting, disconnected or failed.

resources/js/components/DashboardTotals.jsxJavaScript
export function DashboardTotals({ teamId, initial }) {
    const [totals, setTotals] = useState(initial);
    const status = useConnectionStatus();

    useEcho(`dashboard.${teamId}`, "DashboardUpdated", (e) => {
        setTotals(e.totals);
    });

    useEffect(() => {
        if (status !== "connected") return;
        fetch(`/api/teams/${teamId}/dashboard`)
            .then((r) => r.json())
            .then(setTotals);
    }, [status, teamId]);

    return <span class="xml"><TotalsGrid totals={totals} stale={status !== "connected"} />;
}

The refetch is the important part. WebSocket messages sent while a laptop was asleep or a phone was in a tunnel are gone. Treat broadcasts as hints that something changed, and load the current state over HTTP every time the connection comes back. Show a visible “stale” state while it is down, so nobody makes a decision on numbers that stopped moving.

Putting Reverb behind a reverse proxy

Reverb listens on 0.0.0.0:8080 by default. In production it sits on a private port, and your web server terminates TLS and forwards the WebSocket upgrade. Two pairs of environment variables are easy to mix up: REVERB_SERVER_HOST and REVERB_SERVER_PORT are where the Reverb process listens, while REVERB_HOST and REVERB_PORT are the public address Laravel sends broadcasts to, such as ws.example.com on port 443.

/etc/nginx/sites-enabled/ws.example.comNginx
location / {
    proxy_http_version 1.1;
    proxy_set_header Host $http_host;
    proxy_set_header Scheme $scheme;
    proxy_set_header SERVER_PORT $server_port;
    proxy_set_header REMOTE_ADDR $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "Upgrade";

    proxy_pass http://0.0.0.0:8080;
}

Reverb accepts WebSocket connections at /app and receives broadcasts from Laravel at /apps, so if you proxy a path rather than the whole host, make sure both reach it. With Caddy, reverse_proxy 127.0.0.1:8080 handles the upgrade without extra headers. Note that Caddy closes open WebSockets whenever its config is reloaded, unless you set stream_close_delay. Every client reconnecting at once is exactly the load spike you want to avoid.

Process supervision, open files and the event loop

Reverb is a long-running process, so it needs a supervisor to start it on boot and restart it when it exits. With Supervisor, raise minfds as well, because each WebSocket connection holds an open file and child processes inherit Supervisor’s limit.

/etc/supervisor/supervisord.confText
[supervisord]
minfds=10000

[program:reverb]
command=php /var/www/app/artisan reverb:start
user=deploy
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/reverb.log

Because the process stays in memory, it does not see new code. Add php artisan reverb:restart to every deploy: it closes connections gracefully, and Supervisor starts a fresh server. Clients reconnect on their own, which is one more reason the refetch above matters.

Then check every limit between the browser and the process. The lowest one wins.

Limits that cap how many connections one Reverb server can hold
Where it is setWhat to do
Open files per userulimit -n, /etc/security/limits.confRaise above your expected connections
Event loopReactPHP, stream_select by defaultInstall ext-uv before you pass about 1,000 connections
Supervisorminfds in [supervisord]Match the open-file limit
Nginxworker_rlimit_nofile, worker_connectionsRaise both to match
Local ports/proc/sys/net/ipv4/ip_local_port_rangeScale out rather than stretch one server

The event loop is the one teams miss. stream_select needs no extension but is typically limited to 1,024 open files. Reverb switches to an ext-uv loop automatically when the extension is installed (pecl install uv). Also note that limits.conf applies to login sessions. If Supervisor itself runs as a systemd service, set the limit on that unit too, and confirm the real value in /proc/<pid>/limits for the running Reverb process.

Scaling across servers with Redis

When one server is not enough, set REVERB_SCALING_ENABLED=true and run reverb:start on several servers behind a load balancer. Each server uses Redis publish and subscribe to pass incoming messages to the others, so a broadcast reaches every client wherever it is connected. All Reverb servers must share one central Redis server, which is your default Redis connection unless you configure otherwise.

That Redis server is now on the path of every live update, so give it the same attention as your database: monitoring, memory headroom and a plan for restarts. Unique jobs and debouncing, used in the next section, also depend on a cache store that every app server shares.

Throttle updates instead of broadcasting every change

The fastest way to overload a dashboard is a model observer that broadcasts on every save. An import of 5,000 rows becomes 5,000 messages to every open tab, and each one makes the browser re-render. Nobody can read numbers that change 50 times a second anyway.

Instead, each change marks the dashboard as dirty, and one job per team computes the totals and broadcasts a single summary.

app/Jobs/RefreshDashboard.phpPHP
class RefreshDashboard implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
    use Queueable;

    public function __construct(public int $teamId) {}

    public function uniqueId(): string
    {
        return (string) $this->teamId;
    }

    public function uniqueFor(): int
    {
        return 30;
    }

    public function handle(DashboardTotals $totals): void
    {
        broadcast(new DashboardUpdated($this->teamId, $totals->for($this->teamId)));
    }
}
app/Observers/OrderObserver.phpPHP
public function saved(Order $order): void
{
    RefreshDashboard::dispatch($order->team_id)->delay(now()->addSeconds(2));
}

The first change takes a lock and queues a job two seconds out. Every change in that window finds the lock held and is dropped. ShouldBeUniqueUntilProcessing releases the lock just before the job runs, so a change that lands while the totals are being computed schedules the next refresh instead of being lost. uniqueFor() makes sure a lost job cannot freeze the dashboard for good. On Laravel 13, the DebounceFor attribute with a maxWait argument is an alternative when you only want the latest dispatch to run.

We pick the window per screen with the client: an operations board may need one or two seconds, a sales overview is fine with five or ten. Keep payloads to totals and IDs. The shipped config/reverb.php sets max_request_size and max_message_size to 10,000 bytes, and a dashboard summary should be far below that.

A live dashboard needs to be current to within a second or two, not to the millisecond.

What to monitor

Reverb integrates with Laravel Pulse. Add its recorders to config/pulse.php and the matching cards to your Pulse dashboard to see connections and messages over time.

config/pulse.phpPHP
'recorders' => [
    \Laravel\Reverb\Pulse\Recorders\ReverbConnections::class => [
        'sample_rate' => 1,
    ],
    \Laravel\Reverb\Pulse\Recorders\ReverbMessages::class => [
        'sample_rate' => 1,
    ],
    // ...
],

Connection counts are collected by polling, so the pulse:check daemon must run on your Reverb server, and on only one of them when you scale out. Beyond the Pulse cards, we watch four things:

  1. Broadcast queue wait time. If the broadcasts queue backs up, the dashboard lags even though Reverb is healthy.
  2. Connections against your limits. Alert well before the lowest limit in the table above, not at it.
  3. Pruned connections. Reverb dispatches a ConnectionPruned event for stale connections. A sudden rise usually points to the proxy or the network, not to users.
  4. The process itself. Memory and restarts of the Reverb process, from Supervisor’s logs or your server monitoring.

Leave reverb:start --debug for local troubleshooting. Reverb omits debug output by default for performance, and a production log full of message payloads helps nobody. Before launch we load-test the whole path, from queue to proxy to browser, against the traffic we expect.

Summary

Reverb gives a Laravel app live dashboards without a third-party socket service, as long as you run it like the production service it is. Queue broadcasts on their own queue, authorise every channel, raise every limit between the browser and the process, throttle updates to summaries, and refetch state on reconnect. Add Redis when one server is no longer enough, and watch the queue as closely as the sockets.

If you want live features added to an existing app or built into a new one, see how we approach Laravel development, or send us a short brief.

Frequently asked questions

Is Laravel Reverb free?

Yes. Reverb is an open-source, first-party Laravel package under the MIT licence. You pay for the servers it runs on and the time to operate it, rather than a per-connection or per-message fee to a hosted service.

How many connections can Laravel Reverb handle?

It depends on the server and its limits. The default event loop is typically capped at 1,024 open files, so install the uv extension before you pass about 1,000 connections, and raise the open-file limits in the operating system, Supervisor and Nginx. Beyond one server, scale horizontally with Redis and load-test against your expected traffic.

Can Laravel Reverb replace Pusher?

In most Laravel apps, yes. Reverb uses the Pusher protocol, so your broadcast events, channel authorisation and Laravel Echo code stay the same. You change the broadcast connection and the Echo configuration, then take on hosting, scaling and monitoring yourself.

Keep reading

All articles

Have a project in mind?

Four questions, two minutes. A fixed-scope proposal within 48 hours.

A fixed-scope proposal within 48 hours.