illuminate/broadcasting
The Laravel broadcasting component provides tools to broadcast server-side events to client-side JavaScript apps over WebSocket connections, with support for Pusher, Redis, Ably, and custom drivers, plus channel authorization and event broadcasting.
illuminate/broadcasting is not a package you install standalone — it is the framework component behind Broadcast:: and ShouldBroadcast, already present in every Laravel app. If you are outside Laravel, pull it in explicitly:
composer require illuminate/broadcasting
First three things to do, in order:
Pick a driver you can debug. In config/broadcasting.php, set 'default' => env('BROADCAST_CONNECTION', 'log'). The log driver writes every payload to storage/logs/laravel.log — no external service, no frontend needed. This is the fastest way to verify that your event fires, that broadcastOn() returns the channel you expect, and that the payload serializes. Move to pusher/ably/reverb/redis only after the log line looks right.
Register the service provider. Laravel 11+ discovers BroadcastServiceProvider automatically; in older apps confirm it is listed in config/app.php. This is what loads routes/channels.php and makes Broadcast::channel() authorization rules live.
Ship one event end-to-end. The smallest useful loop is a private channel for the current user:
class OrderShipped implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('orders.'.$this->order->user_id)];
}
public function broadcastWith(): array
{
return ['id' => $this->order->id, 'status' => 'shipped'];
}
}
Authorize it in routes/channels.php:
Broadcast::channel('orders.{userId}', fn ($user, $userId) => (int) $user->id === (int) $userId);
Dispatch with OrderShipped::dispatch($order); and confirm the log entry. Then subscribe in Echo: Echo.private('orders.1').listen('OrderShipped', cb).
First use case to target: live notifications for the authenticated user. It exercises the full stack — event, queue, channel auth, Echo — with zero authorization complexity, so any failure is isolated to configuration rather than business logic.
Event-as-trigger, payload-lean. Model state changes as ShouldBroadcast events rather than calling Broadcast::event() ad hoc. Keep broadcastWith() returning IDs and minimal metadata; let the client fetch the rest over HTTP. This keeps you under Pusher's 10KB message limit and avoids CORS/large-header issues. If the payload is genuinely large, broadcast a token and let the client pull.
ShouldBroadcastNow vs ShouldBroadcast. ShouldBroadcast pushes to the queue (good default — the HTTP request returns immediately). ShouldBroadcastNow sends synchronously and blocks the request; reserve it for cases where ordering matters or the event fires outside a queue-safe context. A common mistake is dispatching a broadcasting event inside a queued job that is itself on the sync connection — the broadcast then blocks the request and you lose the async benefit.
Channel authorization as the security boundary. Nothing about broadcastOn() enforces access; the Pusher/Ably auth endpoint does, via Broadcast::channel(). Put every private and presence rule in routes/channels.php and treat it like route middleware:
Broadcast::channel('orders.{orderId}', function ($user, $orderId) {
return $user->orders()->whereKey($orderId)->exists();
});
Presence channels must return an array of user metadata (['id' => $user->id, 'name' => $user->name]) — returning true makes the join succeed but leaves here/joining/leaving callbacks with empty member data.
Echo binding conventions. Laravel broadcasts the event's class name; Echo's .listen('OrderShipped') expects App\Events\OrderShipped to resolve to the OrderShipped alias. That resolution works when the event namespace matches what Echo's broadcaster was configured with. Two failure modes:
App\Domain\Orders\Events\OrderShipped) — Echo won't match. Either .listen('.order.shipped') with a custom broadcastAs() returning order.shipped, or fully qualify in .listen().broadcastAs() to order.shipped but listening for .OrderShipped — the leading dot means "use the raw name, don't prefix."Pick one convention and apply it project-wide.
Model broadcasting. For simple CRUD notifications, Broadcast::channel() plus $model->broadcast() or broadcasts() on the model avoids writing event classes at all:
protected function broadcasts(): array
{
return [new PrivateChannel('orders.'.$this->user_id)];
}
Use this for "record changed" cases; use explicit events when you need distinct payloads or multiple channels per action.
Queue configuration. Broadcasting piggybacks on your queue. In config/broadcasting.php, the queue key controls which connection the broadcast job uses — set it explicitly rather than inheriting QUEUE_CONNECTION if broadcast traffic is heavier than your job traffic. In production you must have php artisan queue:work running for the default connection, or nothing is delivered — a symptom that looks identical to a broken driver.
Testing. Use Event::fake() for dispatch assertions, but to test the broadcast itself use:
Event::fake([OrderShipped::class]);
// ... act
Event::assertDispatched(OrderShipped::class);
For channel authorization, test the closure directly through Broadcast::channel() by asserting against an acting-as user, or hit /broadcasting/auth with $this->actingAs($user) — the latter verifies the guard end-to-end, which is where the real bugs live.
Driver strategy. Use reverb/pusher for managed hosting, redis for apps already running Horizon (Redis pub/sub, requires the phpredis or predis extension and the socket.io-compatible server), log for dev/CI. Note redis broadcasting requires the Redis client, and delivery requires a queue worker; it does not require Redis to listen on a special broadcast port.
Guard mismatch causes silent 403s. The /broadcasting/auth endpoint resolves the user via the guard configured on the broadcasting auth route, not whatever guard your app session uses. If you authenticate via auth:api (token/session mix) but the broadcast auth uses the default web guard, $user is null inside your channel closure and the rule returns false — the frontend just sees a failed subscription with no error. Verify by logging Auth::guard('web')->check() at the auth endpoint, and if guards diverge, add the broadcast route under the correct middleware or align 'guards' in your Echo config.
Queued broadcasts vanish without a worker. This is the single most common "broadcasting is broken" report. Confirm queue:work is running for the connection named in broadcasting.php, confirm the failed-jobs table is empty, and confirm the event implements ShouldBroadcast (not plain ShouldBroadcastNow with a broken driver). If you see the event in jobs but nothing arrives, the worker isn't consuming it.
Event name mismatch. Class name → Echo alias relies on namespace conventions. If .listen('OrderShipped') never fires but the log shows a successful broadcast, check the exact channel name in the payload against broadcastOn() — a trailing . or wrong model ID yields a valid send to a channel nobody is subscribed to. Echo's private channel name includes the private- prefix internally; log the payload's channel field to see the real value.
Payload serialization blowups. Broadcasting serializes the event through the queue and again through the driver. SerializesModels re-fetches models at send time — if a model was deleted between dispatch and send, the broadcast throws and lands in failed jobs. Store IDs in the event constructor rather than full models for anything with a short lifecycle, and always implement broadcastWith() to control exactly what crosses the wire.
dd() inside broadcastOn() or the channel closure kills the queue worker. Useful locally, dangerous if it sneaks past review — the worker dies silently and later jobs pile up. Use Log::debug() instead.
CORS and auth headers. For Pusher/Ably, ensure your /broadcasting/auth route is CSRF-exempt (it is, by default via the framework's exception list) and that Echo's authEndpoint and auth.headers match your app's auth scheme. A 419 on /broadcasting/auth almost always means CSRF — check VerifyCsrfToken::$except.
Debugging checklist, in order: switch to the log driver → dispatch the event → confirm the log line shows the right channel and payload → check /broadcasting/auth returns 200 for the intended user → confirm the queue worker is alive → open the browser's WS frames tab and confirm a Subscribe followed by a SubscriptionSucceeded. Each step isolates one layer; skipping ahead wastes hours.
Extensibility points. Implement Illuminate\Contracts\Broadcasting\Broadcaster to wrap an existing broadcaster with retry logic, custom headers, or an auth proxy; register it via Broadcast::extend('custom', fn ($app, $config) => new CustomBroadcaster(...)) in a service provider, then set 'driver' => 'custom'. This is the right seam for enterprise gateways, load-balanced self-hosted socket servers, or injecting observability into every send. Similarly, Broadcast::channel() closures can be extracted into dedicated Channel classes and registered with Broadcast::channel('orders.{id}', OrderChannel::class) for testable, autowired authorization.
How can I help you explore Laravel packages today?