Real-Time Notifications with Laravel Reverb and Echo
You want to push a notification to the browser the moment an order status changes. No polling. No Pusher bill. Just a WebSocket server running on your own infrastructure.
Laravel Reverb is the answer. It ships as a first-party package, integrates with the existing broadcasting stack, and takes about five minutes to install. The full picture — event → channel → Echo → UI — does need some assembly. This post walks through every step.
How Laravel Reverb Fits the Broadcasting Stack
Laravel's broadcasting system has three moving parts: the server side fires an event that implements ShouldBroadcast, a driver transmits it over a transport, and the client side listens with Laravel Echo.
Reverb is the driver. It runs as a long-lived PHP process, handles WebSocket connections directly, and replaces the need for an external Pusher or Ably account. Echo's API stays identical regardless of which driver you use — swap Pusher for Reverb and your frontend code barely changes.
The flow for a notification looks like this:
- Something in your app fires
OrderStatusUpdated::dispatch($order) - Laravel queues a broadcast job (requires a running queue worker)
- The job sends the payload to Reverb via its internal API
- Reverb pushes the message to any connected Echo clients subscribed to the right channel
One important detail before diving in: broadcasting dispatches jobs via the queue. If you don't have a queue worker running, no events will reach the browser. Use QUEUE_CONNECTION=sync in local development if you want to skip the queue worker during setup.
Installing Reverb
Run the interactive broadcasting installer:
php artisan install:broadcasting
Laravel 12 will prompt you to confirm Reverb as the driver and install all dependencies. If you prefer to be explicit:
php artisan install:broadcasting --reverb
That command:
- Publishes
config/reverb.php - Adds Reverb environment variables to
.env - Installs
laravel/reverbvia Composer - Creates
routes/channels.php
Then install the JavaScript dependencies:
npm install --save-dev laravel-echo pusher-js
Reverb uses the Pusher protocol, so pusher-js is required even though you're not using Pusher.
Your .env will now contain variables split into two groups — the server address (where Reverb actually binds) and the public address (what Echo connects to). In local development they can be the same:
REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
# Public-facing — what Vite exposes to the browser
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
Don't mix up REVERB_HOST/REVERB_PORT (public) with REVERB_SERVER_HOST/REVERB_SERVER_PORT (internal bind address). It's a common source of connection failures.
Start the server in development:
php artisan reverb:start
Reverb listens on 0.0.0.0:8080 by default.
Creating a Laravel Reverb Broadcast Event
Generate the event class:
php artisan make:event OrderStatusUpdated
Then implement it:
<?php
namespace App\Events;
use App\Models\Order;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderStatusUpdated implements ShouldBroadcast
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Order $order) {}
/**
* Broadcast on a private channel scoped to the order's owner.
*/
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel("orders.{$this->order->user_id}");
}
/**
* Use a dot-prefixed name — Echo listens with the leading dot.
*/
public function broadcastAs(): string
{
return 'order.updated';
}
/**
* Only send what the client needs.
*/
public function broadcastWith(): array
{
return [
'order_id' => $this->order->id,
'status' => $this->order->status,
];
}
}
Fire it anywhere in your application:
OrderStatusUpdated::dispatch($order);
Private Channels and Authorization
Public channels broadcast to anyone. For user-specific notifications you need a private channel — Reverb won't push anything to it until the user's session is authorised.
The installer creates routes/channels.php. Register the channel auth callback there:
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
// Only allow the channel owner to subscribe
Broadcast::channel('orders.{userId}', function (User $user, int $userId) {
return $user->id === $userId;
});
Laravel makes an internal HTTP request to /broadcasting/auth when Echo tries to subscribe. The authenticated user is injected automatically — the callback just needs to return true or false.
Connecting Laravel Echo on the Frontend
Echo needs to know about Reverb. Add this to resources/js/bootstrap.js (or your own entry point):
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 ?? 8080,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
Then listen for the event. Note the leading dot on .order.updated — that's required when you define a custom broadcastAs() name:
// Pass the authenticated user's ID from your Blade layout
const userId = window.authUserId;
Echo.private(`orders.${userId}`)
.listen('.order.updated', (event) => {
// Dispatch a browser event for Alpine.js (or any other listener)
window.dispatchEvent(
new CustomEvent('order-updated', { detail: event })
);
});
If you're using Blade, expose the user ID safely:
<script>
window.authUserId = {{ auth()->id() }};
</script>
Displaying the Notification as a Toast
A minimal Alpine.js toast that reacts to the Echo event:
<div
x-data="{ show: false, message: '' }"
@order-updated.window="
message = 'Order #' + $event.detail.order_id + ' is now ' + $event.detail.status;
show = true;
setTimeout(() => show = false, 4000)
"
x-show="show"
x-transition
class="fixed bottom-4 right-4 bg-green-600 text-white px-4 py-2 rounded shadow-lg"
>
<span x-text="message"></span>
</div>
The @order-updated.window directive listens on window for the custom browser event dispatched by Echo. No JavaScript framework boilerplate, no Livewire polling — just a WebSocket push landing straight in the UI.
Running Reverb in Production
Start Reverb manually for a quick sanity check:
php artisan reverb:start --host=0.0.0.0 --port=8080
For production, keep it alive with Supervisor:
[program:reverb]
command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080
directory=/var/www/html
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/reverb.log
When you deploy new code, restart Reverb gracefully — reverb:restart signals the running process to reload after finishing its current work:
php artisan reverb:restart
Gotchas and Edge Cases
Queue worker required. Broadcasting dispatches jobs. If your queue worker is down, events will queue up silently and nothing reaches the browser. During local development you can set QUEUE_CONNECTION=sync to bypass the queue entirely.
SSL termination. Don't run Reverb directly on port 443 with a TLS certificate. Run it on 8080 and proxy it through Nginx. Add the Upgrade header to your Nginx config or WebSocket connections will be dropped:
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $http_host;
proxy_pass http://127.0.0.1:8080;
}
The 1,024-connection ceiling. The default PHP stream_select event loop caps at 1,024 simultaneous connections. For anything beyond that, install ext-uv (pecl install uv) — Reverb detects and uses it automatically.
Reverb doesn't run on Laravel Vapor. Vapor is a serverless platform built on AWS Lambda; long-running processes aren't supported. If you're on Vapor, use Pusher or Ably instead. Laravel Cloud now offers managed Reverb infrastructure if you want self-hosted WebSockets without managing the server yourself.
Horizontal scaling needs Redis. If you run multiple app servers, each has its own Reverb instance. Events broadcast on server A won't reach clients connected to server B unless you enable Redis pub/sub:
REVERB_SCALING_ENABLED=true
Reverb uses your app's default Redis connection. Once enabled, deploy multiple reverb:start instances behind a load balancer.
The leading dot on custom event names. If you define broadcastAs(), Echo must listen with a leading dot: .order.updated, not order.updated. Forget the dot and you'll hear silence — no error, just nothing.
Wrapping Up
Reverb removes the main excuse for avoiding real-time features in a self-hosted Laravel application. Install it, fire a ShouldBroadcast event on a private channel, wire up Echo, and you have WebSocket notifications running without any third-party accounts or per-message billing. For production, keep the queue worker running, proxy SSL through Nginx, and add Supervisor to manage the Reverb process.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.