Broadcasting and Reverb

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

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with laravel/reverb 1.12.0. Broadcasting sends a server-side event to browsers over a WebSocket, so a page updates without polling. Laravel serializes the event and hands it to a broadcaster. A WebSocket server (Reverb, or a hosted service such as Pusher or Ably) delivers it to every subscribed client. Bookshelf broadcasts new books on a public channel and due dates on each member’s private channel, and shows who is reading a book on a presence channel.

sequenceDiagram participant B as Browser (Echo) participant R as Reverb :8080 participant A as Laravel app :8000 participant Q as Queue worker B->>R: WebSocket connect /app/{key} R-->>B: pusher:connection_established (socket_id) B->>A: POST /api/broadcasting/auth (socket_id, private-loans.2) A-->>B: 200 {auth: key:signature} or 403 B->>R: pusher:subscribe (channel, auth) R-->>B: subscription_succeeded A->>Q: LoanDueSoon queued (ShouldBroadcast) Q->>R: HTTP POST event to channel R-->>B: loan.due {loan, title, due_at}

Installing

php artisan install:broadcasting --reverb publishes config/broadcasting.php and routes/channels.php, installs Reverb, writes its credentials to .env and, unless --without-node is given, installs laravel-echo and pusher-js. In Bookshelf, the first attempt failed and reverted composer.json:

laravel/reverb v1.12.0 requires guzzlehttp/psr7 ^2.6 -> found guzzlehttp/psr7[2.6.0, ..., 2.13.1]
but the package is fixed to 3.1.0 (lock file version) by a partial update and that version does not match.

Laravel 13 accepts Guzzle 7.8+ or 8, and Bookshelf had resolved to Guzzle 8.2 with guzzlehttp/psr7 3.1. Reverb 1.12 still requires psr7 2.x. composer require laravel/reverb -W downgraded Guzzle to 7.15.5 and psr7 to 2.13.1, the whole Bookshelf suite still passed, and php artisan reverb:install finished the setup:

BROADCAST_CONNECTION=reverb
REVERB_APP_ID=914389
REVERB_APP_KEY=…
REVERB_APP_SECRET=…
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"

The VITE_ variables reach the browser bundle. The app key is public by design and identifies the application. The secret signs channel authorizations and stays on the server. php artisan reverb:start --debug runs the server and logs every frame.

Broadcast events

An event that implements ShouldBroadcast is sent through the queue. ShouldBroadcastNow sends it during the request:

<?php
// app/Events/LoanDueSoon.php
namespace App\Events;

use App\Models\Loan;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class LoanDueSoon implements ShouldBroadcast                  // queued; ShouldBroadcastNow skips the queue
{
    use Dispatchable, SerializesModels;

    public function __construct(public Loan $loan) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('loans.'.$this->loan->user_id)];
    }

    public function broadcastAs(): string
    {
        return 'loan.due';
    }

    public function broadcastWith(): array                    // without this, every public property is sent
    {
        return ['loan' => $this->loan->id, 'title' => $this->loan->book->title, 'due_at' => $this->loan->due_at->toIso8601String()];
    }
}

// app/Events/BookAddedToCatalogue.php
class BookAddedToCatalogue implements ShouldBroadcastNow
{
    use Dispatchable;

    public function __construct(public Book $book) {}

    public function broadcastOn(): Channel
    {
        return new Channel('catalogue');                       // public: no authorization
    }
}

Channels and authorization

A public channel needs no permission. A private channel (private- on the wire) asks the application whether the user may subscribe. A presence channel (presence-) also returns who the user is, so every subscriber sees the member list. The rules live in routes/channels.php:

<?php
// routes/channels.php
use App\Models\{Book, User};
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('loans.{userId}', fn (User $user, int $userId) => $user->id === $userId);   // private

Broadcast::channel('reading-room.{book}', fn (User $user, Book $book) => ['id' => $user->id, 'name' => $user->name]);   // presence

The authorization endpoint is POST /broadcasting/auth in the web group by default, which suits Blade, Livewire and Inertia pages with a session cookie. Bookshelf’s API clients use Sanctum tokens, so the endpoint moves under /api:

<?php
// bootstrap/app.php: replaces channels: in withRouting()
return Application::configure(basePath: dirname(__DIR__))
    // ... withRouting() without channels:, withMiddleware() and withExceptions() unchanged ...
    ->withBroadcasting(__DIR__.'/../routes/channels.php', ['prefix' => 'api', 'middleware' => ['api', 'auth:sanctum']])
    ->create();

On the wire

Echo speaks the Pusher protocol. To see each step, a 25-line Node script using the ws package connected as user 2, asked the application to authorize three channels, and printed every frame. A PHP script then fired one event of each kind:

<- pusher:connection_established  {"socket_id":"923756425.360203905","activity_timeout":30}
<- pusher_internal:subscription_succeeded catalogue {}
   auth private-loans.2 200 auth=<app key>:…
<- pusher_internal:subscription_succeeded private-loans.2 {}
   auth private-loans.1 403 {"message":"","exception":"Symfony\\Component\\HttpKernel\\Exception\\AccessDeniedHttpException",…}
   auth presence-reading-room.1 200 auth=<app key>:…
<- pusher_internal:subscription_succeeded presence-reading-room.1 {"presence":{"count":1,"ids":["2"],"hash":{"2":{"id":2,"name":"Keyshawn Waters Jr."}}}}
<- App\Events\BookAddedToCatalogue catalogue {"book":{"id":2,"author_id":1,"title":"Parable of the Sower","isbn":"978-1-5387-3218-7","published_year":1993,"created_at":"2026-10-05T18:51:25.000000Z","updated_at":"2026-10-05T22:11:11.000000Z","meta":{"pages":187,"synced_at":"2026-10-05T22:11:11+00:00"}}}
<- loan.due private-loans.2 {"loan":"01m472akqj93z9pevcq8kfv7xw","title":"Kindred","due_at":"2026-10-07T22:18:08+00:00"}

The run shows four things:

  • The auth value is the app key and an HMAC-SHA256 of socket_id:channel with the app secret. Reverb checks the signature, so the browser cannot invent one. A second script that subscribed to private-loans.1 with auth: "<key>:forged" got pusher:error {"code":4009,"message":"Connection is unauthorized"}. A wrong app key got 4001 Application does not exist.

  • Authorization is the application’s channel callback. User 2 was refused loans.1 with a 403. Everything a private channel protects depends on that one-line closure, which needs a test like any policy.

  • BookAddedToCatalogue has no broadcastWith, so its public $book property was serialized whole, including meta and timestamps, to every anonymous subscriber of a public channel. LoanDueSoon sends three chosen fields.

  • The event name is the class name unless broadcastAs sets one. Echo listens for .loan.due (the leading dot means "not namespaced").

LoanDueSoon reached the browser only after php artisan queue:work ran it. A queued broadcast needs a worker, like any job (Queues and Jobs). Broadcast events are dispatched like other events, so a LoanDueSoon dispatched inside a transaction should be held until the commit (Events and Listeners).

Echo in the browser

resources/js/echo.js, created by install:broadcasting, configures Echo from the VITE_REVERB_* variables. React, Vue and Svelte get hooks (useEcho, useEchoPublic, useEchoPresence) that subscribe on mount and leave on unmount:

// resources/js/pages/loans.tsx
import { useEcho } from '@laravel/echo-react';

useEcho(`loans.${user.id}`, '.loan.due', (e) => toast(`${e.title} is due ${e.due_at}`));

// the presence member list, with the Echo instance that echo.js creates
window.Echo.join(`reading-room.${book.id}`).here(setReaders).joining(addReader).leaving(removeReader);

Three more features cover most interfaces:

  • broadcast(new LoanDueSoon($loan))->toOthers() skips the sender’s own connection. It identifies that connection by the X-Socket-ID header that Echo adds to Axios requests.

  • Client events (channel.whisper('typing', {...})) go from browser to browser through Reverb without touching Laravel. Reverb accepts them only from members of presence or private channels, which is the accept_client_events_from setting.

  • Model broadcasting (use BroadcastsEvents on a model) broadcasts created, updated and deleted on conventional channels without event classes.

Conditions, anonymous events and failures

Three more tools decide whether and how something is broadcast. broadcastWhen() on an event skips the broadcast when it returns false. Broadcast::on(), private() and presence() send an anonymous event, with a name and data but no event class, for a one-off notification. ShouldRescue on an event turns a broadcasting failure into a reported exception instead of an error for the user:

<?php
// app/Events/LoanDueSoon.php (added)
public function broadcastWhen(): bool                     // checked at dispatch, before queueing
{
    return $this->loan->status === LoanStatus::Active;
}

// app/Events/BookAddedToCatalogue.php (changed)
class BookAddedToCatalogue implements ShouldBroadcastNow, ShouldRescue   // a failed broadcast is reported, not thrown

A script then dispatched both events with the log broadcaster, which writes each broadcast to the log instead of sending it, and with Reverb stopped:

<?php
config(['broadcasting.default' => 'log', 'queue.default' => 'sync']);
Log::listen(function ($e) { echo '  log: ', str_replace("\n", ' ', $e->message), PHP_EOL; });

$loan = Loan::factory()->for(Book::factory()->create(['title' => 'Kindred']))->create();
echo "active loan:\n";   LoanDueSoon::dispatch($loan);
$loan->update(['status' => LoanStatus::Returned]);
echo "returned loan:\n"; LoanDueSoon::dispatch($loan);

echo "anonymous event:\n";
Broadcast::private("loans.{$loan->user_id}")->as('loan.renewed')->with(['loan' => $loan->id, 'weeks' => 3])->sendNow();

echo "Reverb not running:\n";
config(['broadcasting.default' => 'reverb']);
try {
    BookAddedToCatalogue::dispatch($loan->book);
    echo "  the request carried on\n";
} catch (Throwable $e) {
    echo '  ', get_class($e), ': ', substr($e->getMessage(), 0, 110), PHP_EOL;
}
active loan:
  log: Broadcasting [loan.due] on channels [private-loans.43] with payload: {     "loan": "01m488d7ssf7fbn64yh9v9rapb",     "title": "Kindred",     "due_at": "2026-10-27T09:23:40+00:00",     "socket": null }
returned loan:
anonymous event:
  log: Broadcasting [loan.renewed] on channels [private-loans.43] with payload: {     "loan": "01m488d7ssf7fbn64yh9v9rapb",     "weeks": 3,     "socket": null }
Reverb not running:
  log: Pusher error: cURL error 7: Failed to connect to localhost port 8080 after 0 ms: Couldn't connect to server (see https://curl.se/libcurl/c/libcurl-errors.html) for http://localhost:8080/apps/914389/events?auth_key=…
  the request carried on
  • The returned loan was not broadcast at all. broadcastWhen() runs when the event is dispatched, so a queued broadcast that it skips never reaches the queue, and a loan returned while the job waits in the queue is still broadcast.

  • The anonymous event arrived like a class-based one, on private-loans.43 with the name loan.renewed, which Echo receives with .listen('.loan.renewed', ...). send() queues it and sendNow() sends it during the request; ->toOthers() works as for event classes. A name and payload used in more than one place deserve an event class, which can be tested and documented.

  • The same failure is fatal without ShouldRescue. Before ShouldRescue was added, the dispatch threw Illuminate\Broadcasting\BroadcastException: Pusher error: cURL error 7: Failed to connect to localhost port 8080, and a controller that created a book would have answered 500 although the book was saved. With it, the exception went to the log (the reported message includes the request URL with the public app key and a signature, but not the secret) and the request carried on. Rescue suits broadcasts that only refresh a page. On a queued ShouldBroadcast event it guards the push onto the queue; a delivery failure later happens in the worker, as a failed job with its retries.

When routes/channels.php grows, php artisan make:channel LoanChannel creates a class in app/Broadcasting whose join(User $user, ...) method holds the rule, registered with Broadcast::channel('loans.{userId}', LoanChannel::class). The class is resolved from the container, so it can receive services, and it can be unit-tested without a WebSocket.

Running Reverb in production

Reverb is a long-running ReactPHP process, kept alive by Supervisor or systemd like a queue worker. Nginx terminates TLS and proxies wss:// with the Upgrade headers. Each connection is an open file, so the ulimit -n of the process must exceed the expected connections. Beyond one server, REVERB_SCALING_ENABLED=true makes every Reverb instance publish received messages through Redis, so the instances can sit behind a load balancer. The Pulse recorders ReverbConnections and ReverbMessages, shown with the <livewire:reverb.connections /> and <livewire:reverb.messages /> cards, chart the load (Monitoring and Debugging).

The installed config/reverb.php sets 'allowed_origins' => ['*']. Any website can then open a socket with the public key and listen on public channels. Private channels are still protected by the authorization. Listing the application’s own origins limits the public ones too.

The alternatives use the same events and the same Echo API: Pusher Channels and Ably are hosted services (no process to run, priced by connections and messages), and --mercure installs a Mercure hub broadcaster. Switching is a change of BROADCAST_CONNECTION and the Echo broadcaster option.

Server-sent events instead

When the server only pushes and the browser never sends, a WebSocket server may be more than the job needs. response()->eventStream() streams server-sent events over plain HTTP:

<?php
// routes/web.php
Route::get('/catalogue/stream', fn () => response()->eventStream(function () {
    foreach (App\Models\Book::latest('id')->limit(3)->get() as $book) {
        yield new Illuminate\Http\StreamedEvent(event: 'book', data: ['id' => $book->id, 'title' => $book->title]);
        sleep(1);
    }
}));
$ curl -sN localhost:8000/catalogue/stream | while IFS= read -r l; do [ -n "$l" ] && echo "$(date +%T.%N | cut -c1-10) $l"; done
22:18:52.8 event: book
22:18:52.8 data: {"id":28,"title":"Frankenstein"}
22:18:53.8 event: book
22:18:53.8 data: {"id":26,"title":"Wild Seed"}
22:18:54.8 event: book
22:18:54.8 data: {"id":25,"title":"The Lathe of Heaven"}
22:18:55.8 event: update
22:18:55.8 data: </stream>

The events arrived one second apart, and the response carried Content-Type: text/event-stream and X-Accel-Buffering: no, which tells Nginx not to buffer. The final </stream> event marks the end for the useEventStream hooks in @laravel/stream-react, -vue and -svelte. The cost is that each open stream holds a PHP worker for its whole duration. Under PHP-FPM, a hundred listeners need a hundred workers. Reverb holds thousands of sockets in one process. SSE fits short streams such as AI token output (AI SDK, MCP and Boost) or a progress bar. Broadcasting fits long-lived pages with many viewers.

Where older books and tutorials are outdated

  • laravel-websockets (BeyondCode) or a Node laravel-echo-server (Stauffer ch. 16 uses Pusher and mentions them) are replaced by first-party Reverb (Laravel 11).

  • Uncommenting BroadcastServiceProvider in config/app.php is gone. install:broadcasting wires channels.php in bootstrap/app.php.

  • Socket.IO with the Redis broadcaster and a Node bridge is replaced by the Pusher protocol that Reverb speaks.

  • Polling an endpoint every few seconds is replaced by broadcasting, or by eventStream for one-way updates.