Skip to main content

Command Palette

Search for a command to run...

Wiring Pusher Into Laravel 11 + Inertia — Every Pothole I Hit

Updated
5 min read

I added Pusher Channels (sandbox/free tier) to a Laravel 11 + Inertia React app to replace 4-second polling with real-time presence channels. The integration sounds boring on paper — install the SDK, set the env vars, broadcast an event. The implementation, on the other hand, was a tour of every gotcha Laravel 11 + Pusher + a managed PHP host can throw at you.

This post is the tour. If you're wiring Pusher into a Laravel 11 Inertia app for the first time, here are the mistakes I made so you don't have to.

The setup, in one sentence

Laravel 11 + Inertia React + Pusher Channels (sandbox/free tier) on a RunCloud-managed VPS behind Cloudflare, using presence channels for both real-time delivery and "is the user currently here?" detection.

Mistake 1 — Trusting ${VAR} interpolation in .env

I wanted my Vite frontend keys to mirror Laravel's:

PUSHER_APP_KEY=XXXXX
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"

Laravel's env() helper expands \({...}. Vite does not. It reads .env directly, so import.meta.env.VITE_PUSHER_APP_KEY ended up as the literal string "\){PUSHER_APP_KEY}" — and pusher-js silently fell back to its placeholder URL wss://ws-your_cluster.pusher.com, which doesn't exist.

Lesson: Hardcode VITE_* variables. They live in the bundle anyway.

Mistake 2 — Forgetting CSRF on /broadcasting/auth

By default Laravel Echo's pusher driver makes its own POST to /broadcasting/auth using fetch — and fetch doesn't auto-attach the XSRF-TOKEN cookie that your normal axios calls do. The endpoint is behind web middleware, which includes CSRF.

Fix: tell Echo to authorize through axios so it inherits the cookie behavior:

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
    forceTLS: true,
    authorizer: (channel) => ({
        authorize: (socketId, callback) => {
            axios.post('/broadcasting/auth', {
                socket_id: socketId,
                channel_name: channel.name,
            })
            .then((response) => callback(null, response.data))
            .catch((error) => callback(error, null));
        },
    }),
});

Mistake 3 — Believing withRouting(channels: ...) just works

Laravel 11's slim bootstrap lets you wire channels via:

->withRouting(
    web: __DIR__.'/../routes/web.php',
    channels: __DIR__.'/../routes/channels.php',
)

Internally, this registers the broadcast routes via afterResolving(Factory::class, ...). The catch: nothing in a normal HTTP request resolves the Broadcasting Factory. Routes never get attached. Your POST /broadcasting/auth falls through to the Inertia catch-all and returns 200 with an HTML page. Pusher then complains:

Invalid auth response for channel 'presence-conversation.2',
expected 'channel_data' field.

I spent a long time chasing that error message before realizing the route simply wasn't registered for HTTP. The fix is the old reliable: explicit Broadcast::routes() in a service provider.

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Broadcast::routes();
    require base_path('routes/channels.php');
    // ...
}

Lesson: When in doubt, register routes explicitly. The new bootstrap is convenient until it isn't.

Mistake 4 — Forgetting OPcache exists

I deployed the AppServiceProvider change, ran:

php artisan route:clear
php artisan route:list | grep broadcasting

The route showed up in the list. Great. Except curl-ing the endpoint still returned the Inertia HTML page. Why? route:list runs a fresh PHP CLI process that loads code from disk. PHP-FPM workers, on the other hand, hold compiled bytecode in OPcache with opcache.validate_timestamps=0 on production for performance. They never re-read the file.

The fix is one command:

sudo systemctl reload php83rc-fpm

Lesson: CLI seeing the route ≠ web serving the route. Reload PHP-FPM after deploying PHP code, every time.

Mistake 5 — Same code, same creds, different behavior

This was the one that nearly broke me. Local worked. Production didn't. Same git commit, same Pusher keys, same BROADCAST_CONNECTION=pusher… or so I thought.

I added a diagnostic that dumped the registered broadcaster at auth time:

$broadcaster = Broadcast::driver();
Log::info('broadcaster state', [
    'class' => get_class($broadcaster),
]);

The log printed:

"class": "Illuminate\\Broadcasting\\Broadcasters\\LogBroadcaster"

Production was running LogBroadcaster. Its auth() method silently returns null — that's why curl saw a 200 with an empty body and no exception. The .env on prod still had Laravel's default BROADCAST_CONNECTION=log from before my changes; the deploy never updated it.

One sed + config:clear later, everything worked.

Lesson: When "same code" misbehaves across environments, dump the actual runtime state. Your assumption about which class is being used is the bug. A silent LogBroadcaster looks identical to a working app right up until you check whose auth() is being called.

Mistake 6 — Treating the Pusher SDK response as an array

To check whether a user was currently subscribed to a presence channel, I called Pusher's REST API:

\(result = \)pusher->get("/channels/presence-conversation.{$id}/users");
\(users = \)result['users'] ?? []; // ❌ Cannot use object of type stdClass as array

The Pusher PHP SDK returns stdClass, not associative arrays. Easy fix once you read the error.

\(users = is_object(\)result) ? (\(result->users ?? []) : (\)result['users'] ?? []);
foreach (\(users as \)u) {
    \(uid = is_object(\)u) ? (\(u->id ?? null) : (\)u['id'] ?? null);
    if ((int) \(uid === \)userId) return true;
}

Takeaways, distilled

  • Vite env interpolation isn't a thing. Hardcode VITE_* values.

  • Echo's auth needs CSRF. Use a custom authorizer that goes through axios.

  • Laravel 11's withRouting(channels: ...) doesn't reliably register the auth route. Call Broadcast::routes() explicitly.

  • OPcache holds code. Reload PHP-FPM after deploying.

  • Same code, different env = different behavior. Always check config('broadcasting.default') at runtime when behavior diverges.

  • Pusher SDK responses are stdClass, not arrays.

None of these are exotic bugs. Each one cost me 10–30 minutes — collectively, an afternoon. Hopefully this saves you a few of those minutes.

3 views