Laravel Reverb

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page is written against Laravel 13.38.0 on PHP 8.4.27, laravel/reverb 1.12.0, laravel-echo 2.6.1, @laravel/echo-react 2.6.1, pusher-js 8.6.0, Redis 8.8.3 and MySQL 8.4, checked on 2026-10-24. Laravel Reverb is the first-party WebSocket server of the Laravel ecosystem: a PHP process on a ReactPHP event loop that speaks the Pusher protocol, so it works with Laravel’s broadcasting layer on the server and with Laravel Echo and pusher-js in the browser. Broadcasting and Reverb already explains installation, ShouldBroadcast events, channel authorization callbacks, Echo basics and the Pusher, Ably and Mercure alternatives for a library application; this page does not repeat that. It answers a different question: what does it take to build the Chatterbox chat application of this section, with its rooms, presence, typing indicators, acknowledgements, sequence numbers and history, on Reverb, and where does the Pusher protocol force the design to differ from the Chatterbox protocol of Message Design and Serialization?

Reverb is not a general WebSocket server

Reverb implements one protocol, version 7 of the Pusher Channels protocol. A browser connects to ws://host:8080/app/{key}, receives pusher:connection_established with a socket_id, and then subscribes to named channels. Every frame is a JSON object with an event name, usually a channel, and a data field that is itself a JSON-encoded string. Laravel talks to the same server over HTTP: the reverb broadcast driver signs a request to /apps/{app_id}/events (the Pusher HTTP API, through pusher/pusher-php-server 7.3.0) and Reverb forwards the event to every subscriber of the channel. A Reverb installation therefore has two surfaces, the /app WebSocket endpoint and the /apps events API, and a reverse proxy has to serve both.

Three consequences shape everything below.

  • Reverb has no hook for application frames. Clients cannot send arbitrary messages to Laravel over the socket. The only client-to-server frames it understands are the pusher: control events and client events, which Reverb relays to the other members of a channel without ever calling Laravel.

  • Writes therefore go over HTTP. A message that must be validated, numbered, stored and acknowledged is a normal Laravel request. The socket is a push channel.

  • Any Pusher client works. pusher-js, the Pusher mobile SDKs and the Pusher server libraries can talk to Reverb, so a native Chatterbox client does not need Echo. The Pusher WebSocket protocol reference describes the frames that the run below prints.

Of the three scenarios, Chatterbox suits Reverb well: it is mostly server-to-browser fan-out with durable state in a database, which is what Laravel is good at. Pixel Arena does not fit, because it needs a 20 Hz authoritative tick loop and binary snapshots, and Reverb offers neither a game loop nor binary frames. Huddle only needs a signalling channel, and Reverb would work for that, but ICE candidates are client-to-client traffic that whispers (below) carry without validation, which is a thin base for a signalling server.

How Chatterbox maps onto the Pusher protocol

Reverb does not implement the Chatterbox wire protocol and cannot be made to. A chatterbox.v1 client that connects to wss://chat.example.test/ws and sends a hello frame will not work against it. What this page builds is Chatterbox semantics over the Pusher transport: the envelope (v, type, id, room, ts, payload) is kept as the body of HTTP requests and responses and as the data of pushed events, and the rules (per-room seq, idempotent ids, the acknowledgement and error codes, history paging) are enforced by Laravel. A browser client needs an adapter, the one in the Echo section below, instead of the plain WebSocket client of the other server pages.

Chatterbox on Laravel Reverb: writes go over HTTP to Laravel and MySQL, the event is posted to Reverb and shared through Redis, and pushes return over the Pusher-protocol WebSocket
Chatterbox On Reverb and Laravel Differs from the reference protocol

Transport and subprotocol

Pusher protocol 7 on /app/{key}, plus HTTP for writes. No chatterbox.v1 subprotocol.

Yes: a different wire protocol, the same envelope.

hello

pusher:connection_established gives the socket_id and activity_timeout. Identity comes from the Sanctum token sent to POST /api/broadcasting/auth, once per channel.

No hello frame, no 5 s hello deadline, no resume.

join, leave, presence

Subscribing to presence-chat.{room} is join. Echo’s here, joining and leaving deliver the member list.

Members carry userId and name. There is no away status and no 15 s offline grace: Reverb sends pusher_internal:member_removed as soon as the connection closes.

message, edit, react

POST /api/chat with the envelope. Laravel numbers, stores and broadcasts it as the event message, edit or react on the presence channel.

Same envelope, same payloads, same seq. The sender receives the push and the HTTP ack, in either order.

ack and error

The HTTP response: an ack envelope with status 200, or an error envelope with the status of the code (400, 403, 404, 409, 426, 429).

Same codes. unauthorized is the plain 401 of Sanctum, not an envelope.

typing and receipt

A whisper, the client event client-typing or client-receipt, sent on the socket and relayed by Reverb to the other members of the channel.

No validation, no ack, no persistence, and the frame carries no user id (see below).

history

POST /api/chat with type history, answered with an ack whose result holds the page.

Same payload and paging.

Idempotency

The chat_acks table stores the first ack of each user and id. A retry returns it with dup set to true and does not broadcast again.

Same, but kept for ever instead of 24 h.

Rate limits and sizes

20 envelopes per second per user in Laravel, and Reverb’s max_message_size and rate_limiting for whispers.

Per user instead of per connection. An oversized frame closes the socket with 1009, not 4413.

Heartbeat, close codes, resume

pusher:ping and pusher:pong, Echo’s automatic reconnection and its connected, reconnecting and failed states, and a history request to catch up.

The application close codes (4400 to 4503) do not exist; the Pusher codes 4000 to 4399 replace them.

The adapter therefore has three jobs that the native clients of the other pages get from the protocol: send envelopes with fetch, subscribe before it asks for history, and merge pushed and fetched envelopes by seq.

What the wire looks like

A Node script with the ws package (the client at the end of the page) connected to the Reverb of the Compose project below as two users, asked Laravel to authorize the presence channel and printed the frames. Frame data fields are shown decoded:

<- pusher:connection_established  {"socket_id":"834043982.307151597","activity_timeout":30}
-> POST /api/broadcasting/auth {socket_id, channel_name: "presence-chat.general"}  200 {auth, channel_data}
-> pusher:subscribe {"channel":"presence-chat.general","auth":"<app key>:<hmac>","channel_data":"{\"user_id\":\"1\",...}"}
<- pusher_internal:subscription_succeeded presence-chat.general
   {"presence":{"count":1,"ids":["1"],"hash":{"1":{"userId":"1","name":"Alice"}}}}
<- pusher_internal:member_added presence-chat.general {"user_id":"2","user_info":{"userId":"2","name":"Bob"}}
<- message presence-chat.general
   {"v":1,"type":"message","id":"01JAB3W7Q8Z4N5R66FFL8X1000","room":"general","ts":1792871801210,
    "payload":{"from":{"userId":"1","name":"Alice"},"text":"Hello, Chatterbox","replyTo":null,"attachments":[],"seq":1,"edited":false}}
<- client-typing presence-chat.general {"state":"start"}
<- pusher_internal:member_removed presence-chat.general {"user_id":"1"}

The presence hash is keyed by the user id, and user_info is whatever the channel callback returned: here the Chatterbox member { userId, name }. The whole Chatterbox envelope is the event data. Two details differ from what the Chatterbox reference server sends. The frame has no top-level room or type of its own, because the Pusher channel and event carry that information and the envelope repeats it inside data. And the client-typing frame has no sender: the Pusher reference says that presence client events carry a user_id, but the frame that Reverb 1.12.0 relayed contained only the data the sender put in it.

Project setup

On a machine without PHP, a Dockerfile with PHP, the extensions Laravel needs and Composer is enough. The image tags were verified on Docker Hub on the check date:

FROM php:8.4-cli
RUN apt-get update && apt-get install -y --no-install-recommends git unzip libzip-dev && rm -rf /var/lib/apt/lists/* \
 && docker-php-ext-install pcntl zip pdo_mysql
COPY --from=composer:2.10.3 /usr/bin/composer /usr/local/bin/composer
WORKDIR /app

The application was created and extended with these commands, run in a container of that image:

composer create-project laravel/laravel . --no-interaction
composer require laravel/reverb predis/predis -W      # reverb 1.12.0, predis 3.7.0
php artisan install:api                               # Sanctum 4 and the personal_access_tokens table
php artisan install:broadcasting --reverb --without-node --no-interaction

On this run, install:broadcasting published config/broadcasting.php and routes/channels.php and set BROADCAST_CONNECTION=reverb, but wrote no REVERB_* variables. From then on every Artisan command, including reverb:install, failed with Failed to create broadcaster for connection (the connection being reverb) and the cause Pusher\Pusher::__construct(): Argument #1 ($auth_key) must be of type string, null given, because loading routes/channels.php builds the broadcaster and the broadcaster needs a key. Setting BROADCAST_CONNECTION=log for one command and running php artisan reverb:install --no-interaction generated config/reverb.php and the application id, key and secret, and reverb could be restored. The Broadcasting page describes a guzzlehttp/psr7 conflict on an application locked to Guzzle 8. On this fresh project Guzzle resolved to 7.16.0 with guzzlehttp/psr7 2.13.2, so -W was enough. predis avoids the ext-redis extension; set REDIS_CLIENT=predis.

bootstrap/app.php moves the authorization endpoint under /api, so that the Sanctum bearer token authenticates it (the channels: argument of withRouting() is removed):

->withBroadcasting(__DIR__.'/../routes/channels.php', ['prefix' => 'api', 'middleware' => ['api', 'auth:sanctum']])

Compose file with two Reverb servers

The Compose project runs MySQL, Redis, the Laravel application behind PHP’s built-in server and two Reverb servers that share one application key and one Redis. Ports are chosen to avoid clashes: 8230 for Laravel, 8231 and 8232 for the two Reverb servers and 6430 for Redis. The application sends events to reverb1 (REVERB_HOST); browsers may connect to either server.

name: g3-laravel-reverb
x-laravel: &laravel
  build: .
  working_dir: /app
  volumes: [".:/app"]
  environment: &laravel-env
    REDIS_HOST: redis
    REDIS_CLIENT: predis
    CACHE_STORE: redis
    DB_CONNECTION: mysql
    DB_HOST: db
    DB_DATABASE: chatterbox
    DB_USERNAME: chatterbox
    DB_PASSWORD: change-me
    REVERB_HOST: reverb1                  # where Laravel POSTs events (server side)
    REVERB_PORT: "8080"
    REVERB_SCHEME: http
    REVERB_SCALING_ENABLED: ${SCALING:-true}
    REVERB_ALLOWED_ORIGINS: chat.example.test
    PHP_CLI_SERVER_WORKERS: "4"

services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: chatterbox
      MYSQL_USER: chatterbox
      MYSQL_PASSWORD: change-me
      MYSQL_ROOT_PASSWORD: change-me-root
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-pchange-me-root"]
      interval: 3s
      retries: 30
  redis:
    image: redis:8.8.3-alpine
    ports: ["6430:6379"]
  app:
    <<: *laravel
    command: php -S 0.0.0.0:8000 -t public
    ports: ["8230:8000"]
    depends_on:
      db: { condition: service_healthy }
      reverb1: { condition: service_started }
  reverb1:
    <<: *laravel
    command: php artisan reverb:start --host=0.0.0.0 --port=8080 --debug
    ports: ["8231:8080"]
    depends_on: [redis]
  reverb2:
    <<: *laravel
    command: php artisan reverb:start --host=0.0.0.0 --port=8080
    ports: ["8232:8080"]
    depends_on: [redis]

Two points about the environment. Under php artisan serve the REVERB_HOST from Compose did not reach the application, which sent its events to localhost:8080 from inside the container, and every dispatch failed with cURL error 7; the application therefore runs under php -S directly, which passes the environment through. And REVERB_SERVER_HOST and REVERB_SERVER_PORT (where Reverb listens) are different settings from REVERB_HOST and REVERB_PORT (where Laravel posts events and where browsers connect), which the Laravel documentation stresses and which matters as soon as a proxy sits in between.

Per-application settings

config/reverb.php has one entry per Reverb application. The generated defaults were kept except for two environment variables: the allow-list reads REVERB_ALLOWED_ORIGINS as a comma-separated list, and REVERB_APP_MAX_MESSAGE_SIZE is 16384 to match the Chatterbox frame limit. Each setting below was exercised against the running servers.

Setting Default Observed behaviour

allowed_origins

['*'] as generated

With chat.example.test, a socket with the right Origin connected, one with https://evil.example and one with no Origin each received pusher:error {"code":4009,"message":"Origin not allowed"}. The upgrade itself succeeds, so the refusal is a frame and not an HTTP 403.

accept_client_events_from

members

A whisper from a connection that had not joined the channel got pusher:error {"code":4009,"message":"The client is not a member of the specified channel."} and was not relayed.

max_message_size

10000 bytes

With 16384, a 17000-byte whisper closed the socket with code 1009 and the reason Maximum message size exceeded.

rate_limiting

disabled; 60 attempts per 60 s when enabled

Enabled with 5 attempts, a client’s subscribe plus four whispers passed and the next four got pusher:error {"code":4301,"message":"Rate limit exceeded"}. The socket stayed open because terminate_on_limit is false.

ping_interval, activity_timeout

60 s and 30 s

activity_timeout is the value sent in pusher:connection_established. Reverb answers pusher:ping with pusher:pong and prunes connections that stop answering.

max_connections

unlimited

A per-application cap, useful when one Reverb serves several applications.

A missing Origin is refused as well as a wrong one, which matters for tests: pusher-js in Node and the WebSocket client of Node 24 send no Origin, so a script has to set one (the ws package accepts an origin option) or run against an instance with the wildcard. The * default lets any website open a socket with the public key, so list the real origin in production.

The Laravel side

Everything the Chatterbox protocol requires of a server lives in a few small classes. The migration creates a room counter, the messages, the reactions and the acknowledgements. The ack is stored as text, not as a json column, because MySQL reorders the keys of a json column and the replayed ack would then differ from the first.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('chat_rooms', function (Blueprint $t) {
            $t->string('name', 64)->primary();
            $t->unsignedBigInteger('last_seq')->default(0);      // one counter per room
        });

        Schema::create('chat_messages', function (Blueprint $t) {
            $t->string('id', 36)->primary();                     // the client id is the message id forever
            $t->string('room', 64);
            $t->foreignId('user_id');
            $t->unsignedBigInteger('seq');
            $t->text('text');
            $t->string('reply_to', 36)->nullable();
            $t->json('attachments');
            $t->unsignedBigInteger('created_ms');
            $t->unsignedBigInteger('edited_ms')->nullable();
            $t->unique(['room', 'seq']);
        });

        Schema::create('chat_reactions', function (Blueprint $t) {
            $t->string('target', 36);
            $t->foreignId('user_id');
            $t->string('emoji', 32);
            $t->primary(['target', 'user_id', 'emoji']);
        });

        Schema::create('chat_acks', function (Blueprint $t) {    // idempotency: (user, id) -> original ack
            $t->foreignId('user_id');
            $t->string('id', 36);
            $t->text('ack');
            $t->unsignedBigInteger('created_ms');
            $t->primary(['user_id', 'id']);
        });
    }

    public function down(): void
    {
        foreach (['chat_acks', 'chat_reactions', 'chat_messages', 'chat_rooms'] as $table) {
            Schema::dropIfExists($table);
        }
    }
};

The envelope helpers and the error type keep the controller short. The error table is the Chatterbox one, with its HTTP status and the retryable flag:

<?php

namespace App\Chat;

use Illuminate\Support\Str;

final class Envelope
{
    public static function nowMs(): int
    {
        return (int) floor(microtime(true) * 1000);
    }

    public static function make(string $type, ?string $id, ?string $room, array $payload, ?int $ts = null): array
    {
        return [
            'v' => 1, 'type' => $type, 'id' => $id ?? 's_'.Str::upper((string) Str::ulid()),
            'room' => $room, 'ts' => $ts ?? self::nowMs(), 'payload' => (object) $payload,
        ];
    }

    public static function ack(string $ref, ?string $room, ?int $seq, array $result = [], bool $dup = false): array
    {
        return self::make('ack', null, $room, ['ref' => $ref, 'ok' => true, 'seq' => $seq, 'dup' => $dup, 'result' => (object) $result]);
    }
}
<?php

namespace App\Chat;

use RuntimeException;

final class ChatError extends RuntimeException
{
    private const TABLE = [
        'bad_request' => [400, false], 'unauthorized' => [401, false], 'forbidden' => [403, false],
        'not_found' => [404, false], 'conflict' => [409, false], 'too_large' => [413, false],
        'unsupported_version' => [426, false], 'rate_limited' => [429, true], 'internal' => [500, true],
        'unavailable' => [503, true],
    ];

    public function __construct(public readonly string $codeName, string $message)
    {
        parent::__construct($message);
    }

    public function status(): int { return self::TABLE[$this->codeName][0]; }

    public function retryable(): bool { return self::TABLE[$this->codeName][1]; }
}
<?php

namespace App\Chat;

use App\Models\User;
use Illuminate\Support\Str;

final class Rooms
{
    public static function valid(string $room): bool
    {
        return preg_match('/^[a-z0-9-]{1,64}$/', $room) === 1;
    }

    /** Public rooms are open to every signed-in user; dm-<a>-<b> only to the two users it names. */
    public static function canAccess(User $user, string $room): bool
    {
        if (! self::valid($room)) {
            return false;
        }
        if (str_starts_with($room, 'dm-')) {
            $ids = explode('-', Str::after($room, 'dm-'));

            return count($ids) === 2 && in_array((string) $user->id, $ids, true);
        }

        return true;
    }
}

Pushing is one broadcast event that carries a whole envelope. ShouldBroadcastNow sends it during the request, which keeps sequence order equal to send order: with a queued ShouldBroadcast event, several workers could deliver seq 8 before 7. PresenceChannel makes the wire channel presence-chat.{room}, broadcastAs makes the event name the envelope type, and broadcastWith replaces the default public-property payload:

<?php

namespace App\Events;

use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;

/** One Chatterbox envelope (message, edit or react) pushed to every member of a room. */
class ChatEnvelope implements ShouldBroadcastNow   // sent during the request: seq order is send order
{
    use Dispatchable;

    public function __construct(public string $room, public array $envelope) {}

    public function broadcastOn(): PresenceChannel
    {
        return new PresenceChannel('chat.'.$this->room);
    }

    public function broadcastAs(): string
    {
        return $this->envelope['type'];     // Echo listens for ".message", ".edit", ".react"
    }

    public function broadcastWith(): array
    {
        return $this->envelope;             // the whole envelope is the event data
    }
}
<?php

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

// presence-chat.{room}: the member list is the Chatterbox member list
Broadcast::channel('chat.{room}', function (User $user, string $room) {
    return Rooms::canAccess($user, $room)
        ? ['userId' => (string) $user->id, 'name' => $user->name]
        : false;
});

The channel callback is the membership rule and also the member record: returning the array makes Laravel sign the channel_data that every other member then receives as user_info, and returning false is the 403 that user 3 got for dm-1-2. The controller reuses Rooms::canAccess, so the HTTP endpoint and the socket cannot disagree about who may use a room. The route is one line, Route::post('/chat', ChatController::class)->middleware('auth:sanctum') in routes/api.php.

<?php

namespace App\Http\Controllers;

use App\Chat\ChatError;
use App\Chat\Envelope;
use App\Chat\Rooms;
use App\Events\ChatEnvelope;
use App\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;

class ChatController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        $env = $request->json()->all();
        try {
            return response()->json($this->handle($request->user(), $env));
        } catch (ChatError $e) {
            $error = Envelope::make('error', null, $env['room'] ?? null, [
                'ref' => is_string($env['id'] ?? null) ? $env['id'] : null,
                'code' => $e->codeName, 'message' => $e->getMessage(), 'retryable' => $e->retryable(),
            ]);

            return response()->json($error, $e->status());
        }
    }

    private function handle(User $user, array $env): array
    {
        // rate limit first: 20 envelopes per second per user, checked before deduplication
        if (! RateLimiter::attempt('chat:'.$user->id, 20, fn () => true, 1)) {
            throw new ChatError('rate_limited', 'Slow down.');
        }
        if (($env['v'] ?? null) !== 1) {
            throw new ChatError('unsupported_version', 'Only protocol version 1 is supported.');
        }
        $type = $env['type'] ?? null;
        $id = $env['id'] ?? null;
        $room = $env['room'] ?? null;
        $payload = $env['payload'] ?? null;
        if (! in_array($type, ['message', 'edit', 'react', 'history'], true)) {
            throw new ChatError('bad_request', 'This endpoint carries message, edit, react and history.');
        }
        if (! is_string($id) || ! preg_match('/^[0-9A-Za-z-]{26,36}$/', $id)) {
            throw new ChatError('bad_request', 'id must be 26 to 36 characters.');
        }
        if (! is_string($room) || ! Rooms::valid($room) || ! is_array($payload)) {
            throw new ChatError('bad_request', 'room or payload is invalid.');
        }
        if (! Rooms::canAccess($user, $room)) {
            throw new ChatError('forbidden', 'You cannot use this room.');
        }
        if ($type === 'history') {
            return $this->history($id, $room, $payload);
        }
        if ($stored = DB::table('chat_acks')->where(['user_id' => $user->id, 'id' => $id])->value('ack')) {
            $ack = json_decode($stored);                 // the original ack, flagged as a duplicate
            $ack->payload->dup = true;

            return (array) $ack;
        }

        DB::table('chat_rooms')->insertOrIgnore(['name' => $room, 'last_seq' => 0]);   // outside the transaction: no gap locks
        $broadcast = null;
        try {
            $ack = DB::transaction(function () use ($user, $type, $id, $room, $payload, &$broadcast) {
                $seq = $this->nextSeq($room);
                $broadcast = match ($type) {
                    'message' => $this->message($user, $id, $room, $payload, $seq),
                    'edit' => $this->edit($user, $room, $payload, $seq),
                    'react' => $this->react($user, $room, $payload, $seq),
                };
                $ack = Envelope::ack($id, $room, $seq);
                DB::table('chat_acks')->insert([
                    'user_id' => $user->id, 'id' => $id, 'ack' => json_encode($ack), 'created_ms' => Envelope::nowMs(),
                ]);

                return $ack;
            }, attempts: 3);
        } catch (UniqueConstraintViolationException) {
            throw new ChatError('conflict', 'That id or reaction already exists.');
        }
        ChatEnvelope::dispatch($room, $broadcast);   // after the commit, so nobody sees a rolled-back seq

        return $ack;
    }

    private function nextSeq(string $room): int
    {
        // the row exists (see handle()); lockForUpdate serialises senders in a room until their commit
        $seq = (int) DB::table('chat_rooms')->where('name', $room)->lockForUpdate()->value('last_seq') + 1;
        DB::table('chat_rooms')->where('name', $room)->update(['last_seq' => $seq]);

        return $seq;
    }

    private function message(User $user, string $id, string $room, array $p, int $seq): array
    {
        $text = $p['text'] ?? null;
        $reply = $p['replyTo'] ?? null;
        $attachments = $p['attachments'] ?? [];
        if (! is_string($text) || ($len = strlen(mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')) / 2) < 1 || $len > 4000) {
            throw new ChatError('bad_request', 'text must be 1 to 4000 UTF-16 code units.');
        }
        if (! is_array($attachments) || count($attachments) > 5) {
            throw new ChatError('bad_request', 'At most 5 attachments.');
        }
        if ($reply !== null && ! DB::table('chat_messages')->where(['id' => $reply, 'room' => $room])->exists()) {
            throw new ChatError('not_found', 'replyTo is not a message of this room.');
        }
        $now = Envelope::nowMs();
        DB::table('chat_messages')->insert([
            'id' => $id, 'room' => $room, 'user_id' => $user->id, 'seq' => $seq, 'text' => $text,
            'reply_to' => $reply, 'attachments' => json_encode($attachments), 'created_ms' => $now,
        ]);

        return Envelope::make('message', $id, $room, [
            'from' => ['userId' => (string) $user->id, 'name' => $user->name], 'text' => $text,
            'replyTo' => $reply, 'attachments' => $attachments, 'seq' => $seq, 'edited' => false,
        ], $now);
    }

    private function edit(User $user, string $room, array $p, int $seq): array
    {
        $row = DB::table('chat_messages')->where(['id' => $p['target'] ?? '', 'room' => $room])->first();
        if (! $row) {
            throw new ChatError('not_found', 'Unknown target.');
        }
        if ($row->user_id !== $user->id) {
            throw new ChatError('forbidden', 'Only the author can edit.');
        }
        if (! is_string($p['text'] ?? null) || $p['text'] === '') {
            throw new ChatError('bad_request', 'text is required.');
        }
        $now = Envelope::nowMs();
        DB::table('chat_messages')->where('id', $row->id)->update(['text' => $p['text'], 'edited_ms' => $now]);

        return Envelope::make('edit', null, $room, ['target' => $row->id, 'text' => $p['text'], 'seq' => $seq, 'editedAt' => $now], $now);
    }

    private function react(User $user, string $room, array $p, int $seq): array
    {
        $emoji = $p['emoji'] ?? '';
        $op = $p['op'] ?? '';
        if (! DB::table('chat_messages')->where(['id' => $p['target'] ?? '', 'room' => $room])->exists()) {
            throw new ChatError('not_found', 'Unknown target.');
        }
        if (! is_string($emoji) || preg_match_all('/\X/u', $emoji) !== 1 || ! in_array($op, ['add', 'remove'], true)) {
            throw new ChatError('bad_request', 'One emoji and op add or remove.');
        }
        $key = ['target' => $p['target'], 'user_id' => $user->id, 'emoji' => $emoji];
        if ($op === 'add') {
            DB::table('chat_reactions')->insert($key);          // a second add violates the primary key: conflict
        } elseif (DB::table('chat_reactions')->where($key)->delete() === 0) {
            throw new ChatError('not_found', 'You have not added that emoji.');
        }

        return Envelope::make('react', null, $room, [
            'target' => $p['target'], 'emoji' => $emoji, 'op' => $op, 'from' => ['userId' => (string) $user->id], 'seq' => $seq,
        ]);
    }

    private function history(string $id, string $room, array $p): array
    {
        $before = $p['before'] ?? null;
        $limit = $p['limit'] ?? 50;
        if (($before !== null && ! is_int($before)) || ! is_int($limit) || $limit < 1 || $limit > 100) {
            throw new ChatError('bad_request', 'before is a seq or null, limit is 1 to 100.');
        }
        $rows = DB::table('chat_messages')->join('users', 'users.id', '=', 'chat_messages.user_id')
            ->where('room', $room)->when($before !== null, fn ($q) => $q->where('seq', '<', $before))
            ->orderByDesc('seq')->limit($limit + 1)
            ->get(['chat_messages.*', 'users.name'])->all();
        $hasMore = count($rows) > $limit;
        $rows = array_reverse(array_slice($rows, 0, $limit));       // ascending seq
        $messages = array_map(fn ($r) => Envelope::make('message', $r->id, $room, [
            'from' => ['userId' => (string) $r->user_id, 'name' => $r->name], 'text' => $r->text,
            'replyTo' => $r->reply_to, 'attachments' => json_decode($r->attachments, true),
            'seq' => $r->seq, 'edited' => $r->edited_ms !== null,
        ], $r->created_ms), $rows);

        return Envelope::ack($id, $room, null, [
            'messages' => $messages, 'hasMore' => $hasMore, 'next' => $hasMore ? $rows[0]->seq : null,
        ]);
    }
}

Four design points are worth stating, because each one was found by running the code:

  • The sequence number needs a row lock, taken first. The first version inserted the counter row inside the transaction and then incremented it. Ten parallel posts to one room on MySQL 8.4 produced Deadlock found when trying to get lock for six of them. On SQLite the same test failed with database is locked. Creating the row before the transaction and starting the transaction with lockForUpdate() made ten parallel posts return the contiguous seq values 10 to 19. The attempts: 3 argument retries a transaction that still loses a deadlock.

  • The event is dispatched after the commit. Nobody sees a seq that was rolled back.

  • A broadcast that fails after the commit is not retried. The first run of the scenario hit a Reverb that was unreachable: the request answered 500 after the transaction had committed and the ack was stored. The client’s retry with the same id returned dup: true and Laravel did not broadcast again, so the other members never saw the message until they fetched history. Treat the history endpoint as the recovery path, or write an outbox row in the transaction and publish it from a worker.

  • The rate limit runs before deduplication, as the protocol says. A flood of 30 posts in one second got 10 rate_limited errors with retryable: true, and a limited retry can still be a duplicate afterwards.

Echo and the hooks in the browser

Echo owns the socket: it connects, authorizes each channel through /api/broadcasting/auth, answers pings and reconnects with back-off. The configuration uses the reverb broadcaster, which needs laravel-echo 1.16 or later; the installed version is 2.6.1. @laravel/echo-react 2.6.1 exports configureEcho, useEcho, useEchoPublic, useEchoPresence, useEchoModel, useEchoNotification, useChannel, usePresenceChannel, usePublicChannel, useSocketId and useConnectionStatus. A Chatterbox room in React subscribes to the presence channel, merges pushes by seq, loads history after the subscription is live and shows the connection state:

// resources/js/chatterbox/Room.tsx
import { configureEcho, useConnectionStatus, useEchoPresence } from '@laravel/echo-react';
import { useCallback, useEffect, useRef, useState } from 'react';

configureEcho({
  broadcaster: 'reverb',
  key: import.meta.env.VITE_REVERB_APP_KEY,
  wsHost: import.meta.env.VITE_REVERB_HOST,
  wsPort: Number(import.meta.env.VITE_REVERB_PORT ?? 80),
  wssPort: Number(import.meta.env.VITE_REVERB_PORT ?? 443),
  forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
  enabledTransports: ['ws', 'wss'],
  authEndpoint: '/api/broadcasting/auth',
  auth: { headers: { Authorization: `Bearer ${localStorage.getItem('chatterbox.token')}`, Accept: 'application/json' } },
});

type Message = { id: string; payload: { seq: number; text: string; edited: boolean; from: { userId: string; name: string } } };

async function call(type: string, room: string, payload: object, id = crypto.randomUUID()) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${localStorage.getItem('chatterbox.token')}` },
    body: JSON.stringify({ v: 1, type, id, room, ts: 0, payload }),
  });
  return { ok: response.ok, ...(await response.json()) };      // an ack or error envelope either way
}

export function Room({ room }: { room: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [typing, setTyping] = useState<string[]>([]);
  const seen = useRef(new Set<number>());
  const status = useConnectionStatus();                         // connected, connecting, reconnecting, disconnected, failed

  const merge = useCallback((incoming: Message[]) => setMessages((all) => {
    const fresh = incoming.filter((m) => !seen.current.has(m.payload.seq) && seen.current.add(m.payload.seq));
    return [...all, ...fresh].sort((a, b) => a.payload.seq - b.payload.seq);   // seq is the ordering key
  }), []);

  const { channel } = useEchoPresence<Message>(`chat.${room}`, '.message', (m) => merge([m]));   // leading dot: no namespace

  useEffect(() => {
    if (status !== 'connected') return;
    // subscribe first, then fetch, so nothing falls into the gap; duplicates are dropped by seq
    call('history', room, { before: null, limit: 50 }).then((ack) => merge(ack.payload.result.messages));
    const presence = channel();
    presence?.listenForWhisper('typing', (e: { userId: string; state: string }) =>
      setTyping((names) => (e.state === 'start' ? [...new Set([...names, e.userId])] : names.filter((n) => n !== e.userId))));
  }, [status, room]);

  const send = (text: string) => call('message', room, { text, replyTo: null, attachments: [] });
  return <p>{status}: {messages.length} messages, typing: {typing.join(', ')} <button onClick={() => send('Hello, Chatterbox')}>Send</button></p>;
}

The component type-checks against the hook signatures that the installed package ships, which is the reason for the channel() call: useEchoPresence returns channel, leave, leaveChannel, listen and stopListening, and channel() is the underlying Echo presence channel with here, joining, leaving and whisper. The Vue and Svelte packages have the same hooks. When a hook is not an option, the vanilla API does the same: Echo.join('chat.general').here(...).joining(...).leaving(...).listen('.message', ...).

The vanilla API was run for real. A Node script created two Echo instances with pusher-js 8.6.0 against a Reverb that accepts any origin (pusher-js in Node sends none) and printed:

// Real laravel-echo 2.6.1 + pusher-js 8.6.0 in Node. pusher-js sends no Origin header here, so the
// Reverb on port 8233 runs with REVERB_ALLOWED_ORIGINS='*' (a browser always sends its Origin).
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
import { readFileSync } from 'node:fs';
import { envelope, send } from './chatterbox-client.mjs';

const tokens = Object.fromEntries(readFileSync('tokens.txt', 'utf8').trim().split('\n').map((l) => l.split(' ').slice(1)));
const connect = (user) => new Echo({
  broadcaster: 'reverb', Pusher, key: process.env.REVERB_APP_KEY,
  wsHost: 'localhost', wsPort: 8233, forceTLS: false, enabledTransports: ['ws'],
  authEndpoint: 'http://localhost:8230/api/broadcasting/auth',
  auth: { headers: { Authorization: `Bearer ${tokens[user]}`, Accept: 'application/json' } },
});
const pause = (ms) => new Promise((r) => setTimeout(r, ms));

const bob = connect('bob');
bob.join('chat.general')
  .here((members) => console.log('here    ', JSON.stringify(members)))
  .joining((m) => console.log('joining ', JSON.stringify(m)))
  .leaving((m) => console.log('leaving ', JSON.stringify(m)))
  .listen('.message', (e) => console.log('message ', e.payload.seq, e.payload.text))
  .listenForWhisper('typing', (e) => console.log('typing  ', JSON.stringify(e)));
await pause(1500);

const alice = connect('alice');
const room = alice.join('chat.general');
await pause(1500);
room.whisper('typing', { state: 'start' });
console.log('ack     ', (await send('alice', envelope('message', 'general', { text: 'sent through Echo' }))).seq);
await pause(1000);
console.log('state   ', alice.connector.pusher.connection.state, typeof alice.socketId());
alice.leave('chat.general');
await pause(1000);
alice.disconnect(); bob.disconnect();
process.exit(0);
here     [{"userId":"2","name":"Bob"}]
joining  {"userId":"1","name":"Alice"}
typing   {"state":"start"}
message  1 sent through Echo
ack      1
state    connected string
leaving  {"userId":"1","name":"Alice"}

Whispers: typing and receipts

A client event is a frame whose name starts with client-. Reverb relays it to the other members of the channel without calling Laravel, and only private and presence channels accept it. That makes it the right carrier for the two fire-and-forget Chatterbox types, typing and receipt: no database write, no ack, no seq. The run showed the properties that matter. The sender did not receive its own whisper. A connection that had not joined the channel got 4009 and the whisper was not relayed. And the relayed frame was {"state":"start"} and nothing else.

The last point is a security limit. Any member can put any userId into the data, so a receiver cannot trust it. Treat typing indicators as hints, never as authorship, and if a receipt must be authoritative (for example for read markers that are shown to others as facts), send it with POST /api/chat and let Laravel stamp from from the token. Client side, throttle typing to one frame every 3 s as the protocol says and expire start after 5 s; Reverb will not do it for you, though rate_limiting bounds the damage.

toOthers and X-Socket-ID

The Chatterbox protocol sends message, edit and react to every member including the sender, so the sender’s copy is how it learns the seq. A client that renders optimistically does not need its own copy. broadcast(new ChatEnvelope(…​))→toOthers() omits the connection whose socket_id arrives in the X-Socket-ID header, and the event must use the InteractsWithSockets trait. Echo adds the header automatically to requests made with a global Axios instance. A client that uses fetch, like the one above, has to send X-Socket-ID itself, with the value of Echo.socketId() (useSocketId() in the hooks). A test with the header set to Alice’s socket_id confirmed that Alice received nothing and Bob received the message. Without the header, toOthers() has nothing to exclude and sends to everyone, which fails open and is easy to miss.

Model broadcasting is the other shortcut: a model with the BroadcastsEvents trait broadcasts created, updated and deleted events on a private channel named after the model, with the payload under model, without an event class. It suits a read-only mirror, such as a table of read receipts that a moderator panel watches. It does not fit the message flow here, because the envelope, the sequence number and the acknowledgement are produced by the request, not by the model event.

Scaling with Redis

One Reverb process keeps every connection and every channel’s member list in its own memory. Two processes behind a load balancer are two separate worlds, unless they share events. REVERB_SCALING_ENABLED=true makes each instance publish the events it receives to a Redis pub/sub channel (reverb by default) and subscribe to the same channel. The experiment was run on the Compose project above: three sockets, one on Reverb 1 and two on Reverb 2 or 1, joined chat.general, and Laravel posted a message each round, always to reverb1.

Run Sockets that received the message (instance 1, 2, 1) Presence across instances

REVERB_SCALING_ENABLED=false

yes, no, yes

Bob, on instance 2, saw a member list with only himself, and Alice received no pusher_internal:member_added

REVERB_SCALING_ENABLED=true

yes, yes, yes

Bob’s list had both users and Alice received member_added; a dropped connection produced member_removed on the other instance within milliseconds

The first row is the failure mode to remember: without the backplane, nothing errors. Messages simply reach only the users who happened to land on the instance that Laravel posted to. The Redis channel is plain pub/sub, which is at-most-once: an instance that is restarting misses the events published meanwhile, and that is another reason for the history endpoint and the seq gap check. Scaling WebSockets compares this backplane with the ones of the other stacks.

Putting nginx in front gives one public hostname. Reverb serves /app for WebSockets and /apps for the events API, so the proxy forwards both, with the Upgrade headers on the first. This configuration was run in front of the two Reverb servers; a socket through it joined the presence channel, and an unsigned post to /apps came back 401 from Reverb:

upstream reverb { server reverb1:8080; server reverb2:8080; }   # round robin; the Redis backplane makes any instance fine

map $http_upgrade $connection_upgrade { default upgrade; '' close; }

server {
    listen 80;
    server_name chat.example.test;

    location /app {                          # WebSocket (Pusher protocol)
        proxy_pass http://reverb;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_read_timeout 120s;             # longer than the 60 s ping interval
    }
    location /apps { proxy_pass http://reverb; proxy_set_header Host $http_host; }   # HTTP events API
}

The remaining production items come from the Reverb documentation and were not load-tested here.

  • Open files. Every connection is a file descriptor, so ulimit -n (or LimitNOFILE in systemd) and nginx’s worker_rlimit_nofile and worker_connections must exceed the connection count, and a Supervisor-managed Reverb needs minfds in supervisord.conf.

  • Event loop. The default stream_select loop is limited to 1024 descriptors. Installing ext-uv with pecl install uv makes Reverb switch to a libuv loop automatically, which is required beyond about 1000 concurrent connections.

  • Ports. Each client connection uses an ephemeral port on a proxy, about 28000 with the default range, so scale out before you tune the range.

  • Process management. Supervisor or systemd restart php artisan reverb:start. php artisan reverb:restart closes connections gracefully so the supervisor starts the new code, and clients reconnect, which is the reconnection storm that Connection Lifecycle, Heartbeats and Reconnection describes.

  • Monitoring. The Pulse recorders ReverbConnections and ReverbMessages and the cards <livewire:reverb.connections /> and <livewire:reverb.messages /> chart connections and messages, and pulse:check must run on one Reverb server only. Reverb also dispatches ChannelCreated, ChannelRemoved, ConnectionPruned, MessageReceived and MessageSent events that a listener can count.

  • Managed option. Laravel Cloud offers managed Reverb clusters, which removes the process, proxy and Redis items above.

Testing

Three layers cover the application. The first two are PHPUnit feature tests that need no WebSocket: they fake the event and call the authorization endpoint. The third is the script that talks to the real servers.

<?php

namespace Tests\Feature;

use App\Events\ChatEnvelope;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class ChatterboxTest extends TestCase
{
    use RefreshDatabase;

    private function envelope(string $type, string $room, array $payload, string $id = '01JAB3W7Q8Z4N5R6T7V8X9Y0ZA'): array
    {
        return ['v' => 1, 'type' => $type, 'id' => $id, 'room' => $room, 'ts' => 0, 'payload' => $payload];
    }

    public function test_only_the_two_users_of_a_dm_can_authorize_its_presence_channel(): void
    {
        [$a, $b, $c] = User::factory()->count(3)->create();
        $channel = "presence-chat.dm-{$a->id}-{$b->id}";

        foreach ([$a, $b] as $member) {
            $this->actingAs($member, 'sanctum')
                ->postJson('/api/broadcasting/auth', ['socket_id' => '1234.5678', 'channel_name' => $channel])
                ->assertOk()->assertJsonStructure(['auth', 'channel_data']);
        }
        $this->actingAs($c, 'sanctum')
            ->postJson('/api/broadcasting/auth', ['socket_id' => '1234.5678', 'channel_name' => $channel])
            ->assertForbidden();
    }

    public function test_a_message_gets_a_seq_is_broadcast_once_and_a_retry_is_a_duplicate(): void
    {
        Event::fake([ChatEnvelope::class]);
        $this->actingAs(User::factory()->create(), 'sanctum');
        $message = $this->envelope('message', 'general', ['text' => 'Hello, Chatterbox', 'replyTo' => null, 'attachments' => []]);

        $first = $this->postJson('/api/chat', $message)->assertOk();
        $retry = $this->postJson('/api/chat', $message)->assertOk();

        $this->assertSame(1, $first->json('payload.seq'));
        $this->assertFalse($first->json('payload.dup'));
        $this->assertTrue($retry->json('payload.dup'));
        $this->assertSame(1, $retry->json('payload.seq'));
        Event::assertDispatchedTimes(ChatEnvelope::class, 1);
        Event::assertDispatched(ChatEnvelope::class, fn ($e) => $e->room === 'general' && $e->envelope['payload']->seq === 1);
    }

    public function test_errors_are_envelopes_with_the_chatterbox_codes(): void
    {
        $this->actingAs(User::factory()->create(), 'sanctum');

        $this->postJson('/api/chat', $this->envelope('message', 'dm-998-999', ['text' => 'hi']))
            ->assertForbidden()->assertJsonPath('type', 'error')->assertJsonPath('payload.code', 'forbidden');
        $this->postJson('/api/chat', ['v' => 2] + $this->envelope('message', 'general', ['text' => 'hi']))
            ->assertStatus(426)->assertJsonPath('payload.code', 'unsupported_version');
        $this->postJson('/api/chat', $this->envelope('message', 'general', ['text' => 'hi', 'replyTo' => 'missing']))
            ->assertNotFound()->assertJsonPath('payload.retryable', false);
    }
}

The tests need one change to phpunit.xml. The generated file sets BROADCAST_CONNECTION to null. Channel callbacks are registered when routes/channels.php loads, on the broadcaster that is the default at that moment. A test that switched the default to reverb afterwards found the callbacks missing and got a 403 for a legitimate member. Set the test environment to the real driver with throw-away credentials instead:

<env name="BROADCAST_CONNECTION" value="reverb"/>
<env name="REVERB_APP_ID" value="test-app"/>
<env name="REVERB_APP_KEY" value="test-key"/>
<env name="REVERB_APP_SECRET" value="test-secret"/>

The project’s five tests pass (OK (5 tests, 24 assertions): these three and the framework’s two examples). The integration script needs the Compose project and tests the contract end to end, across both Reverb servers. The helper below is a complete Pusher-protocol client of 45 lines, and the script after it asserts join, broadcast across instances, ack, duplicate, the error codes, a whisper and history:

// Minimal Pusher-protocol client for tests: Node 24 + ws 8.22.0, no Echo.
import WebSocket from 'ws';
import { readFileSync } from 'node:fs';

export const KEY = process.env.REVERB_APP_KEY;
export const API = 'http://localhost:8230/api';
const tokens = Object.fromEntries(readFileSync('tokens.txt', 'utf8').trim().split('\n').map((l) => l.split(' ').slice(1)));
const headers = (user) => ({ Authorization: `Bearer ${tokens[user]}`, 'Content-Type': 'application/json', Accept: 'application/json' });

let counter = 0;
const run = Math.random().toString(36).slice(2, 8).toUpperCase();
export const newId = () => `01JAB3W7Q8Z4N5R6${run}${1000 + counter++}`;                 // 26 characters
export const envelope = (type, room, payload, id = newId()) => ({ v: 1, type, id, room, ts: 0, payload });

export async function send(user, env) {                                                    // writes go over HTTP
  const res = await fetch(`${API}/chat`, { method: 'POST', headers: headers(user), body: JSON.stringify(env) });
  const body = await res.json();
  return { status: res.status, ...body.payload, type: body.type };
}

export class Socket {
  frames = [];
  constructor(user, port) {
    this.user = user;
    this.ws = new WebSocket(`ws://localhost:${port}/app/${KEY}?protocol=7&client=test&version=1`, { origin: 'https://chat.example.test' });
    this.ws.on('message', (raw) => {
      const frame = JSON.parse(raw);
      if (typeof frame.data === 'string') frame.data = JSON.parse(frame.data);          // data is double-encoded
      if (frame.event === 'pusher:connection_established') this.socketId = frame.data.socket_id;
      this.frames.push(frame);
    });
  }
  async until(test, ms = 3000) {
    for (const end = Date.now() + ms; Date.now() < end; await new Promise((r) => setTimeout(r, 20))) {
      const hit = this.frames.find(test);
      if (hit) return hit;
    }
    throw new Error('timeout');
  }
  async join(channel) {                                                                  // authorize over HTTP, then subscribe
    await this.until((f) => f.event === 'pusher:connection_established');
    const res = await fetch(`${API}/broadcasting/auth`, { method: 'POST', headers: headers(this.user), body: JSON.stringify({ socket_id: this.socketId, channel_name: channel }) });
    if (!res.ok) return { denied: res.status };
    this.ws.send(JSON.stringify({ event: 'pusher:subscribe', data: { channel, ...(await res.json()) } }));
    return this.until((f) => f.event === 'pusher_internal:subscription_succeeded' && f.channel === channel);
  }
  whisper(channel, event, data) { this.ws.send(JSON.stringify({ event: `client-${event}`, channel, data })); }
  count(event) { return this.frames.filter((f) => f.event === event).length; }
}
import assert from 'node:assert/strict';
import { Socket, envelope, send } from './chatterbox-client.mjs';

const room = 'presence-chat.general';
const alice = new Socket('alice', 8231);   // Reverb instance 1
const bob = new Socket('bob', 8232);       // Reverb instance 2
const mallory = new Socket('mallory', 8231);

const joined = await alice.join(room);
assert.deepEqual(Object.keys(joined.data.presence.hash), ['1']);
await bob.join(room);
await alice.until((f) => f.event === 'pusher_internal:member_added');                   // crossed instances, via Redis
assert.deepEqual(await mallory.join('presence-chat.dm-1-2'), { denied: 403 });          // not a member of the DM

const hello = envelope('message', 'general', { text: 'Hello, Chatterbox', replyTo: null, attachments: [] });
const ack = await send('alice', hello);
assert.deepEqual([ack.type, ack.ok, ack.dup, typeof ack.seq], ['ack', true, false, 'number']);
const pushed = await bob.until((f) => f.event === 'message');                           // bob is on the other instance
assert.equal(pushed.data.id, hello.id);
assert.equal(pushed.data.payload.seq, ack.seq);
await alice.until((f) => f.event === 'message');                                         // the sender gets its copy too

const retry = await send('alice', hello);                                                // same id again
assert.deepEqual([retry.dup, retry.seq], [true, ack.seq]);
await new Promise((r) => setTimeout(r, 300));
assert.equal(bob.count('message'), 1);                                                   // no second broadcast

const code = async (user, env) => (await send(user, env)).code;
assert.equal(await code('bob', envelope('edit', 'general', { target: hello.id, text: 'x' })), 'forbidden');
assert.equal(await code('alice', envelope('message', 'general', { text: 'hi', replyTo: 'missing' })), 'not_found');
assert.equal(await code('alice', { ...envelope('message', 'general', { text: 'hi' }), v: 2 }), 'unsupported_version');
assert.equal(await code('alice', envelope('message', 'general', { text: '' })), 'bad_request');
assert.equal(await code('mallory', envelope('message', 'dm-1-2', { text: 'hi' })), 'forbidden');
const like = (op) => envelope('react', 'general', { target: hello.id, emoji: '👍', op });
assert.equal((await send('bob', like('add'))).ok, true);
assert.equal(await code('bob', like('add')), 'conflict');

alice.whisper(room, 'typing', { state: 'start' });                                       // peer to peer, never touches Laravel
assert.deepEqual((await bob.until((f) => f.event === 'client-typing')).data, { state: 'start' });
assert.equal(alice.count('client-typing'), 0);                                           // not echoed to the sender

const page = (await send('bob', envelope('history', 'general', { before: null, limit: 2 })));
assert.deepEqual([page.result.messages.length, page.result.hasMore], [1, false]);       // edit and react consumed seq 2 and 3
console.log('all Chatterbox-on-Reverb checks passed');
process.exit(0);

Run it with docker compose up -d --build, docker compose exec app php artisan migrate --force, the creation of three users and tokens in tokens.txt (php artisan tinker with createToken()), and REVERB_APP_KEY=... node conformance.mjs. It printed all Chatterbox-on-Reverb checks passed. Rooms, tokens and ids are throw-away, and the script generates a fresh id prefix per run: reusing ids against a database that remembers them returns dup and silently skips the broadcast, which cost one confusing debugging session while writing it.

Security

  • Authorization is the channel callback. A private or presence channel is only as safe as the closure in routes/channels.php; it deserves the same tests as a policy, as above. A forged auth value (<key>:forged) got 4009 Connection is unauthorized, and the endpoint answered 401 without a token.

  • The secret signs, the key identifies. REVERB_APP_KEY is public; REVERB_APP_SECRET signs channel authorizations and the events API calls and stays on the server. A wrong key got 4001 Application does not exist.

  • Allow-list origins (see the table) and serve wss:// only. The bearer token goes in an Authorization header to the HTTP endpoints, which sidesteps the cookie, query-string and subprotocol options that a raw WebSocket needs, one more advantage of keeping credentials off the socket. Authentication and Authorization compares the options.

  • Presence reveals members. Everybody on a presence channel receives the user_info of everybody else. Return only what the room may show, as { userId, name } does here, never the e-mail address.

  • Whispers are unvalidated: size-limited and rate-limited by Reverb, but with a free-form body and no sender identity.

  • Escape on output. Chatterbox text is user content: render it as text, not HTML. The same rule applies to user_info.name.

  • Limits. max_message_size, rate_limiting and max_connections bound abuse of the socket; the 20 per second limiter in the controller bounds the HTTP path. Security collects the WebSocket-wide checklist.

Common errors and pitfalls

  • Failed to create broadcaster for connection for reverb after install:broadcasting: the credentials are missing; see the setup section.

  • Events never reach the browser although the request succeeded: the queue worker is not running for a queued ShouldBroadcast event, or the retry was a duplicate whose first broadcast failed. Use ShouldBroadcastNow or a worker, and check php artisan reverb:start --debug, which prints every frame.

  • cURL error 7 ... localhost port 8080 in the log: the container sent the event to localhost. Set REVERB_HOST to the service name for the server side, and remember php artisan serve ignores it.

  • Some users miss messages after scaling out: REVERB_SCALING_ENABLED is not set on every instance, or the instances point to different Redis servers.

  • 4009 Origin not allowed in a test script: set an Origin or use the wildcard instance.

  • The member count differs from the connection count: one user with two tabs is one member. In a run with Alice on two connections, the second produced no member_added for Bob, and member_removed came only when the last of her connections closed. Count connections yourself if the number matters.

  • Closing code 1009 on a large whisper: raise max_message_size or send large payloads over HTTP.

  • Gaps in seq in a history page are normal: edits and reactions consume numbers but only messages are returned.

  • A missing X-Socket-ID makes toOthers() send to everyone.

Version and specification notes

  • Reverb 1.12.0 (Packagist, current on the check date) supports Laravel 10.47 through 13 on PHP 8.2 or later, according to its composer.json, and is a Pusher protocol 7 server. The reverb broadcaster in Echo requires laravel-echo 1.16 or later; Echo 2.x and the framework hooks (@laravel/echo-react, -vue, -svelte) are the current clients.

  • Laravel 13.x documents Reverb as a first-party package that install:broadcasting installs. The earlier laravel-websockets package and a Node laravel-echo-server are superseded; see Broadcasting and Reverb.

  • The Pusher protocol is a vendor specification, not a standard. Reverb implements the subset that Laravel and Pusher clients use: public, private and presence channels, client events, the events HTTP API and the ping and error frames shown above. It is not an implementation of the IETF WebSocket extensions beyond what the RFC 6455 framing of The WebSocket Protocol provides.

  • Chatterbox protocol version 1 is unchanged: only its transport differs on this page.