Real-Time Notifications with Laravel Reverb and Echo

Set up real-time notifications in Laravel using Reverb and Echo. Install, broadcast a private event, authorise channels, and display a toast — no Pusher needed.

Steven Richardson
Steven Richardson
· 8 min read

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:

  1. Something in your app fires OrderStatusUpdated::dispatch($order)
  2. Laravel queues a broadcast job (requires a running queue worker)
  3. The job sends the payload to Reverb via its internal API
  4. 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/reverb via 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.

FAQ#

Why isn't my broadcast event reaching the browser?

The most common cause is a missing or stopped queue worker — broadcasting always dispatches jobs via your configured queue. Check QUEUE_CONNECTION and ensure a worker is running with php artisan queue:work. In local development, set QUEUE_CONNECTION=sync in .env to bypass the queue entirely and broadcast synchronously. If the queue is running, verify that your Echo listener matches the broadcastAs() name on the event (with the leading dot).

Do I need to use a separate Reverb server or can it run on my app server?

Reverb runs as a separate long-lived PHP process, but it can run on the same physical server as your web app. Use Supervisor to manage both the web app and Reverb independently so they can be restarted without affecting each other. In production, it's common to run Reverb on the same server but on a different port (e.g., 8080 for Reverb, 80/443 for the web app). Proxy Reverb through Nginx to add SSL and proper header handling.

What does "the leading dot on custom event names" mean and why do I need it?

When you define broadcastAs() on an event, you're customizing its name. Laravel's convention is to prefix custom broadcast names with a dot when listening in Echo. If your event defines broadcastAs() as order.updated, Echo must listen with .order.updated — the dot signals Echo that this is a custom name, not the auto-generated default. Forgetting the dot results in silence: the event broadcasts but no listener receives it.

Can Reverb handle thousands of concurrent users, or do I hit a ceiling?

PHP's default stream_select event loop caps at 1,024 simultaneous connections. If you expect more, install the ext-uv PHP extension (pecl install uv), and Reverb will automatically detect and use it, removing the limit. For massive scale, horizontal scaling with Redis pub/sub (REVERB_SCALING_ENABLED=true) and multiple Reverb instances behind a load balancer is the pattern.

How do I know if a user is actually subscribed to a private channel before broadcasting?

You don't need to check — that's Reverb's job. The authorization callback in routes/channels.php is where the gating happens. If the callback returns false, Reverb rejects the subscription before it happens. Once a client is subscribed (callback returned true), the event is broadcast only to that channel; unsubscribed users will never see it. Your event class doesn't need to validate audience — the channel definition handles it.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.