Cover image for Real-Time Notifications with Laravel 12 Reverb and Vue 3: A Production Guide

At a glance

Reading time

~200 words/min

Published

7 hours ago

Apr 29, 2026

Views

4

All-time total

Real-Time Notifications with Laravel 12 Reverb and Vue 3: A Production Guide

Polling an API every five seconds to check if something changed is the kind of code that quietly costs you money in production. Each poll is a database query, a serialization pass, a network round-trip, and a wasted response 95% of the time. Real-time flips the model: the server pushes when something actually happens. With Laravel 12 shipping Reverb as a first-class WebSocket server, you no longer need Pusher's hosted plan or a Soketi side-car. You can run the whole pipeline in-process with the same Laravel codebase, the same auth guard, and the same broadcast events your jobs already dispatch.

 

This post walks through a complete setup: Reverb on the server, broadcasting a notification event over a private channel, and consuming it on a Vue 3 client with Laravel Echo. Every snippet is production-grade no mock-only configs, no skipped auth.

Why Reverb (and why now)

Before Laravel 11, the practical real-time stack was Pusher (paid, hosted) or Soketi (self-hosted Pusher-protocol server). Both worked, but both meant adding another moving piece. Reverb is Laravel's own WebSocket server, written in PHP using ReactPHP under the hood, and it speaks the Pusher protocol which means every existing Echo client and every broadcast() call drops in unchanged. You install it with php artisan reverb:install and run it with php artisan reverb:start.

 

  • Single deployment : same repo, same Composer install, no extra Node sidecar.
  • Native scaling : Reverb supports horizontal scaling via Redis pub/sub.
  • Free : no per-connection pricing tiers.
  • Pusher-protocol compatible : clients written for Pusher work without changes.

Step 1 : Install and configure Reverb

Inside a fresh or existing Laravel 12 project, run the install command. It will register the broadcasting service provider, add the Reverb config file, and write out a sane .env block.

composer require laravel/reverb
php artisan reverb:install

The installer adds these keys to your .env. Treat them like any other secret, never commit them.

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=local
REVERB_APP_KEY=local-key
REVERB_APP_SECRET=local-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}"

Open config/reverb.php if you want to tune ping intervals, allowed origins, or scaling. The defaults are fine for development. For production behind a reverse proxy, you'll want to terminate TLS at nginx (or a load balancer) and proxy WebSocket upgrades to Reverb on port 8080.

Step 2 : Define a broadcast event

Imagine an admin gets notified every time an order is placed. Create an event that implements ShouldBroadcast:

// app/Events/OrderPlaced.php
namespace App\Events;

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

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

    public function __construct(public Order $order) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('admin.orders')];
    }

    public function broadcastWith(): array
    {
        return [
            'id'         => $this->order->id,
            'total'      => $this->order->total,
            'customer'   => $this->order->customer_name,
            'placed_at'  => $this->order->created_at->toIso8601String(),
        ];
    }

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

A few things worth noticing:

  • PrivateChannel : only authenticated, authorized users can subscribe. We'll wire authorization in step 3.
  • broadcastWith() : explicitly choose what gets sent. Never broadcast the whole model; only the fields the client legitimately needs.
  • broadcastAs() : gives the event a stable public name. The frontend listens for order.placed, not the fully-qualified PHP class name.

Step 3 : Authorize the private channel

Open routes/channels.php and define who is allowed onto admin.orders:

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

Broadcast::channel('admin.orders', function (User $user) {
    return $user->is_admin === true;
});

Channel authorization runs once on subscribe, not on every event. If you change a user's role mid-session, force a re-subscribe (or expire their token) so a former admin doesn't keep receiving messages.

Step 4 : Dispatch the event

Anywhere in your application : a controller, a job, an observer call:

use App\Events\OrderPlaced;

OrderPlaced::dispatch($order);

If you implement ShouldBroadcastAfterCommit instead of ShouldBroadcast, the event will only broadcast after the surrounding database transaction commits. That's almost always what you want it prevents clients from seeing an "order placed" notification for a row that the transaction later rolls back.

Step 5 : Vue 3 client with Laravel Echo

Install Echo and the Pusher protocol client (Reverb speaks Pusher protocol):

npm install --save-dev laravel-echo pusher-js

Initialise Echo once, ideally in resources/js/echo.ts:

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

window.Pusher = Pusher;

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

Now use it from a Vue 3 component via a composable:

// resources/js/composables/useOrderStream.ts
import { onMounted, onUnmounted, ref } from 'vue';
import { echo } from '@/echo';

interface OrderEvent {
    id: number;
    total: number;
    customer: string;
    placed_at: string;
}

export function useOrderStream() {
    const orders = ref<OrderEvent[]>([]);
    let channel: ReturnType<typeof echo.private> | null = null;

    onMounted(() => {
        channel = echo.private('admin.orders')
            .listen('.order.placed', (e: OrderEvent) => {
                orders.value.unshift(e);
            });
    });

    onUnmounted(() => {
        echo.leave('admin.orders');
        channel = null;
    });

    return { orders };
}

The leading dot in .order.placed tells Echo this is a custom broadcast name, not a fully-qualified class name. Skipping the dot is the most common cause of "events fire on the server but the client never receives them" Echo would otherwise look for App\Events\OrderPlaced.

Step 6 : Production deployment notes

In production, Reverb runs as a long-lived process. Use a process supervisor like Supervisor or systemd:

[program:reverb]
process_name=%(program_name)s
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/reverb.log
stopwaitsecs=3600

Behind nginx, proxy /app/ or a dedicated subdomain to Reverb with WebSocket upgrade headers:

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_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_read_timeout 86400s;
}

For multi-server setups, point Reverb at Redis as a scaling driver. Each Reverb instance subscribes to the same Redis channels, so broadcast() on any web node fans out to clients connected to any Reverb node.

Common pitfalls

  • Events dispatch but never arrive client-side : almost always the leading-dot issue, or the channel auth callback returns false.
  • CSRF errors on the auth endpoint : make sure your SPA loads /sanctum/csrf-cookie first or the broadcasting auth route is in routes/web.php.
  • Connection drops every 30s : your reverse proxy is killing idle WebSockets. Bump proxy_read_timeout.
  • Memory growth : Reverb is long-lived PHP. Watch for unintended object retention; restart workers periodically as a safety net.

Wrapping up

Reverb collapses what used to be three separate concerns, a WebSocket server, a broadcasting bridge, a JS client down to one stack you already deploy. For most Laravel apps that need real-time, that's the simplest production-grade story available in 2026. Start with one event, one channel, one component, and grow from there. Once the pipeline is in place, every new "should be live" feature is a one-line dispatch.

Share

Related posts

CRUD Operations In Laravel 8

This tutorial is created to illustrate the basic CRUD (Create , Read, Update, Delete) operation using SQL with Laravel 8. Laravel is one of the fastest-growing frameworks for PHP.

4 years ago

Scheduling Tasks with Cron Job in Laravel 5.8

Cron Job is used to schedule tasks that will be executed every so often. Crontab is a file that contains a list of scripts, By editing the Crontab, You can run the scripts periodically.

7 years ago

Connecting Multiple Databases in Laravel 5.8

This tutorial is created to implement multiple database connections using mysql. Let’s see how to configure multiple database connections in Laravel 5.8.

6 years ago

Integrating Google ReCaptcha in Laravel 5.8

reCAPTCHA is a free service from Google. It’s a CAPTCHA-like system designed to recognize that the user is human and, at the same time, assist in the digitization of books. It helps to protects your w

6 years ago

Clearing Route, View, Config Cache in Laravel 5.8

Sometimes you may face an issue that the changes to the Laravel Project may not update on the web. This occures when the application is served by the cache. In this tutorial, You’ll learn to Clear App

6 years ago