Wiring Pusher Into Laravel 11 + Inertia — Every Pothole I Hit
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
authorizerthat goes through axios.Laravel 11's
withRouting(channels: ...)doesn't reliably register the auth route. CallBroadcast::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.
