You add a comment, and four other people on the same page see it appear. You start typing, the avatars at the bottom show the typing dots. A long-running export finishes and the button on someone else's screen flips from "Generating..." to "Download." For most of Laravel's history, getting any of that working meant signing up for Pusher, paying per connection, or running Soketi yourself and praying the Node service didn't crash overnight.

Reverb changed that. It's Laravel's own WebSocket server, shipped with Laravel 11 in March 2024, and it's the kind of feature where the right reaction is "wait, this just runs?" Yes. It just runs.

What Reverb Actually Is

Reverb is a PHP WebSocket server built on top of ReactPHP, distributed as a first-party Laravel package. It speaks the Pusher wire protocol — meaning the JavaScript client (pusher-js) and laravel-echo work against it unchanged. If you already had a Laravel app talking to Pusher or Soketi, switching to Reverb is mostly a config change.

Install with the dedicated artisan command, which also wires up config/broadcasting.php, env keys, the JS bootstrap, and a default channel auth route:

Bash
php artisan install:broadcasting

It will ask whether you want Reverb or Pusher. Pick Reverb. You now have a working broadcasting setup. Run the server:

Bash
php artisan reverb:start

By default it binds to 0.0.0.0:8080 and uses the app key/secret you generated. Configuration lives in config/reverb.php (apps, allowed origins, scaling). In production you'll put it behind Nginx with a TLS terminator and use a process supervisor — same shape as any persistent daemon.

How Broadcasting Wires The App To The Browser

Broadcasting is event-driven. You raise an event in PHP, mark it as broadcastable, and Laravel pushes the payload to a channel. The browser is subscribed to that channel and reacts.

PHP
namespace App\Events;

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

    public function __construct(public Comment $comment) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('post.'.$this->comment->post_id)];
    }

    public function broadcastWith(): array
    {
        return [
            'id'      => $this->comment->id,
            'body'    => $this->comment->body,
            'author'  => $this->comment->author->name,
            'postedAt'=> $this->comment->created_at->toIso8601String(),
        ];
    }
}

The ShouldBroadcast interface queues the broadcast. If you want it sent synchronously from the request, implement ShouldBroadcastNow instead — useful for events fired from within an already-queued job, but generally you want the queued version.

On the JavaScript side, you import Echo and Pusher and point them at Reverb:

JavaScript
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,
    wssPort: import.meta.env.VITE_REVERB_PORT,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

window.Echo.private(`post.${postId}`)
    .listen('CommentPosted', (e) => {
        appendComment(e);
    });

Three pieces — event class, channel, listener — and a comment posted from one tab appears in another tab in milliseconds.

Public, Private, And Presence Channels

Channels come in three flavors, each with different auth semantics.

  • Public channels require no auth. new Channel('news'). Useful for marketing-style fan-out where it doesn't matter who hears.
  • Private channels require auth. new PrivateChannel('post.42'). The browser subscribes via Echo, Echo POSTs to your /broadcasting/auth endpoint, and a callback in routes/channels.php returns true/false based on the authenticated user.
  • Presence channels are private channels with member awareness. Subscribers receive a list of who else is in the channel and live join/leave events. new PresenceChannel('post.42') and the auth callback returns user data (an array) instead of a boolean.
PHP
// routes/channels.php
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('post.{postId}', function ($user, $postId) {
    return $user->canViewPost($postId);
});

Broadcast::channel('document.{documentId}', function ($user, $documentId) {
    if (! $user->canEditDocument($documentId)) {
        return false;
    }
    return ['id' => $user->id, 'name' => $user->name, 'avatar' => $user->avatar_url];
});

The presence channel pattern is what you reach for when you want avatars-of-everyone-currently-viewing — Google Docs style. Echo gives you here(), joining(), leaving() on the subscription so the UI can update as people come and go.

Diagram of a Reverb broadcast loop: a Laravel HTTP request triggers an event that implements ShouldBroadcast, the queued broadcast goes to Reverb on port 8080, Reverb pushes the message over a WebSocket to every subscribed Echo client. Two browser tabs are shown — one private channel for the comment fan-out, one presence channel showing five live avatars. The flow shows the broadcasting/auth round-trip on subscription.
Event in Laravel, message on Reverb, render in every connected browser.

Broadcasting To The Sender Or Not

A frequent confusion: when User A posts the comment, do they receive the broadcast they just triggered? By default, yes. Echo will hear its own event, you'll insert the comment into the DOM via the WebSocket reply, and you'll have a brief race with whatever optimistic UI you wrote.

The toOthers() helper handles it. It tells the broadcaster to skip the connection that originated the request:

PHP
broadcast(new CommentPosted($comment))->toOthers();

This works because Echo sends a X-Socket-Id header when subscribed, and Laravel uses it to filter on the broadcast. If you forget, your own UI shows duplicates. It's the most common Reverb bug.

Three-panel comparison of the same Reverb broadcast at three altitudes. Left panel "Local · npm run dev" — two browser-tab mockups showing tab A posting a comment and tab B receiving it in 8 ms, a flat latency line, and a "what's hidden" list (sync queue, allowed_origins '*', toOthers() not yet noticed, SerializesModels never crashes). Middle panel "Production · reverb:start" — a Reverb log tail with auth round-trip, queued broadcast, worker idle, origin-denied, duplicate echo from missing toOthers(), SerializesModels lookup against a deleted row, presence join, and a jagged latency chart peaking at p95 1.4s. Right panel "Monitoring · /horizon + Pulse" — metric cards for connections (3,184), broadcasts/min (412), auth p95 (42 ms), failed broadcasts 24h (19), a top-channels list with private-post.142, presence-doc.9, private-typing.42, and a broadcast queue lag chart averaging 0.6s.
Same code, three audiences — only the dashboard remembers what really happened.

Where Reverb Slots In Production

Reverb is one process. You start it, you keep it alive, and your Echo clients connect to it. The deployment shape that holds up:

  • Process supervisor. systemd or supervisord, the same way you'd keep a queue worker running. There's a reverb:restart command that signals running daemons to stop after the current connections drain.
  • Nginx in front. TLS termination, a proxy_pass upgrade to the WebSocket on port 8080. Reverb does not need to handle TLS itself.
  • Scaling. Single Reverb instance handles a lot — the docs benchmark thousands of concurrent connections per process. When you outgrow one, Reverb has a Redis-backed pub/sub mode so multiple Reverb processes share state. Configure reverb.scaling.enabled and you can run several behind a load balancer with sticky sessions.
  • Same machine as the app. No reason it needs its own server. It's a daemon next to PHP-FPM and Horizon.

For most apps the right answer is one Reverb process per node, fronted by Nginx, started by supervisor, monitored by Pulse. That's it.

Common Gotchas Worth Knowing About

A few things bite teams new to Reverb:

  • The auth endpoint. /broadcasting/auth needs to be reachable from the browser with the user's session cookie or API token. SPA setups behind a separate domain occasionally forget the CORS config, and the symptom is silent — Echo just never subscribes.
  • Queue connection for broadcasts. Broadcasts implementing ShouldBroadcast are queued. If your queue worker is down, your broadcast doesn't fire. Use ShouldBroadcastNow for events triggered from inside a job to avoid an extra hop.
  • Allowed origins. config/reverb.php defaults to * in development. In production lock it down to your actual domains, otherwise any browser anywhere can connect.
  • Model serialization. SerializesModels re-fetches the model when the queued broadcast runs. If the row was deleted between the dispatch and the broadcast, the broadcast crashes. For ephemeral events (UserStartedTyping), don't pass full Eloquent models — pass IDs and handle the lookup in the listener.
  • Heroku and other ephemeral platforms. A WebSocket server needs a long-lived process. Heroku's request timeout will kill it. Reverb is a fit for VPS, Forge, Vapor (with caveats), or your own Kubernetes — not for "12-factor PaaS where every dyno reboots in 24 hours" without thought.

When You Don't Need Broadcasting At All

The trap with real-time is that it sounds cooler than what the product actually needs. Two questions to ask before you reach for Reverb:

  1. Does the user expect updates within seconds, or within a refresh? If they tab away and come back, polling every 30 seconds is fine. Real-time matters when latency between users is the feature.
  2. Is this driven by another user, or by a server-side process? If it's a long-running job and the user is staring at the screen waiting, a simple SSE endpoint (or polling with exponential backoff) ships in a fraction of the time.

Reverb is great. So is not needing it. Treat the WebSocket as a tool, not a default.

A One-Sentence Mental Model

Reverb is a Pusher-compatible WebSocket server that runs next to your Laravel app — broadcastable events queue up, push through Reverb, and arrive in Echo clients on private or presence channels with auth handled by routes/channels.php, which is everything you actually need to build live UI without paying anyone per connection.