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:broadcastingThis 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 OrderStatusUpdatedOpen 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-jsIn 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.phpallowsbroadcasting/authand setssupports_credentialsto true if using cookies. - Unstarted Reverb server in production: Running
php artisan reverb:startin a terminal isn't enough. You need Supervisor or Systemd to keep the process alive in production. Usephp artisan reverb:start --port=8080managed 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 forOrderStatusUpdatedwon't match unless you either listen for.OrderStatusUpdatedor explicitly implementbroadcastAs()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.









