Real-Time Features in Laravel with Reverb: A Setup That Survives Production

How we build live dashboards, notifications, and status updates in Laravel apps: Reverb for WebSockets, broadcast events with tight payloads, and the Vue side that keeps a connection through proxies and mobile sleep.

Real-Time Features in Laravel with Reverb: A Setup That Survives Production

Every client asks for it sooner or later: a dashboard that updates without a refresh, notifications that appear the moment something happens, an order status that flips on screen while the warehouse is still scanning the box. For years the standard answer in Laravel was Pusher, and the answer to Pusher was "that's another monthly bill and another third party that can go down". Since Laravel 11, there has been a first-party option that fixes both problems: Reverb, a WebSocket server written in PHP on ReactPHP, designed to run on the same kind of VPS we already manage.

This is the setup we've settled on for live updates in Laravel + Vue apps, and the details that decide whether it holds up.

How the moving parts fit together

Four pieces, and knowing which one does what saves hours of debugging:

  • Reverb holds the WebSocket connections and forwards broadcast events to subscribed clients.
  • Laravel's broadcast layer (the Broadcast facade and event classes) sends events to Reverb, usually through a Redis pub/sub connection.
  • Laravel Echo on the frontend subscribes to channels and fires your callbacks.
  • Pusher-protocol compatibility is what glues Echo to Reverb: Reverb speaks the same protocol, so no frontend changes when you switch.

The flow for one event looks like this: something happens in your app (a job finishes, a payment comes in), you dispatch an event implementing ShouldBroadcast, Laravel publishes it over Redis, Reverb picks it up and pushes a JSON message to every socket subscribed to that channel, and Echo calls your listener.

The server setup, minus the marketing

Reverb installs as a composer package and runs as a long-lived process. On our VPS deployments it runs under Supervisor with two workers, and the details that matter are these:

[program:reverb]
command=php /home/deploy/app/artisan reverb:start --host=127.0.0.1 --port=8080
numprocs=2
autostart=true
autorestart=true
user=deploy

Bind Reverb to localhost and let nginx handle TLS and proxying. A WebSocket upgrade needs the Upgrade and Connection headers passed through, and a long read timeout, or nginx will kill idle connections after a minute:

location /app/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 300s;
}

The frontend needs to know the public hostname, the port, and whether TLS is on, and those three values belong in .env and nowhere else. VITE_REVERB_HOST and friends get baked into the JS bundle at build time, so changing them means a rebuild, and forgetting that is how you end up with a staging frontend shouting at the production socket server.

What to broadcast (and what not to)

The event payload is the part teams get wrong most often. A broadcast event ships its public properties as JSON to every subscriber, which makes it a data-exposure surface, not just a transport detail. Two rules keep it safe:

class OrderShipped implements ShouldBroadcast
{
    use InteractsWithSockets, Dispatchable;

    public function __construct(public Order $order) {}

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

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

Rule one: use broadcastWith() and send the few fields the UI actually renders, never a whole model. A model with a hidden-cast mistake or an accessor chain can leak more than you meant to. Rule two: keep user-specific data on private channels with a channel authorization callback in routes/channels.php. A public channel called orders that "just works" during development is, in production, a list of everyone's orders to anyone who opens devtools.

Channels and events deserve naming discipline the same way your API routes have it: orders.{id} for per-user streams, and a presence channel like warehouse if you want to know who's currently looking at a page. Presence channels are also how you build the "3 people editing this record" warning without any extra infrastructure.

The Vue side

Echo does the subscribing, but the connection still needs care. The two things we always do in a Vue + Inertia app:

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

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: true,
    enabledTransports: ['ws', 'wss'],
});

First, scope subscriptions to the component that cares and disconnect on unmount. If a dashboard widget listens on orders.7 and you never call Echo.leave(), every visit to that page adds another subscription, and Reverb keeps sending duplicated payloads to a connection that already knows everything. Second, treat the socket as a cache invalidation signal, not a second source of truth: when the event arrives, refresh the specific query or refetch the small piece of state the widget needs. Live payloads drift out of sync with your API responses the first time someone changes a serializer, and debugging "the list updated but the totals didn't" is worse than an extra fetch.

Browser tabs sleep on mobile, and when the user comes back the socket can look alive while it's actually dead. Listen for connection_state_changed, and refetch on online and on visible. Five lines of code, and the "it worked until I locked my phone" tickets disappear.

The details that bite

A short list from real deployments:

  • Redis is required for horizontal scale. Reverb processes only see events published through Redis, so with more than one worker or a second server, event broadcasting must go through Redis, not the "log" or "null" drivers you left from development.
  • Queue the broadcast. Broadcasting synchronously means a slow socket push sits inside your web request. Add implements ShouldBroadcast (the queueing variant via ShouldBroadcastNow only for time-critical pings) and let a queue worker deliver it.
  • Rate-limit before you need it. A bug that broadcasts in a loop (one event triggering another, triggering the first) can take down the socket server and, on a small box, the rest of the app with it. Cap events per channel and alert on broadcast volume the way you alert on queue backlog.
  • Monitor the process. Reverb is a long-running process and it deserves the same Supervisor checks and log rotation as your queue workers. A dead Reverb is silent: the page looks fine, nothing breaks, updates simply stop arriving.

Real-time features earn their keep when they're boring: one supervisor program, three nginx lines, thin event payloads, and Echo subscriptions that clean up after themselves. Once that's in place, the same setup carries dashboards, chat, notifications, and presence indicators without a second thought.