Laravel Real-Time in 2026: Reverb, Echo, and the WebSocket Stack No One Talks About

Pusher is expensive. Ably has limits. Laravel Reverb is self-hosted, open source, and built for the exact stack you’re already running. Here’s how to build real-time notifications, live dashboards, and multiplayer features without paying per message.


For years, adding real-time features to a Laravel application meant one of three things: pay Pusher per message and watch costs scale with users, set up and maintain Soketi or a custom Node.js WebSocket server alongside your PHP stack, or just skip it entirely because the integration overhead wasn’t worth it.

Laravel Reverb marks a massive milestone in the Laravel ecosystem. For the first time, Laravel ships with a first-party, high-performance WebSocket server capable of powering large-scale real-time applications with minimal configuration.

Built on PHP and powered by the high-performance event loop from ReactPHP, Reverb runs on the same network as the Laravel application, meaning messages do not have to round-trip through an external API. Independent benchmarks show roughly 40% lower latency compared to popular third-party WebSocket services, which is felt most in chat applications and live dashboards.

In November 2025, Laravel Cloud launched managed WebSocket clusters powered by Reverb at up to 50% lower pricing than competing managed services — giving teams the option of either self-hosting for free or using a managed service without vendor lock-in.

This is the complete practical guide to the entire stack: Reverb, Echo, broadcasting events, channel types, presence channels, queued listeners, horizontal scaling with Redis, and production deployment.


How the Stack Fits Together

Before writing code, the mental model matters. Real-time in Laravel involves four layers that work together:

1. Your application dispatches a broadcastable event
   UserNotified::dispatch($user, $message)
        │
        ▼
2. Laravel's broadcasting system routes it to Reverb
   (via the configured broadcast driver)
        │
        ▼
3. Reverb's WebSocket server delivers it to subscribed clients
   (via the channel the event was broadcast on)
        │
        ▼
4. Laravel Echo (on the frontend) handles the incoming message
   .listen('.notification.received', (event) => { ... })

Each layer has a clear responsibility. Your application logic doesn’t know about WebSockets. Reverb doesn’t know about your application logic. Echo doesn’t know about Reverb’s internals. The broadcast driver is the seam that connects them.


Installation and Configuration

Installing the Broadcasting Stack

# Installs Reverb, configures broadcasting, sets up Echo
php artisan install:broadcasting

Behind the scenes, the install:broadcasting Artisan command will run the reverb:install command, which will install Reverb with a sensible set of default configuration options.

This single command:

  • Installs laravel/reverb
  • Publishes config/reverb.php
  • Updates config/broadcasting.php to use Reverb
  • Installs laravel-echo and pusher-js via npm
  • Creates resources/js/echo.js with the Echo configuration

Environment Configuration

# .env
BROADCAST_CONNECTION=reverb

REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http

# Frontend (Vite reads these with the VITE_ prefix)
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

config/reverb.php — Key Options

// config/reverb.php
return [
    'default' => env('REVERB_SERVER', 'reverb'),

    'servers' => [
        'reverb' => [
            'host'    => env('REVERB_SERVER_HOST', '0.0.0.0'),
            'port'    => env('REVERB_SERVER_PORT', 8080),
            'scheme'  => env('REVERB_SCHEME', 'http'),

            'options' => [
                // Maximum payload size in bytes (default: 10MB)
                'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
            ],
        ],
    ],

    'apps' => [
        [
            'key'                    => env('REVERB_APP_KEY'),
            'secret'                 => env('REVERB_APP_SECRET'),
            'app_id'                 => env('REVERB_APP_ID'),
            'options'                => [
                'host'           => env('REVERB_HOST', 'localhost'),
                'port'           => env('REVERB_PORT', 8080),
                'scheme'         => env('REVERB_SCHEME', 'http'),
                'useTLS'         => env('REVERB_SCHEME', 'http') === 'https',
            ],
            'allowed_origins'        => ['*'],
            'ping_interval'          => env('REVERB_APP_PING_INTERVAL', 60),
            'ping_timeout'           => env('REVERB_APP_PING_TIMEOUT', 10),
            'activity_timeout'       => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
            'max_message_size'       => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
        ],
    ],

    'scaling' => [
        'enabled'    => env('REVERB_SCALING_ENABLED', false),
        'channel'    => env('REVERB_SCALING_CHANNEL', 'reverb'),
        'connection' => env('REVERB_SCALING_CONNECTION', 'default'),
    ],
];

Starting Reverb

# Development
php artisan reverb:start

# With debug output
php artisan reverb:start --debug

# Custom host and port
php artisan reverb:start --host=127.0.0.1 --port=8080

# Watch mode — restarts on file changes (development only)
php artisan reverb:start --debug

Frontend Echo Configuration

// resources/js/echo.js — generated by install:broadcasting
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 ?? 80,
    wssPort:      import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS:     (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
    // Reconnection configuration
    disableStats:  true,
    reconnectDelay: 2000,
    reconnectAttempts: 10,
})

Creating Broadcastable Events

The Event Class

// app/Events/NotificationReceived.php
<?php

namespace App\Events;

use App\Models\User;
use App\Models\Notification;
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 NotificationReceived implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly User         $user,
        public readonly Notification $notification,
    ) {}

    // The channel this event broadcasts on
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel("users.{$this->user->id}"),
        ];
    }

    // The event name the frontend listens for
    // Default: App\Events\NotificationReceived → 'App\\Events\\NotificationReceived'
    // Override for a cleaner name:
    public function broadcastAs(): string
    {
        return 'notification.received';
    }

    // Only send the data the frontend actually needs
    // Never broadcast full Eloquent models — control the payload
    public function broadcastWith(): array
    {
        return [
            'id'         => $this->notification->id,
            'type'       => $this->notification->type,
            'title'      => $this->notification->title,
            'message'    => $this->notification->message,
            'action_url' => $this->notification->action_url,
            'created_at' => $this->notification->created_at->toISOString(),
        ];
    }

    // Only broadcast when this condition is true
    public function broadcastWhen(): bool
    {
        return $this->notification->should_broadcast;
    }
}

ShouldBroadcastNow — Skip the Queue

By default, broadcastable events are dispatched through the queue. ShouldBroadcastNow broadcasts synchronously — useful for urgent, time-sensitive updates like typing indicators.

use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;

class TypingIndicator implements ShouldBroadcastNow
{
    public function __construct(
        public readonly int  $roomId,
        public readonly int  $userId,
        public readonly bool $isTyping,
    ) {}

    public function broadcastOn(): array
    {
        return [new PresenceChannel("rooms.{$this->roomId}")];
    }

    public function broadcastAs(): string
    {
        return 'typing.updated';
    }

    public function broadcastWith(): array
    {
        return ['user_id' => $this->userId, 'is_typing' => $this->isTyping];
    }
}

Channel Types: Public, Private, and Presence

Public Channels — No Authentication

Public channels are visible to anyone who knows the channel name. Use them for data that doesn’t require authorisation: public feeds, site-wide announcements, stock prices.

// Event
public function broadcastOn(): array
{
    return [new Channel('announcements')];
}
// Echo — subscribe to a public channel
Echo.channel('announcements')
    .listen('.announcement.posted', (event) => {
        displayAnnouncement(event)
    })

Private Channels — Authenticated Users Only

Private channels require the connecting user to be authenticated and authorised. The authorisation check runs server-side via routes/channels.php.

// routes/channels.php
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

// User can only subscribe to their own channel
Broadcast::channel('users.{userId}', function (User $user, int $userId) {
    return $user->id === $userId;
});

// User can subscribe to a conversation if they're a participant
Broadcast::channel('conversations.{conversationId}', function (User $user, int $conversationId) {
    return $user->conversations()->where('id', $conversationId)->exists();
});

// Admin-only channel
Broadcast::channel('admin.dashboard', function (User $user) {
    return $user->role === 'admin';
});
// Echo — subscribe to a private channel
// Echo automatically sends authentication request to /broadcasting/auth
Echo.private(`users.${userId}`)
    .listen('.notification.received', (event) => {
        addNotification(event)
    })
    .error((error) => {
        console.error('Channel subscription failed:', error)
    })

Presence Channels — Know Who’s Here

Presence channels are private channels that also track who’s subscribed. Members receive the full member list when they join, and are notified when others join or leave. Perfect for “who’s online”, live collaboration indicators, and multiplayer features.

// routes/channels.php
// Return user data to share with other channel members
Broadcast::channel('rooms.{roomId}', function (User $user, int $roomId) {
    if ($user->canJoinRoom($roomId)) {
        return [
            'id'     => $user->id,
            'name'   => $user->name,
            'avatar' => $user->avatar_url,
            'role'   => $user->role,
        ];
    }
    return false;  // deny access
});
// Echo — presence channel
Echo.join(`rooms.${roomId}`)
    // Called immediately with the current member list
    .here((members) => {
        setOnlineMembers(members)
    })
    // Called when a new member joins
    .joining((member) => {
        addOnlineMember(member)
        showToast(`${member.name} joined`)
    })
    // Called when a member leaves
    .leaving((member) => {
        removeOnlineMember(member)
    })
    // Called when a broadcastable event is received
    .listen('.message.sent', (event) => {
        addMessage(event)
    })
    // Called on subscription error
    .error((error) => {
        console.error('Presence channel error:', error)
    })

Real-World Pattern 1: Live Notification System

This is the most common real-time feature — server-side events pushed to specific users instantly.

The Event

// app/Events/UserNotified.php
class UserNotified implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly User   $user,
        public readonly string $title,
        public readonly string $message,
        public readonly string $type = 'info',  // info|success|warning|error
        public readonly ?string $actionUrl = null,
    ) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel("users.{$this->user->id}")];
    }

    public function broadcastAs(): string
    {
        return 'notification.received';
    }

    public function broadcastWith(): array
    {
        return [
            'title'      => $this->title,
            'message'    => $this->message,
            'type'       => $this->type,
            'action_url' => $this->actionUrl,
            'timestamp'  => now()->toISOString(),
        ];
    }
}

Dispatching from Anywhere

// From a controller, job, listener, or any service
UserNotified::dispatch(
    $user,
    'Order Shipped',
    'Your order #1234 has been dispatched.',
    'success',
    '/orders/1234'
);

// From a queued listener — fire-and-forget
class NotifyUserOnOrderShipped implements ShouldQueue
{
    public function handle(OrderShipped $event): void
    {
        UserNotified::dispatch(
            $event->order->user,
            'Order Shipped',
            "Your order #{$event->order->id} is on its way.",
            'success',
            route('orders.show', $event->order)
        );
    }
}

Vue Component

<!-- components/NotificationBell.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

interface Notification {
  title:      string
  message:    string
  type:       'info' | 'success' | 'warning' | 'error'
  action_url: string | null
  timestamp:  string
}

const props          = defineProps<{ userId: number }>()
const notifications  = ref<Notification[]>([])
const unreadCount    = ref(0)

onMounted(() => {
  window.Echo.private(`users.${props.userId}`)
    .listen('.notification.received', (notification: Notification) => {
      notifications.value.unshift(notification)
      unreadCount.value++
      showToast(notification)
    })
})

onUnmounted(() => {
  window.Echo.leave(`users.${props.userId}`)
})

function markAllRead() {
  unreadCount.value = 0
}
</script>

<template>
  <div class="notification-bell">
    <button @click="markAllRead">
      🔔
      <span v-if="unreadCount > 0" class="badge">{{ unreadCount }}</span>
    </button>

    <div class="notification-list">
      <div
        v-for="(n, i) in notifications"
        :key="i"
        :class="['notification', `notification--${n.type}`]"
      >
        <strong>{{ n.title }}</strong>
        <p>{{ n.message }}</p>
        <a v-if="n.action_url" :href="n.action_url">View →</a>
      </div>
    </div>
  </div>
</template>

Real-World Pattern 2: Live Dashboard with Metrics

A live dashboard that updates as metrics change — no polling, no page refresh.

The Event

// app/Events/MetricsUpdated.php
class MetricsUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly array $metrics
    ) {}

    // Admin-only dashboard channel
    public function broadcastOn(): array
    {
        return [new PrivateChannel('admin.dashboard')];
    }

    public function broadcastAs(): string
    {
        return 'metrics.updated';
    }

    public function broadcastWith(): array
    {
        return $this->metrics;
    }
}

Scheduled Broadcast via Octane Tick or Job

// Approach 1: Scheduled command broadcasts metrics every 30 seconds
class BroadcastLiveMetrics extends Command
{
    protected $signature = 'metrics:broadcast';

    public function handle(MetricsService $metrics): void
    {
        MetricsUpdated::dispatch([
            'revenue_today'      => $metrics->revenueToday(),
            'active_users'       => $metrics->activeUsers(),
            'orders_last_hour'   => $metrics->ordersLastHour(),
            'server_cpu'         => $metrics->serverCpu(),
            'updated_at'         => now()->toISOString(),
        ]);
    }
}

// In Console/Kernel.php
$schedule->command('metrics:broadcast')->everyThirtySeconds();

// Approach 2: Octane tick (Swoole only) — every 30 seconds inside the worker
Octane::tick('metrics', function () {
    MetricsUpdated::dispatch(app(MetricsService::class)->current());
})->seconds(30);

Vue Dashboard Component

<!-- components/LiveDashboard.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

interface Metrics {
  revenue_today:    number
  active_users:     number
  orders_last_hour: number
  server_cpu:       number
  updated_at:       string
}

const metrics   = ref<Metrics | null>(null)
const lastPulse = ref<Date | null>(null)

onMounted(() => {
  window.Echo.private('admin.dashboard')
    .listen('.metrics.updated', (data: Metrics) => {
      metrics.value   = data
      lastPulse.value = new Date()
    })
})

onUnmounted(() => {
  window.Echo.leave('admin.dashboard')
})
</script>

<template>
  <div class="dashboard">
    <div v-if="metrics" class="metrics-grid">
      <MetricCard label="Revenue Today"    :value="`$${metrics.revenue_today.toLocaleString()}`" />
      <MetricCard label="Active Users"     :value="metrics.active_users" />
      <MetricCard label="Orders/Hour"      :value="metrics.orders_last_hour" />
      <MetricCard label="CPU Usage"        :value="`${metrics.server_cpu}%`" />
    </div>
    <p class="pulse" v-if="lastPulse">
      Last updated: {{ lastPulse.toLocaleTimeString() }}
    </p>
  </div>
</template>

Real-World Pattern 3: Collaborative Presence (Who’s Editing)

Show which users are viewing or editing a resource — a pattern used in Notion, Figma, and Google Docs.

// routes/channels.php
Broadcast::channel('documents.{documentId}', function (User $user, int $documentId) {
    $document = Document::find($documentId);

    if (!$document || !$user->can('view', $document)) {
        return false;
    }

    return [
        'id'     => $user->id,
        'name'   => $user->name,
        'avatar' => $user->avatar_url,
        'color'  => $user->cursor_color,  // unique color per user
    ];
});
<!-- components/CollaboratorAvatars.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const props       = defineProps<{ documentId: number }>()
const collaborators = ref<{ id: number; name: string; avatar: string; color: string }[]>([])

onMounted(() => {
  window.Echo.join(`documents.${props.documentId}`)
    .here((members) => {
      collaborators.value = members
    })
    .joining((member) => {
      collaborators.value.push(member)
    })
    .leaving((member) => {
      collaborators.value = collaborators.value.filter(c => c.id !== member.id)
    })
})

onUnmounted(() => {
  window.Echo.leave(`documents.${props.documentId}`)
})
</script>

<template>
  <div class="collaborators">
    <TransitionGroup name="avatar">
      <img
        v-for="user in collaborators"
        :key="user.id"
        :src="user.avatar"
        :alt="user.name"
        :title="`${user.name} is here`"
        :style="{ borderColor: user.color }"
        class="avatar"
      />
    </TransitionGroup>
    <span v-if="collaborators.length > 1">
      {{ collaborators.length }} people here
    </span>
  </div>
</template>

Client-to-Server Events: Whispers

Presence channels support “whispers” — client-to-client messages that are relayed via Reverb without being persisted or broadcast through the Laravel application. They bypass the event system entirely, making them extremely lightweight. Perfect for cursor positions, typing indicators, and ephemeral collaboration state.

// Send a whisper — goes directly to Reverb, not through Laravel
Echo.join(`documents.${documentId}`)
    .whisper('cursor.moved', {
        x:  mouseX,
        y:  mouseY,
        pageId: currentPage,
    })

// Listen for whispers from other users
Echo.join(`documents.${documentId}`)
    .listenForWhisper('cursor.moved', (event) => {
        updateCursorPosition(event.userId, event.x, event.y)
    })
// Typing indicator with whispers — most efficient implementation
const channel = Echo.join(`conversations.${conversationId}`)

// Throttle to avoid spamming
let typingTimeout = null

inputEl.addEventListener('input', () => {
    channel.whisper('typing', { userId, isTyping: true })

    clearTimeout(typingTimeout)
    typingTimeout = setTimeout(() => {
        channel.whisper('typing', { userId, isTyping: false })
    }, 2000)
})

channel.listenForWhisper('typing', ({ userId, isTyping }) => {
    updateTypingIndicator(userId, isTyping)
})

Horizontal Scaling with Redis

Laravel Reverb supports horizontal scaling through Redis pub/sub. The same setup that handles a hundred concurrent connections will handle a hundred thousand without changing the application code, just by adding more servers behind the load balancer.

Enabling Scaling

// .env
REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb
REVERB_SCALING_CONNECTION=default  # your Redis connection name
// config/database.php — ensure Redis is configured
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),

    'default' => [
        'host'     => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port'     => env('REDIS_PORT', 6379),
        'database' => env('REDIS_DB', 0),
    ],
],

When scaling is enabled, Reverb uses Redis pub/sub to relay messages between servers. A message broadcast by Server 1 is published to Redis, which delivers it to Servers 2 and 3, which then forward it to their connected clients. The application code and event dispatch code stay identical — only the infrastructure changes.

Load Balancer
     │
     ├── Reverb Server 1 ──┐
     ├── Reverb Server 2 ──┤── Redis Pub/Sub ── message relay
     └── Reverb Server 3 ──┘

Production Deployment

System Limits: File Descriptors

Each WebSocket connection is held in memory until either the client or server disconnects. In Unix and Unix-like environments, each connection is represented by a file. However, there are often limits on the number of allowed open files at both the operating system and application level.

For a server expected to handle thousands of concurrent connections, raise the file descriptor limit:

# Check current limits
ulimit -n

# Temporary increase for the current session
ulimit -n 100000

# Permanent increase — add to /etc/security/limits.conf
www-data soft nofile 100000
www-data hard nofile 100000

Nginx Configuration

# /etc/nginx/sites-available/your-app

server {
    listen 443 ssl;
    server_name your-app.com;

    # SSL configuration
    ssl_certificate     /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    # PHP-FPM or Octane — regular HTTP requests
    location / {
        proxy_pass         http://127.0.0.1:80;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
    }

    # Reverb WebSocket server — separate location block
    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_set_header       X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header       X-Forwarded-Proto $scheme;
        proxy_read_timeout     60s;
        proxy_connect_timeout  60s;
        proxy_send_timeout     60s;
    }
}

Supervisor Configuration

; /etc/supervisor/conf.d/reverb.conf

[program:reverb]

process_name=%(program_name)s command=php /var/www/your-app/artisan reverb:start –host=0.0.0.0 –port=8080 –no-interaction autostart=true autorestart=true user=www-data redirect_stderr=true stdout_logfile=/var/www/your-app/storage/logs/reverb.log stopwaitsecs=60 stopsignal=SIGTERM

# Reload Supervisor
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start reverb

# Restart Reverb after a deploy
sudo supervisorctl restart reverb

Zero-Downtime Deploy

#!/bin/bash
# Deploy script — after code is updated

php artisan down

composer install --no-dev --optimize-autoloader
npm ci && npm run build

php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache

php artisan up

# Gracefully restart Reverb — existing connections finish, workers reload
php artisan reverb:restart
# OR via Supervisor:
# sudo supervisorctl restart reverb

Reverb vs Pusher vs Laravel Cloud WebSockets

Self-hosting means no surprise pricing changes, no service deprecations, and no shifting terms of service. Here’s the full comparison for teams deciding between the options in 2026:

Reverb (Self-hosted)PusherLaravel Cloud WebSockets
CostFree (server cost only)Per message / connection~50% less than Pusher
Horizontal scalingRedis pub/subManagedManaged
LatencyLowest (same network)Higher (external API)Low (managed Reverb)
Operations overheadHighNoneNone
Vendor lock-inNoneHighMedium (Laravel Cloud)
Connection limitsSystem limitsPlan limitsAuto-scaling
Best forCost-sensitive, control-focusedSmall projects, fast startScale without DevOps

Debugging and Monitoring

Debug Mode

# See all WebSocket activity in real time
php artisan reverb:start --debug

Debug output shows:

  • Every connection established and closed
  • Every subscription request with authorisation result
  • Every message received and broadcast
  • Errors and timeouts

Logging

// config/reverb.php — enable detailed logging
'servers' => [
    'reverb' => [
        'options' => [
            // Log all connections, subscriptions, and messages
        ],
    ],
],
// Listen to Reverb events in your application
use Laravel\Reverb\Events\MessageReceived;
use Laravel\Reverb\Events\ChannelSubscribed;

Event::listen(MessageReceived::class, function ($event) {
    Log::debug('Reverb message received', [
        'channel' => $event->channel,
        'event'   => $event->event,
    ]);
});

Health Check Endpoint

// routes/web.php
Route::get('/reverb/health', function () {
    try {
        // Attempt to connect to Reverb
        $socket = stream_socket_client(
            'tcp://' . config('reverb.servers.reverb.host') . ':' . config('reverb.servers.reverb.port'),
            $errno, $errstr, 2
        );

        if (!$socket) {
            return response()->json(['status' => 'down', 'error' => $errstr], 503);;
        }

        fclose($socket);
        return response()->json(['status' => 'up']);
    } catch (\Exception $e) {
        return response()->json(['status' => 'down', 'error' => $e->getMessage()], 503);
    }
})->middleware('throttle:60,1');

Production Checklist

✓ REVERB_SCHEME=https in production .env
✓ SSL termination configured in Nginx (not Reverb directly)
✓ Nginx upgrade headers set correctly for WebSocket proxying
✓ File descriptor limits raised (ulimit -n 100000+)
✓ Supervisor configured to keep Reverb alive and restart on failure
✓ Queue workers running (broadcastable events go through the queue)
✓ REVERB_SCALING_ENABLED=true if running multiple app servers
✓ Redis configured and accessible for scaling pub/sub
✓ reverb:restart in deploy script (graceful reload of workers)
✓ All broadcastable events use broadcastWith() — never expose full Eloquent models
✓ Private channels authorised in routes/channels.php
✓ Presence channel auth returns user data object (not just true)
✓ Echo.leave() called on component unmount — prevents connection leaks
✓ Health check endpoint behind rate limiting
✓ Reverb logs monitored via storage/logs/reverb.log
✓ Whispers used for ephemeral collaboration data (not broadcast events)

Final Thoughts

What used to be a two-week integration project has become a two-day build. Laravel Reverb delivers real-time WebSocket capability directly within the stack Laravel teams already know — no separate service, no per-message billing, no context switching between PHP and Node ecosystems.

The architecture is clean: broadcastable events that your application already dispatches, channel authorisation that integrates with Laravel’s authentication system, and a frontend Echo client that handles reconnection, channel subscriptions, and presence tracking automatically.

The patterns in this post — live notifications, admin dashboards, collaborative presence, typing indicators via whispers, and horizontal scaling with Redis — cover the vast majority of real-time requirements in production applications. All of them use the same mental model: your application dispatches events, Reverb delivers them, Echo handles them. The implementation complexity lives at the seam, not in the business logic.

Build it once. Monitor it with debug mode. Scale it with Redis. Ship without the monthly bill.

Leave a Reply

Your email address will not be published. Required fields are marked *