Laravel Broadcasting with Reverb: Building Typed Event Contracts for WebSocket Channels
#laravel #reverb #broadcasting #websockets #pest

Laravel Broadcasting with Reverb: Building Typed Event Contracts for WebSocket Channels

3 min read Mohamed Said Mohamed Said

The Problem with Stringly-Typed Broadcasts

Most Laravel broadcasting tutorials stop at event(new OrderShipped($order)) and a matching Echo.private('orders.' + id) on the frontend. That works until a channel name typo silently drops events in production, or a payload shape change breaks the JS client with no PHP-side warning.

The fix is treating broadcast events as first-class typed contracts — enforced on both the PHP emitter and the channel authorization layer.

Defining a Typed Broadcast Contract

Start with an interface that every broadcastable event in a bounded context must implement:

namespace App\Broadcasting\Contracts;

interface BroadcastContract
{
    /** @return non-empty-string[] */
    public function broadcastOn(): array;

    /** @return array<string, mixed> */
    public function broadcastWith(): array;

    public function broadcastAs(): string;
}

Now implement a concrete event:

namespace App\Domain\Orders\Events;

use App\Broadcasting\Contracts\BroadcastContract;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;

final class OrderStatusUpdated implements ShouldBroadcastNow, BroadcastContract
{
    public function __construct(
        public readonly int $orderId,
        public readonly string $status,
        public readonly int $tenantId,
    ) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel("tenant.{$this->tenantId}.orders.{$this->orderId}")];
    }

    public function broadcastWith(): array
    {
        return [
            'order_id' => $this->orderId,
            'status'   => $this->status,
        ];
    }

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

ShouldBroadcastNow skips the queue — useful for status updates where latency matters more than throughput.

Channel Authorization with Typed Guards

Define the channel in routes/channels.php and keep the authorization logic in a dedicated class:

// routes/channels.php
Broadcast::channel(
    'tenant.{tenantId}.orders.{orderId}',
    App\Broadcasting\Channels\TenantOrderChannel::class
);
namespace App\Broadcasting\Channels;

use App\Models\Order;
use App\Models\User;

final class TenantOrderChannel
{
    public function join(User $user, int $tenantId, int $orderId): bool
    {
        if ($user->tenant_id !== $tenantId) {
            return false;
        }

        return Order::query()
            ->where('id', $orderId)
            ->where('tenant_id', $tenantId)
            ->exists();
    }
}

Using an invokable class instead of a closure keeps the authorization logic testable in isolation and out of the route file.

Testing the Full Flow with Pest

Laravel's Event::fake() and Broadcasting::fake() let you assert broadcast behavior without a live Reverb server:

use App\Domain\Orders\Events\OrderStatusUpdated;
use Illuminate\Support\Facades\Broadcasting;
use Illuminate\Support\Facades\Event;

it('broadcasts order status update on the correct private channel', function () {
    Broadcasting::fake();

    $order = Order::factory()->for(
        Tenant::factory()->create(['id' => 42])
    )->create();

    event(new OrderStatusUpdated(
        orderId: $order->id,
        status: 'shipped',
        tenantId: 42,
    ));

    Broadcasting::assertSentTo(
        new \Illuminate\Broadcasting\PrivateChannel("tenant.42.orders.{$order->id}"),
        OrderStatusUpdated::class,
        fn ($event) => $event->status === 'shipped'
    );
});

For channel authorization, test the channel class directly:

it('denies access to orders from a different tenant', function () {
    $user = User::factory()->create(['tenant_id' => 1]);
    $order = Order::factory()->create(['tenant_id' => 2]);

    $channel = new \App\Broadcasting\Channels\TenantOrderChannel();

    expect($channel->join($user, tenantId: 2, orderId: $order->id))->toBeFalse();
});

No HTTP request, no WebSocket handshake — pure unit test.

Architectural Takeaways

  • Define a BroadcastContract interface so every event in a context is structurally consistent and statically analysable.
  • Use broadcastAs() to decouple the PHP class name from the JS event name — rename classes freely without breaking clients.
  • Move channel auth to dedicated classes — closures in channels.php are untestable and grow messy fast.
  • Prefer ShouldBroadcastNow for user-facing status updates; reserve queued broadcasting for high-volume background events.
  • Test with Broadcasting::fake() — assert channel, event name, and payload shape without infrastructure.

Found this useful?

Frequently Asked Questions

3 questions
Q01 When should I use ShouldBroadcastNow versus ShouldBroadcast?
Use ShouldBroadcastNow when the event must reach the client immediately, such as a status update triggered by a user action. Use ShouldBroadcast (queued) for high-volume events like analytics pings or background job progress, where a small delay is acceptable and you want to protect your Reverb server from burst load.
Q02 How do I prevent channel name collisions in a multi-tenant app?
Prefix every channel with a tenant identifier, e.g. tenant.{tenantId}.resource.{id}. Enforce this in the BroadcastContract implementation and validate the tenantId in the channel authorization class, rejecting any user whose tenant_id does not match the channel parameter.
Q03 Does Broadcasting::fake() work with Reverb specifically?
Yes. Broadcasting::fake() intercepts the broadcast dispatch before it reaches any driver, including Reverb. Your assertions run against the in-memory fake, so the tests are driver-agnostic and require no running Reverb server.

Continue reading

More Articles

View all