Navod Hansajith
All posts

Real-time updates with Laravel Reverb

17 October 2026, 4 min read, Laravel, Reverb, WebSockets

Laravel Reverb is a WebSocket server that ships with Laravel. You run it on your own server, so there are no per-message fees. It uses the Pusher protocol, which means Laravel Echo and other Pusher clients connect to it without changes.

This guide sends a live "order status changed" update to the user who owns the order. It covers:

  • installing Reverb and Echo
  • a broadcast event on a private channel
  • listening in the browser, and from token-based clients
  • running Reverb in production

1. Install broadcasting

php artisan install:broadcasting

Choose Reverb when asked, and let it install the Node dependencies. The command:

  • installs laravel/reverb
  • creates routes/channels.php
  • adds resources/js/echo.js with Laravel Echo and pusher-js
  • writes the Reverb settings to .env
BROADCAST_CONNECTION=reverb

REVERB_APP_ID=...
REVERB_APP_KEY=...
REVERB_APP_SECRET=...
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http

VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

The VITE_ values are read when the front end is built, so rebuild your assets after changing them.

2. Create the event

php artisan make:event OrderStatusUpdated
namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderStatusUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public Order $order)
    {
    }

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

    public function broadcastAs(): string
    {
        return 'order.status';
    }

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

What each method does:

  • broadcastOn picks the channel. A private channel per user means only that user receives their order updates.
  • broadcastAs gives the event a short public name instead of the full class name.
  • broadcastWith sends only the fields the client needs. Without it, every public property is serialised, including the whole order model.

3. Authorise the channel

In routes/channels.php:

use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('orders.{userId}', function (User $user, int $userId) {
    return $user->id === $userId;
});

When a client subscribes to private-orders.5, Laravel calls this function with the logged-in user. Only user 5 gets true; everyone else gets a 403.

4. Broadcast when the status changes

use App\Events\OrderStatusUpdated;

$order->update(['status' => 'shipped']);

OrderStatusUpdated::dispatch($order);

ShouldBroadcast events are queued, so a queue worker must be running or nothing is sent. To skip the queue, which is useful while debugging, implement ShouldBroadcastNow instead.

5. Listen in the browser

install:broadcasting already configured Echo in resources/js/echo.js:

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

window.Pusher = Pusher;

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 ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

Put the user ID in your layout:

<meta name="user-id" content="{{ auth()->id() }}">

Then subscribe and update the page:

const userId = document.querySelector('meta[name="user-id"]').content;

window.Echo.private(`orders.${userId}`)
    .listen('.order.status', (event) => {
        const row = document.querySelector(`[data-order="${event.id}"] .status`);
        if (row) row.textContent = event.status;
    });

Note the leading dot in .order.status. It tells Echo to use the name from broadcastAs as it is, without adding the App\Events namespace.

6. Run it locally

You need four processes, each in its own terminal:

php artisan serve
php artisan reverb:start --debug
php artisan queue:work
npm run dev

--debug prints every connection and message, which is the quickest way to see whether an event reached Reverb.

7. Mobile apps and SPAs with tokens

The default /broadcasting/auth route uses the session cookie. A Flutter app or an SPA on another domain authenticates with a Sanctum token instead. In bootstrap/app.php, remove the channels: line from withRouting() and add:

->withBroadcasting(
    __DIR__.'/../routes/channels.php',
    ['prefix' => 'api', 'middleware' => ['api', 'auth:sanctum']],
)

The client then sends its token when it authorises a channel:

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: 'your-reverb-app-key',
    wsHost: 'ws.example.com',
    wssPort: 443,
    forceTLS: true,
    enabledTransports: ['ws', 'wss'],
    authEndpoint: 'https://example.com/api/broadcasting/auth',
    auth: { headers: { Authorization: `Bearer ${token}` } },
});

In Flutter, any Pusher-protocol client package can connect to Reverb in the same way: point it at your Reverb host and use the same auth endpoint and bearer token.

8. Run Reverb in production

Run Reverb on an internal port and put Nginx in front of it on its own subdomain with TLS. In .env:

REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080

REVERB_HOST=ws.example.com
REVERB_PORT=443
REVERB_SCHEME=https

The REVERB_SERVER_* values are where Reverb listens. REVERB_HOST, REVERB_PORT and REVERB_SCHEME are the public address that browsers and your app use. Run npm run build after changing them.

The Nginx site for ws.example.com:

server {
    listen 80;
    server_name ws.example.com;

    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;
    }
}
sudo certbot --nginx -d ws.example.com

Keep Reverb and the queue worker running with Supervisor, for example in /etc/supervisor/conf.d/app.conf:

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

[program:queue]
command=php /var/www/app/artisan queue:work --sleep=3 --tries=3
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/queue.log
sudo supervisorctl reread
sudo supervisorctl update

minfds raises the open file limit. Each WebSocket connection uses one, and the default limit of 1024 is reached quickly.

After each deploy, restart both so they load the new code:

php artisan reverb:restart
php artisan queue:restart

When events do not arrive

  • No queue worker. ShouldBroadcast events wait in the queue. Check php artisan queue:work is running, or test with ShouldBroadcastNow.
  • 403 on /broadcasting/auth. The channel callback returned false, or the request is not authenticated. Check the user and the channel name.
  • Listener never fires. With broadcastAs, listen for .order.status with the leading dot.
  • Connection blocked on an HTTPS site. The page is loaded over HTTPS but Echo connects with ws://. Set REVERB_SCHEME=https and REVERB_PORT=443, then rebuild.
  • Still unsure. Run php artisan reverb:start --debug and watch whether the message reaches Reverb at all.

Need help with a Laravel project?

I am available for freelance work.

Get a quote