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
BroadcastContractinterface 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.phpare untestable and grow messy fast. - Prefer
ShouldBroadcastNowfor 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.