+1 (415) 599-8902

Real-Time Laravel with Reverb: Broadcasting, Presence and Production Setup

Most Laravel applications still tell users what happened by making them refresh. A queued import finishes and the page shows stale data; a teammate comments and nobody sees it until the next page load; a dashboard polls every five seconds and hammers the database for the privilege of usually changing nothing.

Broadcasting fixes this, and since Laravel 11 you no longer need a third-party service to do it. Reverb is Laravel's first-party WebSocket server: a PHP daemon you run alongside your app, protocol-compatible with Pusher, so every existing Laravel Echo client works against it unchanged.

This tutorial builds a real feature end to end — live progress for a queued job, plus a presence-aware activity feed — and then covers the part tutorials usually skip: running it in production without it falling over.

What you need

  • Laravel 11, 12 or 13 on PHP 8.2+
  • Redis (for queues and, in production, for scaling Reverb across processes)
  • Node and npm for the front-end client

Step 1: Install Reverb and Echo

php artisan install:broadcasting

That single command publishes config/broadcasting.php and routes/channels.php, installs laravel/reverb, adds the Reverb credentials to .env, and offers to install and scaffold laravel-echo and pusher-js on the front end. Say yes to both prompts.

Your .env gains something like:

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=123456
REVERB_APP_KEY=local-key
REVERB_APP_SECRET=local-secret
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http

VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

Note the two halves. REVERB_* is what your PHP application uses to publish events to the server. VITE_REVERB_* is compiled into your JavaScript bundle so the browser knows where to connect. The app key is public; the app secret must never reach the client.

The generated resources/js/echo.js is standard:

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'],
});

Start the pieces in three terminals:

php artisan serve
php artisan reverb:start --debug
npm run dev

--debug prints every connection, subscription and message. You will want it while building and never in production.

Step 2: An event worth broadcasting

The example everyone reaches for is chat. The example that actually earns its keep on client projects is job progress: a user uploads a 50,000-row CSV, the import runs on the queue, and the browser shows a real progress bar instead of a spinner and a prayer.

php artisan make:event ImportProgressUpdated
<?php

namespace App\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ImportProgressUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public int $importId,
        public int $userId,
        public int $processed,
        public int $total,
    ) {}

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

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

    public function broadcastWith(): array
    {
        return [
            'processed' => $this->processed,
            'total' => $this->total,
            'percent' => $this->total > 0
                ? (int) round($this->processed / $this->total * 100)
                : 0,
        ];
    }
}

Three deliberate choices here:

  • ShouldBroadcast, not ShouldBroadcastNow. The former queues the broadcast so your request or job is not blocked by an HTTP call to the WebSocket server. Use ShouldBroadcastNow only when you have measured that the extra queue hop matters, and remember it makes ordering your problem.
  • broadcastAs() gives the client a short, stable event name (progress.updated) instead of the fully-qualified class name. Rename or move the class later without breaking the front end.
  • broadcastWith() controls the payload explicitly. Without it, every public property is serialised — which is how internal fields, tokens and full model attributes end up in a browser. Always define it.

Step 3: Authorise the channel

Private channels are authorised server-side in routes/channels.php. This is your access control; the channel name in JavaScript is a request, not a permission.

use App\Models\Import;
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('imports.{importId}', function (User $user, int $importId) {
    return Import::whereKey($importId)
        ->where('user_id', $user->id)
        ->exists();
});

Return true/false for private channels. Return an array of user data for presence channels (below). If the callback throws or returns falsy, the subscription is refused and Echo will report an auth error in the console.

Test the callback like any other code:

it('only lets the owner watch an import', function () {
    $import = Import::factory()->create();

    expect(Broadcast::channel(...))->not->toBeNull();

    $this->actingAs($import->user)
        ->postJson('/broadcasting/auth', ['channel_name' => "private-imports.{$import->id}", 'socket_id' => '1.1'])
        ->assertOk();

    $this->actingAs(User::factory()->create())
        ->postJson('/broadcasting/auth', ['channel_name' => "private-imports.{$import->id}", 'socket_id' => '1.1'])
        ->assertForbidden();
});

Step 4: Fire it from the queued job

public function handle(): void
{
    $total = $this->import->rows_total;
    $processed = 0;

    foreach ($this->rows() as $chunk) {
        $this->insert($chunk);
        $processed += count($chunk);

        ImportProgressUpdated::dispatch(
            $this->import->id,
            $this->import->user_id,
            $processed,
            $total,
        );
    }
}

One warning: do not broadcast per row. A 50,000-row import that fires 50,000 events will flood the queue, the WebSocket server and the browser, and the progress bar will lag minutes behind reality. Broadcast per chunk, or throttle to at most a few events per second:

if ($processed % 500 === 0 || $processed === $total) {
    ImportProgressUpdated::dispatch(...);
}

Chunky, infrequent events are the single biggest difference between a real-time feature that feels good and one that melts under load.

Step 5: Listen in the browser

Plain JavaScript:

window.Echo.private(`imports.${importId}`)
    .listen('.progress.updated', (e) => {
        document.querySelector('#bar').style.width = `${e.percent}%`;
        document.querySelector('#label').textContent = `${e.processed} / ${e.total}`;
    });

The leading dot on .progress.updated tells Echo the name is absolute rather than a namespaced class name. Forgetting it is the most common reason "the event never arrives".

In Livewire 3 you do not need Echo wiring by hand — declare the listener on the component:

use Livewire\Attributes\On;

class ImportProgress extends Component
{
    public Import $import;
    public int $percent = 0;

    public function getListeners(): array
    {
        return ["echo-private:imports.{$this->import->id},.progress.updated" => 'onProgress'];
    }

    public function onProgress(array $payload): void
    {
        $this->percent = $payload['percent'];
    }
}

Always clean up when the component or page goes away, otherwise a long-lived SPA accumulates subscriptions:

window.Echo.leave(`imports.${importId}`);

Step 6: Presence channels and whispers

Presence channels are private channels that also track who is subscribed — ideal for "3 people viewing this record" or typing indicators.

Broadcast::channel('records.{record}', function (User $user, Record $record) {
    if (! $user->can('view', $record)) {
        return null;
    }

    return ['id' => $user->id, 'name' => $user->name];
});
window.Echo.join(`records.${recordId}`)
    .here((users) => renderViewers(users))
    .joining((user) => addViewer(user))
    .leaving((user) => removeViewer(user))
    .listenForWhisper('typing', (e) => showTyping(e.name));

Whispers are client-to-client messages that never touch your PHP application:

window.Echo.join(`records.${recordId}`)
    .whisper('typing', { name: window.currentUser.name });

Perfect for typing indicators and cursor positions — high frequency, zero value in persisting. Throttle whispers client-side too; every keystroke is not a message.

One more thing worth knowing: ->toOthers(). When the user who caused a change already updated their own UI optimistically, broadcast to everyone except them:

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

This requires Echo to send its socket ID with the originating request, which the Laravel Echo axios interceptor does automatically for same-origin requests. If you use a custom HTTP client, set the X-Socket-ID header yourself.

Step 7: Testing

Broadcasting is testable without a running Reverb server:

use App\Events\ImportProgressUpdated;
use Illuminate\Support\Facades\Event;

it('broadcasts progress for the import', function () {
    Event::fake([ImportProgressUpdated::class]);

    (new RunImport($import))->handle();

    Event::assertDispatched(ImportProgressUpdated::class,
        fn ($e) => $e->importId === $import->id && $e->total === 100);
});

For the payload and channel, assert on the event object directly — broadcastOn() and broadcastWith() are plain methods, so unit-test them. Reserve a real browser test (Pest 4 browser testing, or Dusk) for one end-to-end smoke test that proves the whole chain works.

Running Reverb in production

This is where projects come unstuck. A checklist from real deployments:

1. Run it as a supervised daemon. Reverb is a long-running process. Under Supervisor:

[program:reverb]
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
user=www-data
numprocs=1
stopwaitsecs=3600

2. Terminate TLS at the proxy. Browsers on HTTPS pages cannot open ws:// connections. Put Nginx in front:

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_read_timeout 60s;
}

Then set REVERB_SCHEME=https, VITE_REVERB_SCHEME=https and VITE_REVERB_PORT=443 so the client connects over wss:// on the standard port — which also survives corporate firewalls that block port 8080.

3. Raise the file descriptor limit. Every connection is a file descriptor. The default 1024 caps you at roughly a thousand concurrent users:

ulimit -n 10000

Set it permanently in /etc/security/limits.conf and in the Supervisor config (minfds=10000).

4. Scale horizontally with Redis. More than one Reverb process or server means a client connected to process A must receive events published by process B. Enable scaling:

REVERB_SCALING_ENABLED=true
REDIS_HOST=...

Reverb then uses Redis pub/sub to fan events out across all processes. Load-balance connections across them; sticky sessions are not required for WebSockets in the way they are for HTTP sessions, but a long-lived connection stays on one node, so plan for uneven distribution.

5. Keep queue workers running. ShouldBroadcast events go through the queue. No workers means no broadcasts, and it is a genuinely confusing failure because nothing errors — events simply pile up. Monitor the default (or a dedicated broadcasts) queue depth.

6. Restart on deploy. Reverb holds your application code in memory. Add php artisan reverb:restart to your deploy script, and expect clients to reconnect — Echo does this automatically, but your UI should re-fetch state on reconnect rather than assume it missed nothing.

7. Watch it. php artisan reverb:start supports --debug locally; in production use Pulse or your APM to track connection counts, and remember that memory grows with connections. A few thousand concurrent connections is comfortable on a modest instance; tens of thousands needs measurement.

When not to use WebSockets

Real-time is not free. If updates are rare, a 30-second poll or a wire:poll.30s on one component is simpler, cheaper and has no daemon to babysit. If you only need server-to-client one-way updates with no presence, server-sent events may be enough. Reach for Reverb when updates are frequent, latency matters, or you need presence and whispers.

Where this goes next

Once broadcasting is in place it tends to spread: live notifications, collaborative editing, dashboards that update themselves, queue progress everywhere. The infrastructure work — supervision, TLS, scaling, restarts — is done once and reused by every feature after it.

If you would like a hand adding real-time features to an existing Laravel application, or getting Reverb running reliably on your infrastructure, see our Laravel feature enhancement and hosting and deployment engineering services, or get in touch.