Tech Verse Logo
Enable dark mode
Real-Time Laravel Reverb and Next.js WebSockets

Real-Time Laravel Reverb and Next.js WebSockets

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

Ditch Third-Party WebSocket SaaS

For years, running real-time events in Laravel meant paying for Pusher or wrestling with packages that fell out of maintenance. Laravel Reverb changes that. It's a first-party WebSocket server written in PHP, built directly on top of ReactPHP. It handles thousands of concurrent connections on a modest single vCPU VPS while consuming under 50MB of RAM.

If you're upgrading or starting a new build on Laravel 12 and PHP 8.3, Reverb is the choice to make. Pairing it with a Next.js 16 frontend running React 19 gives you instant state sync across clients, provided you handle private channel authorization properly. Here is how to configure both ends without hitting the classic 403 authorization traps.

Installing and Configuring Reverb in Laravel 12

Setting up Reverb starts with installing the broadcasting stack in your Laravel 12 app. Run this artisan command:

php artisan install:broadcasting

This command installs the laravel/reverb package, generates your configuration in config/reverb.php, creates the routes/channels.php file, and populates your .env file with generated credentials. Your .env file will contain keys like this:

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=891234
REVERB_APP_KEY=p9q3r8x2k1m5n0a7
REVERB_APP_SECRET=z4y3x2w1v0u9t8s7
REVERB_HOST="127.0.0.1"
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}"

When running in local development without SSL, REVERB_SCHEME=http and port 8080 work fine. In production, however, your frontend talks over HTTPS, meaning WebSockets must use wss:// on port 443. Never expose port 8080 directly to the internet in production. Put NGINX or Caddy in front of Reverb as a reverse proxy to terminate TLS and forward traffic to 127.0.0.1:8080.

Creating a Broadcastable Event and Private Channel

Let's build a real scenario: a live order status tracker. When an order changes state, we broadcast an event on a private channel so only the user who owns that order receives the update.

Generate the event using Artisan:

php artisan make:event OrderStatusUpdated

Open the generated file and implement the ShouldBroadcast interface. By default, Laravel queues broadcast events. If your queue worker isn't running, your WebSockets won't fire. Use ShouldBroadcastNow during initial testing if you want synchronous firing, but stick to ShouldBroadcast in production so event serialization doesn't slow down your HTTP responses.

<?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 OrderStatusUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public Order $order)
    {
    }

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

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

Always define broadcastWith() explicitly. If you omit it, Laravel sends every public property on the event model. That frequently leaks internal fields like database IDs of soft-deleted relations or system timestamps you never intended to send over the wire.

Authorizing Private Channels

Because we used PrivateChannel, Laravel requires authentication before a client can subscribe to orders.{orderId}. Next.js will issue an HTTP POST request to /broadcasting/auth when subscribing. We define who can listen in routes/channels.php:

<?php

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

Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
    $order = Order::find($orderId);

    if (! $order) {
        return false;
    }

    return (int) $user->id === (int) $order->user_id;
});

Here is where developers get burned. If your API uses Laravel Sanctum or Passport tokens instead of standard session cookies, the default broadcast authorization route will fail with a 401 Unauthorized or 403 Forbidden error. To fix this, open bootstrap/app.php or your broadcast setup and ensure the broadcast auth routes apply the auth:sanctum middleware explicitly:

Broadcast::routes(['middleware' => ['auth:sanctum']]);

If you miss this step, incoming HTTP requests from your Next.js app to /broadcasting/auth won't carry session cookies, Sanctum won't inspect the Authorization: Bearer header, and your channel connection will immediately drop.

Connecting from Next.js 16 and React 19

On the frontend, install laravel-echo and pusher-js. Even though Reverb is a native PHP server, it implements the Pusher protocol, making pusher-js the correct client library.

npm install laravel-echo pusher-js

In Next.js 16 App Router, WebSockets belong strictly inside Client Components. Create a custom hook that passes your API authentication token to the broadcast endpoint.

'use client';

import { useEffect, useState } from 'react';
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

declare global {
    interface Window {
        Pusher: typeof Pusher;
        Echo: Echo;
    }
}

export function useOrderTracker(orderId: number, authToken: string) {
    const [status, setStatus] = useState<string | null>(null);

    useEffect(() => {
        if (!authToken) return;

        window.Pusher = Pusher;

        const echo = new Echo({
            broadcaster: 'reverb',
            key: process.env.NEXT_PUBLIC_REVERB_APP_KEY,
            wsHost: process.env.NEXT_PUBLIC_REVERB_HOST || '127.0.0.1',
            wsPort: Number(process.env.NEXT_PUBLIC_REVERB_PORT) || 8080,
            wssPort: Number(process.env.NEXT_PUBLIC_REVERB_PORT) || 8080,
            forceTLS: (process.env.NEXT_PUBLIC_REVERB_SCHEME || 'http') === 'https',
            enabledTransports: ['ws', 'wss'],
            authEndpoint: `${process.env.NEXT_PUBLIC_API_URL}/broadcasting/auth`,
            auth: {
                headers: {
                    Authorization: `Bearer ${authToken}`,
                    Accept: 'application/json',
                },
            },
        });

        const channel = echo.private(`orders.${orderId}`);

        channel.listen('OrderStatusUpdated', (data: { status: string }) => {
            setStatus(data.status);
        });

        return () => {
            channel.stopListening('OrderStatusUpdated');
            echo.disconnect();
        };
    }, [orderId, authToken]);

    return status;
}

Notice the authEndpoint and auth.headers setup. When Echo attempts to join private-orders.123 (Laravel automatically prefixes private channels with private- behind the scenes), it sends a POST request with the channel name and socket ID to your backend. The Bearer token ensures Sanctum authenticates the request and evaluates your routes/channels.php callback.

Production Pitfalls and Common Debugging

When deploying this stack, three main operational problems occur repeatedly:

  • CORS blocking broadcast authorization: Next.js runs on a different domain or port during local development than Laravel. Ensure config/cors.php allows broadcasting/auth and sets supports_credentials to true if using cookies.
  • Unstarted Reverb server in production: Running php artisan reverb:start in a terminal isn't enough. You need Supervisor or Systemd to keep the process alive in production. Use php artisan reverb:start --port=8080 managed under Supervisor alongside your queue workers.
  • Event names mismatch: If your broadcast event doesn't define a custom broadcastAs() method, Laravel appends the full PHP class namespace to the client listener. So listening for OrderStatusUpdated won't match unless you either listen for .OrderStatusUpdated or explicitly implement broadcastAs() in your PHP class.

If you implement broadcastAs() like this:

public function broadcastAs(): string
{
    return 'OrderStatusUpdated';
}

Then your client code channel.listen('OrderStatusUpdated', ...) will trigger instantly without namespace mismatch issues.

Reverb removes the need for third-party WebSocket subscriptions while offering speed comparable to Go-based alternatives. Keeping channel rules tight and header authorization explicit keeps your real-time data secure and instantly responsive.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Real-Time Laravel Reverb and Next.js WebSockets

    Real-Time Laravel Reverb and Next.js WebSockets

    Laravel Events: When to Use Them and When They Obscure Flow

    Laravel Events: When to Use Them and When They Obscure Flow

    Laravel Database Index Tuning with Postgres EXPLAIN

    Laravel Database Index Tuning with Postgres EXPLAIN

    Preventing Laravel Cache Stampedes with Atomic Locks

    Preventing Laravel Cache Stampedes with Atomic Locks

    Fast Laravel Database Testing Without In-Memory SQLite

    Fast Laravel Database Testing Without In-Memory SQLite

    Testing Laravel APIs with Pest: A Practical Guide

    Testing Laravel APIs with Pest: A Practical Guide

    Mocking HTTP Clients in Laravel 12 with Http::fake

    Mocking HTTP Clients in Laravel 12 with Http::fake

    Detecting and Fixing N+1 Queries in Laravel 12

    Detecting and Fixing N+1 Queries in Laravel 12

    Model Caching Patterns in Laravel 12 for Production

    Model Caching Patterns in Laravel 12 for Production

    Ensuring Secure URLs in Laravel Applications

    Ensuring Secure URLs in Laravel Applications