Typed Broadcasting Contracts in Laravel Reverb | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. Laravel Broadcasting with Reverb: Building Typed Event Contracts for WebSocket Channels

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

 Skip the stringly-typed broadcasting guesswork. Learn how to define strict PHP event contracts, scope them to private channels, and test the full WebSocket flow without a live server.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 19 Jul 2026 · Updated 19 Jul 2026 · 3 min read

ShareCopy linkCopied

 ![Laravel Broadcasting with Reverb: Building Typed Event Contracts for WebSocket Channels](https://cdn.msaied.com/444/e50bb90e38d98d08949583fdcf123e65.png) 

  On this page +1. [The Problem with Stringly-Typed Broadcasts](#the-problem-with-stringly-typed-broadcasts)
2. [Defining a Typed Broadcast Contract](#defining-a-typed-broadcast-contract)
3. [Channel Authorization with Typed Guards](#channel-authorization-with-typed-guards)
4. [Testing the Full Flow with Pest](#testing-the-full-flow-with-pest)
5. [Architectural Takeaways](#architectural-takeaways)

 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:

```php
namespace App\Broadcasting\Contracts;

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

    /** @return array */
    public function broadcastWith(): array;

    public function broadcastAs(): string;
}

```

Now implement a concrete event:

```php
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:

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

```

```php
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:

```php
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:

```php
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.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [reverb](https://www.msaied.com/public/articles?search=reverb)
- [broadcasting](https://www.msaied.com/public/articles?search=broadcasting)
- [websockets](https://www.msaied.com/public/articles?search=websockets)
- [pest](https://www.msaied.com/public/articles?search=pest)

 Frequently asked questions 
---------------------------

  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.

   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.

   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.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

[Mohamed Said](https://www.msaied.com/public#person)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articlePostgreSQL Partial, Covering, and Expression Indexes for Laravel Query Tuning](https://www.msaied.com/public/articles/postgresql-partial-covering-and-expression-indexes-for-laravel-query-tuning) [Next articleLaravel Event Sourcing: Projections, Snapshots, and Replay Without the Framework Tax](https://www.msaied.com/public/articles/laravel-event-sourcing-projections-snapshots-and-replay-without-the-framework-tax)  

   On this page
-------------

1. [The Problem with Stringly-Typed Broadcasts](#the-problem-with-stringly-typed-broadcasts)
2. [Defining a Typed Broadcast Contract](#defining-a-typed-broadcast-contract)
3. [Channel Authorization with Typed Guards](#channel-authorization-with-typed-guards)
4. [Testing the Full Flow with Pest](#testing-the-full-flow-with-pest)
5. [Architectural Takeaways](#architectural-takeaways)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
