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.jswith Laravel Echo andpusher-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:
broadcastOnpicks the channel. A private channel per user means only that user receives their order updates.broadcastAsgives the event a short public name instead of the full class name.broadcastWithsends 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.
ShouldBroadcastevents wait in the queue. Checkphp artisan queue:workis running, or test withShouldBroadcastNow. - 403 on
/broadcasting/auth. The channel callback returnedfalse, or the request is not authenticated. Check the user and the channel name. - Listener never fires. With
broadcastAs, listen for.order.statuswith the leading dot. - Connection blocked on an HTTPS site. The page is loaded over HTTPS but Echo connects with
ws://. SetREVERB_SCHEME=httpsandREVERB_PORT=443, then rebuild. - Still unsure. Run
php artisan reverb:start --debugand watch whether the message reaches Reverb at all.