Home PHP Laravel Broadcasting Explained: Real-Time Events, Channels & WebSockets in Production

Laravel Broadcasting Explained: Real-Time Events, Channels & WebSockets in Production

In Plain English 🔥
Imagine a radio station. When the DJ says something, every radio tuned to that station hears it instantly — the DJ doesn't call each listener individually. Laravel Broadcasting works the same way: your server is the DJ, your browser is the radio, and a channel is the station frequency. The moment something interesting happens on your server (a new message, an order update, a live score), every browser 'tuned in' gets the update without refreshing the page.
⚡ Quick Answer
Imagine a radio station. When the DJ says something, every radio tuned to that station hears it instantly — the DJ doesn't call each listener individually. Laravel Broadcasting works the same way: your server is the DJ, your browser is the radio, and a channel is the station frequency. The moment something interesting happens on your server (a new message, an order update, a live score), every browser 'tuned in' gets the update without refreshing the page.

Most web apps start request–response: the browser asks, the server answers, everyone goes home. That model collapses the moment your users expect live updates — a chat message that appears without a page reload, a dashboard that ticks in real time, a notification that pops up the second an order ships. Building that from scratch means wrestling with WebSockets, long-polling, reconnection logic, and heartbeat timers. It is a genuine pain, and it is exactly the problem Laravel Broadcasting was designed to erase.

Laravel Broadcasting is the layer that connects your server-side events to your browser-side JavaScript listeners through a persistent connection. You fire an event on the server, Broadcasting serialises it onto a channel, a WebSocket driver delivers it to every subscribed client, and Laravel Echo (the companion JS library) reacts to it — all without you writing a single line of WebSocket server code. The real genius is the abstraction: you can swap the underlying driver (Pusher, Ably, Redis/Soketi, or the new first-party Laravel Reverb) without touching your application logic.

By the end of this article you will understand exactly how a broadcasted event travels from a queued job all the way to a DOM update, how to choose and configure the right driver for your scale, how to lock down private and presence channels so sensitive data stays safe, and how to avoid the handful of production gotchas that catch even experienced Laravel engineers off guard.

How Broadcasting Actually Works Under the Hood

When you call broadcast(new OrderShipped($order)), a lot happens before any browser sees a thing. Laravel resolves the BroadcastManager, which picks the configured driver (Pusher, Reverb, Redis, etc.) from config/broadcasting.php. The event is serialised — by default via its broadcastWith() payload — and handed off to that driver's HTTP or socket API.

If your event implements ShouldBroadcastNow it is dispatched synchronously on the current process. If it implements ShouldBroadcast it is pushed onto your queue, which means the HTTP request returns immediately and the broadcast happens in a worker process. This distinction matters enormously under load: synchronous broadcasts block your web server, so ShouldBroadcast (async) is almost always the right choice in production.

On the client side, Laravel Echo opens a persistent WebSocket connection to whatever server the driver provides. Echo subscribes to a channel by name. When the driver receives a message for that channel name it pushes it down every open socket subscribed to it. Echo's event listener fires, your JavaScript callback runs, and the DOM updates. No polling, no page reload, no manual socket management.

The channel name is the routing key for the entire system. Get it wrong and messages silently disappear — which is responsible for more 'broadcasting is broken' support tickets than anything else.

OrderShipped.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
<?php

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

// ShouldBroadcast tells Laravel to push this event through the broadcast driver.
// Using ShouldBroadcastNow would skip the queue — fine for low traffic, risky at scale.
class OrderShipped implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        // public properties are automatically included in broadcastWith() by default
        public readonly Order $order
    ) {}

    /**
     * Define WHICH channel(s) this event broadcasts on.
     * A PrivateChannel requires the client to be authorised — crucial for user-specific data.
     */
    public function broadcastOn(): array
    {
        return [
            // Channel name uses the order owner's ID so each user only sees their own updates.
            new PrivateChannel('orders.' . $this->order->user_id),
        ];
    }

    /**
     * Control exactly what data goes to the browser.
     * Never rely on the default — always be explicit to avoid leaking sensitive model attributes.
     */
    public function broadcastWith(): array
    {
        return [
            'order_id'     => $this->order->id,
            'status'       => $this->order->status,
            'updated_at'   => $this->order->updated_at->toIso8601String(),
            // Deliberately omitting payment_token, internal_notes, etc.
        ];
    }

    /**
     * Override the default event name (App\Events\OrderShipped) with something
     * clean that your JavaScript can reference without knowing PHP namespaces.
     */
    public function broadcastAs(): string
    {
        return 'order.shipped';
    }
}
▶ Output
// No direct console output — this is an event class.
// When dispatched, the broadcasting driver logs:
// Broadcasting [order.shipped] on [private-orders.42]
// (visible with LOG_CHANNEL=stack and BROADCAST_DRIVER debug enabled)
⚠️
Watch Out: ShouldBroadcast vs ShouldBroadcastNowUsing ShouldBroadcastNow feels simpler but it executes the driver's HTTP call (to Pusher, Reverb, etc.) synchronously inside your web request. Under load this adds 50–200 ms of latency to every response that triggers it and can cascade into timeouts. Default to ShouldBroadcast and ensure your queue workers are running.

Public, Private and Presence Channels — Security Is Not Optional

Laravel Broadcasting has three channel types, and picking the wrong one is a security hole, not just an inconvenience.

Public channels (Channel) require zero authentication. Anyone who knows the channel name can subscribe. Use these only for genuinely public data: live sports scores, public leaderboards, site-wide announcements.

Private channels (PrivateChannel) require the client to hit your /broadcasting/auth endpoint before Echo will subscribe. Laravel checks the channel name against your routes/channels.php authorisation callbacks. If the callback returns false the connection is refused at the driver level. Use these for anything user-specific: order updates, private messages, account notifications.

Presence channels (PresenceChannel) are private channels with an extra superpower: they track who is currently subscribed. Every join and leave fires a membership event, and every subscriber can query the full member list. This makes them perfect for 'who is online in this room' features, collaborative editors, and live user counts.

The authorisation flow is: Echo sends a POST to /broadcasting/auth with the socket ID and channel name, Laravel runs your channel route callback, and if it returns truthy the driver issues a signed auth token that proves this socket is allowed on this channel. The token lives for the duration of the connection — it is not re-checked on every message.

channels.php · PHP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
<?php

// routes/channels.php — this is your channel-level authorization firewall.
// Return true/false for private channels, return a user data array for presence channels.

use App\Models\Order;
use App\Models\ChatRoom;
use Illuminate\Support\Facades\Broadcast;

/*
 |--------------------------------------------------------------------------
 | Private Channel: orders.{userId}
 |--------------------------------------------------------------------------
 | The {userId} wildcard is resolved and passed as a parameter.
 | The authenticated user object is always the first argument.
 | Return true only if the authenticated user owns this channel.
 */
Broadcast::channel('orders.{userId}', function ($authenticatedUser, int $userId) {
    // Strict integer comparison prevents type-juggling bypass (e.g. '1abc' == 1 in PHP)
    return $authenticatedUser->id === $userId;
});

/*
 |--------------------------------------------------------------------------
 | Presence Channel: chat-room.{roomId}
 |--------------------------------------------------------------------------
 | For presence channels, return an ARRAY of user data on success.
 | This array becomes the member info other subscribers can see.
 | Return false to deny access.
 */
Broadcast::channel('chat-room.{roomId}', function ($authenticatedUser, int $roomId) {
    $room = ChatRoom::find($roomId);

    // Guard: room must exist and user must be a member
    if (! $room || ! $room->hasMember($authenticatedUser)) {
        return false; // Echo will throw a subscription error on the client
    }

    // The array you return becomes the member object other users can inspect.
    // Only expose what other room members should actually see.
    return [
        'id'     => $authenticatedUser->id,
        'name'   => $authenticatedUser->display_name,
        'avatar' => $authenticatedUser->avatar_url,
    ];
});

/*
 |--------------------------------------------------------------------------
 | Class-based channel authorisation (cleaner for complex logic)
 |--------------------------------------------------------------------------
 | Instead of a closure, point to a classLaravel will resolve it from the container,
 | meaning you can inject repositories, services, and cache layers.
 */
Broadcast::channel('orders.{userId}', \App\Broadcasting\OrderChannel::class);
▶ Output
// When a client attempts to subscribe to private-orders.42 as user 99:
// POST /broadcasting/auth → 403 Forbidden
// Echo client fires: .subscription_error event

// When client subscribes as user 42:
// POST /broadcasting/auth → 200 OK
// {"auth": "app_key:signed_hmac_token", "channel_data": null}
⚠️
Pro Tip: Use Class-Based Channels for Anything Non-TrivialClosure-based channel auth in routes/channels.php can't be cached, can't be unit tested in isolation, and gets messy fast. Extract to a class with a join() method the moment your auth logic involves more than a single ID comparison. Laravel resolves the class from the service container, so you can inject ACL services, caches, or repository layers cleanly.

Choosing Your Driver: Pusher vs Redis/Soketi vs Laravel Reverb

The driver is the WebSocket server that sits between Laravel and the browser. Your application code stays identical across drivers — only the config changes. But the operational implications are wildly different.

Pusher / Ably are managed services. You pay per message and per connection. Zero infrastructure to run, excellent dashboards, global edge nodes. Perfect for startups and apps where engineering time is more expensive than Pusher's bill. The hard limits (100 connections on free tier, message rate caps) become painful fast on high-traffic apps.

Redis + Soketi (or the older laravel-websockets package) runs your own WebSocket server, using Redis pub/sub as the message bus. Your Laravel app publishes to a Redis channel, Soketi subscribes and pushes to clients. This is Pusher-protocol-compatible, meaning your existing Pusher-flavoured Echo config works unchanged. Great for privacy-sensitive data and when you need horizontal scaling, but you own the ops burden.

Laravel Reverb (Laravel 11+) is the first first-party WebSocket server. It is written in PHP using ReactPHP/Amp event loops, meaning it runs as a long-lived PHP process rather than Node.js. It speaks the Pusher protocol so Echo requires no changes. For most applications it is now the default recommendation: single binary, php artisan reverb:start, no Node runtime, integrates with Octane for maximum throughput.

The choice comes down to three questions: do you want zero ops overhead (Pusher/Ably), full data sovereignty (Reverb/Soketi), or are you already on a Redis-heavy stack?

broadcasting.php · PHP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
<?php

// config/broadcasting.php — annotated production configuration
// Switch drivers by changing BROADCAST_DRIVER in your .env — zero code changes needed.

return [

    /*
    |--------------------------------------------------------------------------
    | Default Broadcaster
    |--------------------------------------------------------------------------
    | Options: 'reverb' | 'pusher' | 'ably' | 'redis' | 'log' | 'null'
    | Use 'log' locally to see broadcast output in your log file without a WebSocket server.
    | Use 'null' in automated tests to prevent real broadcasts.
    */
    'default' => env('BROADCAST_DRIVER', 'log'),

    'connections' => [

        /*
         * Laravel Reverb — first-party, zero extra runtime required.
         * Run: php artisan reverb:start (add --debug in development)
         * For production: run behind Nginx reverse-proxy with TLS termination.
         */
        'reverb' => [
            'driver'  => 'reverb',
            'key'     => env('REVERB_APP_KEY'),
            'secret'  => env('REVERB_APP_SECRET'),
            'app_id'  => env('REVERB_APP_ID'),
            'options' => [
                'host'   => env('REVERB_HOST', '0.0.0.0'),
                'port'   => env('REVERB_PORT', 8080),
                'scheme' => env('REVERB_SCHEME', 'https'), // always https in production
                // 'useTLS' handled by your Nginx/Caddy proxy — Reverb speaks plain HTTP internally
            ],
            // Reverb respects your queue connection for async broadcasts
            'client_options' => [],
        ],

        /*
         * Pusher — managed WebSocket-as-a-service.
         * Set PUSHER_* vars from your Pusher dashboard.
         */
        'pusher' => [
            'driver'  => 'pusher',
            'key'     => env('PUSHER_APP_KEY'),
            'secret'  => env('PUSHER_APP_SECRET'),
            'app_id'  => env('PUSHER_APP_ID'),
            'options' => [
                'cluster'   => env('PUSHER_APP_CLUSTER', 'mt1'),
                'encrypted' => true, // never set this to false in production
                /*
                 * Performance tuning: batch multiple broadcasts in one HTTP request.
 n                * Reduces RTT when firing many events in a single request lifecycle.
                 */
                'batch'     => true,
                'curl_options' => [
                    CURLOPT_CONNECTTIMEOUT => 3, // fail fast rather than blocking workers
                    CURLOPT_TIMEOUT        => 5,
                ],
            ],
        ],

        /*
         * Redis driver — pairs with Soketi or a compatible Pusher-protocol server.
         * Your Laravel app publishes to Redis; the WebSocket server consumes and forwards.
         */
        'redis' => [
            'driver'     => 'redis',
            'connection' => env('BROADCAST_REDIS_CONNECTION', 'default'),
            // Use a dedicated Redis connection for broadcasts to avoid HOL blocking
            // on your general-purpose Redis connection (cache, sessions, queues).
        ],

    ],

];
▶ Output
// With BROADCAST_DRIVER=log in .env:
// [2024-06-01 10:23:45] local.INFO: Broadcasting [order.shipped] on channels [private-orders.42] with payload:
// {"order_id":42,"status":"shipped","updated_at":"2024-06-01T10:23:45+00:00"}
🔥
Interview Gold: Why Reverb Uses PHP Instead of NodeInterviewers love this. Reverb uses a non-blocking event loop (via ReactPHP/Amp) inside a single long-lived PHP process — the same model Node.js uses. The key insight is that WebSocket servers spend 99% of their time waiting on I/O, not computing. A non-blocking event loop handles thousands of idle connections cheaply regardless of language. The advantage of PHP is that your ops team only needs one runtime and Reverb integrates directly with Laravel's service container, queues, and authentication middleware.

Laravel Echo, Presence Channels and Real-Time DOM Updates End to End

Laravel Echo is the JavaScript counterpart that closes the loop. It wraps the Pusher JS SDK (or the Reverb connector) and gives you a clean, fluent API for subscribing to channels and listening to events. Without Echo you would have to manage socket IDs, channel auth tokens, and reconnection logic yourself.

The channel() method subscribes to a public channel, private() auto-hits /broadcasting/auth before subscribing, and join() subscribes to a presence channel and gives you three hooks: here() (initial member list), joining() (new member), and leaving() (member left). These three hooks are all you need to build a 'who is typing' indicator or a live attendee count.

One production subtlety that trips people up: Echo's WebSocket connection is per-tab, but your /broadcasting/auth route uses your session cookie or Bearer token. In a SPA with token-based auth you must tell Echo's axios instance to include the Authorization header on every auth request. Forgetting this means every private channel subscription silently fails with a 401, yet the public WebSocket connection remains open — making it look like the driver is working when it is not.

Another nuance: broadcastToOthers() vs broadcast(). When a user triggers an action that broadcasts back to them too, you often get a ghost update: the UI changes optimistically via JavaScript, then the broadcast arrives and changes it again. broadcastToOthers() sends to every subscriber except the socket that triggered the action, preventing the double-update.

echo-setup.js · JAVASCRIPT
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// resources/js/bootstrap.js (or your entry point)
// Full Echo setup for a Laravel Reverb backend with Sanctum SPA authentication.

import Echo from 'laravel-echo';
import Pusher from 'pusher-js'; // Reverb reuses the Pusher JS client under the hood

// Make Pusher available globally — Echo looks for window.Pusher
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb', // or 'pusher' — same Pusher JS client either way
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
    enabledTransports: ['ws', 'wss'], // disable long-polling fallback for cleaner failure modes

    // CRITICAL for SPA/token auth: attach Bearer token to the channel auth request.
    // Without this, every private channel POST to /broadcasting/auth returns 401.
    authorizer: (channel) => ({
        authorize: (socketId, callback) => {
            window.axios.post('/broadcasting/auth', {
                socket_id:    socketId,
                channel_name: channel.name,
            }, {
                headers: {
                    Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
                },
            })
            .then(response => callback(false, response.data))
            .catch(error  => callback(true, error));
        },
    }),
});


// --- Listening to a Private Channel ---
// resources/js/pages/OrderTracking.vue (or any component)

const currentUserId = window.authUser.id; // set by your Blade layout or API

// .private() triggers the /broadcasting/auth check automatically
window.Echo
    .private(`orders.${currentUserId}`)
    .listen('.order.shipped', (eventPayload) => {
        // The leading dot means we're using the broadcastAs() name, not the class name.
        // Without the dot Echo looks for App\Events\OrderShipped as the event name.
        console.log('Order update received:', eventPayload);
        // eventPayload = { order_id: 42, status: 'shipped', updated_at: '...' }

        updateOrderStatusInUI(eventPayload.order_id, eventPayload.status);
    });


// --- Presence Channel: Live Room Membership ---
window.Echo
    .join(`chat-room.${roomId}`)
    .here((members) => {
        // Called once on successful subscription with the full current member list
        // members = [{ id: 1, name: 'Alice', avatar: '...' }, { id: 2, name: 'Bob', avatar: '...' }]
        renderMemberList(members);
    })
    .joining((newMember) => {
        // Called whenever someone else subscribes to this channel
        addMemberToList(newMember);
        showToast(`${newMember.name} joined the room`);
    })
    .leaving((departedMember) => {
        // Called when a member's WebSocket disconnects or they explicitly unsubscribe
        removeMemberFromList(departedMember.id);
    })
    .listen('.new.message', (message) => {
        appendMessageToChat(message);
    });


// --- broadcastToOthers: Prevent Ghost Updates ---
// In your controller, instead of:
//   broadcast(new MessageSent($message));
// Use:
//   broadcast(new MessageSent($message))->toOthers();
//
// This tells the driver to exclude the socket ID that made this HTTP request,
// preventing the user who sent the message from receiving their own broadcast
// and double-rendering it alongside the optimistic UI update.
//
// Requires the X-Socket-ID header on your axios requests:

window.axios.defaults.headers.common['X-Socket-ID'] = window.Echo.socketId();
// Echo.socketId() is available only AFTER the connection is established.
// Wrap in Echo.connector.pusher.connection.bind('connected', () => { ... }) if needed.
▶ Output
// Browser Console when OrderShipped is dispatched for user 42:
// Order update received: { order_id: 42, status: 'shipped', updated_at: '2024-06-01T10:23:45+00:00' }

// Presence channel join for chat-room.7:
// here() called with: [{ id: 1, name: 'Alice', avatar: 'https://...' }, { id: 3, name: 'Carol', avatar: 'https://...' }]
// joining() called with: { id: 5, name: 'Dave', avatar: 'https://...' }
⚠️
Watch Out: The Leading Dot in .listen('.order.shipped')When you define broadcastAs() on your event class, Echo expects a leading dot in the listen() call: .listen('.order.shipped'). Without the dot, Echo constructs the full namespaced class name (App\Events\OrderShipped) as the event name and your listener silently never fires. This is the single most common 'broadcasting not working' bug we see in code reviews.
AspectLaravel ReverbPusher (Managed)Redis + Soketi
Infrastructure ownershipSelf-hosted (your server)Fully managed (Pusher's servers)Self-hosted (WebSocket server + Redis)
Runtime requirementPHP 8.2+ onlyNone (SaaS)Node.js (Soketi) + Redis
Cost modelServer cost onlyPer message + connection feeServer + Redis cost
Pusher JS protocolYes — Echo unchangedYes — Echo unchangedYes — Echo unchanged
Horizontal scalingVia Reverb scaling config + RedisHandled by PusherRedis pub/sub handles fan-out
Debug toolingphp artisan reverb:start --debugPusher debug console (excellent)Soketi metrics endpoint
Best forNew Laravel 11+ apps, privacy-firstFast prototyping, startup scaleExisting Redis infra, data sovereignty
Max connections (free tier)Unlimited (your hardware limit)100 (free tier)Unlimited (your hardware limit)
TLS terminationDelegate to Nginx/Caddy proxyHandled by PusherDelegate to Nginx/Caddy proxy

🎯 Key Takeaways

  • ShouldBroadcast (queued) vs ShouldBroadcastNow (synchronous) is not a minor detail — under load, synchronous broadcasting blocks your web workers and causes cascading timeouts. Always queue in production.
  • Channel security lives entirely in routes/channels.php. A Public channel has zero auth — never put user-specific data on one. Private channels require a truthy return from your callback; Presence channels require a data array so members can identify each other.
  • The leading dot in Echo's .listen('.event.name') is mandatory when you override broadcastAs(). Without it Echo silently uses the PHP class name and your listener never fires — no error, just silence.
  • Laravel Reverb is now the zero-ops default for new projects: one PHP process, no Node runtime, full Pusher-protocol compatibility, and it integrates directly with your existing Laravel auth and queue infrastructure.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using ShouldBroadcastNow in a high-traffic endpoint — Symptom: your API responses are slow (200–500ms spikes) and Pusher/Reverb timeout errors appear in logs during traffic surges — Fix: switch to ShouldBroadcast (the queued version) and ensure at least one queue worker is running. The HTTP request returns immediately and the broadcast happens asynchronously in the worker process.
  • Mistake 2: Forgetting the leading dot when using broadcastAs() in Echo's listen() — Symptom: the WebSocket connection is established, the driver confirms the event was broadcast, but the JavaScript callback never fires and no errors appear in the browser console — Fix: change .listen('order.shipped', ...) to .listen('.order.shipped', ...). Without the dot, Echo looks for the fully qualified PHP class name as the event identifier. With broadcastAs() you renamed it, so you must use the dot-prefixed custom name.
  • Mistake 3: Not isolating the Redis broadcast connection from your general Redis connection — Symptom: cache reads, session lookups, and queue jobs intermittently slow down during high-broadcast periods; Redis latency graphs spike in correlation with broadcast volume — Fix: define a separate Redis connection in config/database.php (e.g. 'broadcast_redis') and point BROADCAST_REDIS_CONNECTION at it. Broadcasting uses SUBSCRIBE/PUBLISH commands which occupy a Redis connection differently from GET/SET commands and will starve shared connections under load.

Interview Questions on This Topic

  • QWalk me through what happens, step by step, from the moment you call `broadcast(new OrderShipped($order))` to the moment a JavaScript callback fires in the user's browser. Include the queue, the driver, and channel auth.
  • QWhat is the difference between a Private channel and a Presence channel in Laravel? Give a concrete example of when you would use each, and explain what data the channel auth callback should return for each type.
  • QA colleague tells you 'broadcasting is broken — I can see the WebSocket connection is open in DevTools but the listen() callback never fires.' Walk me through the four most likely causes and how you would diagnose each one.

Frequently Asked Questions

Do I need a separate WebSocket server to use Laravel Broadcasting?

Yes — Laravel itself does not maintain persistent WebSocket connections; it publishes events to a driver that does. Your options are a managed service (Pusher, Ably), a self-hosted server (Laravel Reverb, Soketi), or Redis pub/sub paired with a compatible server. Laravel Reverb is the easiest self-hosted option: run php artisan reverb:start and you have a WebSocket server with zero additional runtimes.

Why is my broadcast event firing but the JavaScript listener never triggering?

The most common cause is a mismatch between the event name on the server and what Echo is listening for. If you defined broadcastAs() on your event, Echo requires a leading dot in listen(): .listen('.your.event.name'). Also verify your channel name matches exactly (including the 'private-' prefix Echo adds automatically for PrivateChannel), and check your browser's Network tab to confirm the /broadcasting/auth POST is returning 200, not 401 or 403.

What is the difference between broadcast() and event() in Laravel?

event() dispatches a standard Laravel event that goes to registered Listeners in your EventServiceProvider — it stays entirely server-side. broadcast() (or using the ShouldBroadcast interface) additionally sends the event payload to a WebSocket driver so browser clients can receive it. An event can implement ShouldBroadcast and still have server-side Listeners — both happen independently.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousLaravel Sanctum API Authentication
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged