Per-Action Broadcast Events over Role-Scoped Private Channels (Reverb + React)

Jul 18 / 1 min read

Language Mismatch Disclaimer: Please be aware that the language of this article may not match the language settings of your browser or device.
Do you want to read articles in English instead ?

Most real-time features start life as one event. Something changed on the server, so you fire a DataUpdated broadcast, every client hears it, and every client refetches. It demos beautifully. Then the product grows a second user role, and a third, and suddenly that one signal is waking up every connected browser on every mutation, refetching data those users cannot even see, and quietly leaking the fact that something happened on a resource they have no business knowing about.

I have built this the lazy way and I have built it the deliberate way. The deliberate way is not much more code, and it is the difference between a real-time layer that scales with your roles and one that fights you at every new feature. The pattern has four parts: named per-action events, private role-scoped channels, ids-not-records payloads, and broadcast after commit on a dedicated lane. This post walks through all four on both sides, so you can lift the shape into your own Laravel Reverb and React app.

I will keep the example generic: a shared board that several roles look at, where any of a dozen actions can change what one role should be seeing right now. If you want the real system this pattern came out of, it is a K-12 scheduling platform I wrote up as a case study.

The problem with one firehose event

When multiple roles each see a different slice of the same data, a change is never a broadcast to everyone. A counselor claiming a queue item matters to the queue staff and to that student, not to a teacher three classrooms away. So the design question is not "how do I push changes," it is "how do I push this change to exactly the screens that should react, and no others."

A single "refetch" event fails that on two counts. It is wasteful, because it wakes clients that have nothing to update. And it is a leak, because the mere arrival of the event on a channel tells a listener that activity is happening on a resource. Both problems disappear once each action owns its own event and each event owns its own routing.

Laravel: a named per-action event

Each board-mutating action emits its own event. The event carries the model, implements ShouldBroadcast so Reverb picks it up, and implements ShouldDispatchAfterCommit so it never fires from inside an open transaction.

<?php

declare(strict_types=1);

namespace App\Events;

use App\Models\Assignment;
use App\Models\User;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;

final readonly class AssignmentClaimed implements ShouldBroadcast, ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(
        public Assignment $assignment,
    ) {}

    /** @return array<int, PrivateChannel> */
    public function broadcastOn(): array
    {
        $channels = [
            new PrivateChannel('queue.'.$this->assignment->queue_type),
        ];

        $ownerId = User::where('id', $this->assignment->owner_id)->value('id');
        if (is_string($ownerId)) {
            $channels[] = new PrivateChannel('user.'.$ownerId);
        }

        return $channels;
    }

    public function broadcastAs(): string
    {
        return 'assignment.claimed';
    }

    /** @return array<string, mixed> */
    public function broadcastWith(): array
    {
        return [
            'assignment_id' => $this->assignment->id,
            'status' => $this->assignment->status->value,
        ];
    }
}

Four things earn their keep here:

  • broadcastAs() gives the event a stable name. assignment.claimed is what the React side subscribes to. Named events mean a client can listen for the two or three things it cares about instead of a catch-all, and your wire protocol reads like a domain vocabulary rather than a class-name dump.
  • broadcastOn() computes recipients per event. The event itself decides which channels its recipients listen on: a role channel for the queue, plus the affected user's private channel. The routing lives with the event that knows the domain, not in a central switchboard you have to keep in sync.
  • broadcastWith() sends ids and enums, never the record. The payload is an id and a status. A channel can never overshare, because there is nothing sensitive on the wire. The client's job is to hear "this id changed" and go re-read through its normal authorized API, which brings me to the React side in a moment.
  • ShouldDispatchAfterCommit plus a bounded retry policy. The broadcast dispatches only after the database transaction commits, so a client never sees a change that later rolls back. Retry count and backoff for a broadcast job are queue worker configuration, not something the event declares, so transient Reverb hiccups get bounded retries without turning into infinite retry storms.

I also pin these broadcast jobs to their own queue so a slow transport never sits behind general work:

public function broadcastQueue(): string
{
    return 'broadcasts';
}

Give that lane its own worker in your queue config. Broadcasts are latency-sensitive in a way that a nightly export is not, and a dedicated lane keeps the two from starving each other.

Channel authorization is where the safety lives

None of the above is safe until the channels are guarded. This is the channel authorization step, and it is a correctness requirement, not a nicety. In routes/channels.php, every private channel a client can subscribe to gets a callback that returns whether this user is allowed to listen:

<?php

declare(strict_types=1);

use App\Enums\Role;
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

// A user's own private channel: only they may listen.
Broadcast::channel('user.{userId}', fn (User $user, string $userId): bool => $user->id === $userId);

// A role-scoped queue channel: only staff with a service role may subscribe.
Broadcast::channel('queue.{queueType}', fn (User $user, string $queueType): bool => $user->hasAnyRole([
    Role::Counselor->value,
    Role::Nurse->value,
    Role::Admin->value,
]));

The per-user channel guard is an identity check. The role channel guard is an authorization check against the same role model the rest of your app uses. Because the guard runs when the client subscribes, an unauthorized user is turned away at the door: they never receive a single event on that channel, so the ids-not-records payload never even reaches them. The two defenses compound. Even if a payload were richer than it should be, a client that cannot subscribe never sees it.

I write a small note in comments above any channel whose payloads touch sensitive resources, spelling out which roles may subscribe and why. Six months later that comment is the fastest way to answer "can a teacher see this?"

React: consume the named event, invalidate the query

On the front end I treat the broadcast as the source of truth for when to refetch, and TanStack Query as the source of truth for what the data is. The hook subscribes to the channel, listens for the named event, and invalidates the relevant query. It does not trust the payload to patch state directly.

import { useEcho } from '@laravel/echo-react';
import { useQueryClient } from '@tanstack/react-query';

type AssignmentClaimedEvent = { assignment_id?: string; status?: string };

export function useAssignmentStream(queueType: string) {
    const queryClient = useQueryClient();

    useEcho(
        `queue.${queueType}`,
        ['.assignment.claimed'],
        (payload: unknown) => {
            const event = payload as AssignmentClaimedEvent;

            if (event.assignment_id) {
                void queryClient.invalidateQueries({
                    queryKey: ['assignments', queueType],
                });
            }
        },
    );
}

The leading dot in .assignment.claimed tells Echo this is a custom broadcastAs name rather than a fully-qualified class. When the event lands, the hook invalidates the assignments query, TanStack Query refetches through the normal authorized endpoint, and the board updates. The user re-reads data they are allowed to read, over a request that runs the same policies as any other fetch. Nothing sensitive rode in on the socket.

This is deliberately server-confirmed, not optimistic. Optimistic updates feel instant, but they invite divergence the moment a mutation is rejected by a server-side rule, and on a shared board divergence is a correctness bug. The authoritative update arrives over the socket and invalidates the cache. I trade the last few milliseconds of perceived latency for a board that is never lying to anyone. The whole approach, and the reasoning behind server-confirmed writes, is what I do day to day as a React frontend developer working against real-time Laravel backends.

What breaks first, honestly

The nice property of this design is that its scale limit is legible. Under heavy concurrent load the first thing to saturate is not your database and not the React clients: it is the broadcast fan-out itself, the queue lane feeding Reverb and the per-event channel computation that resolves recipients. Knowing that in advance means the headroom plan is horizontal Reverb capacity and precomputed recipient routing, not a bigger database you did not need. A design with a named limit beats one with a hidden one.

The shape, in one breath

Name every action's event. Let each event compute its own role-scoped private channels. Send ids and enums, never records. Dispatch after commit, on a lane of its own, with bounded retries. Guard every channel with the same authorization model as the rest of the app. On the client, listen for the named event and invalidate a query rather than patching state from the payload. That is the entire pattern, and it holds up as roles multiply, which the one-firehose version never does.