opekunov/laravel-centrifugo-broadcaster
Centrifugo broadcaster driver for Laravel that lets you broadcast real-time events over WebSockets through a Centrifugo server using Laravel's familiar broadcasting API. Simple to install and configure, it offers a drop-in way to add scalable real-time features to your app.
What this package is: a Laravel broadcast driver for Centrifugo — a standalone Go websocket server. Laravel keeps doing what it always does (fire events, queue jobs, authorize channels), but the fan-out to browsers goes through Centrifugo's HTTP API instead of Pusher/Ably/Redis. You get self-hosted realtime with no per-connection vendor bill.
Install (v3.0.2):
composer require opekunov/laravel-centrifugo-broadcaster
php artisan vendor:publish --tag=centrifugo-config
Supported matrix is now Laravel 8.75 – 13.x on PHP 8.0 – 8.4. If you're on Laravel 13, this release is the one that declares and CI-verifies it (orchestra/testbench ^11, phpunit ^12); nothing else changed at the API level.
Wire up the three moving parts (this is where most of the first hour goes):
.env — CENTRIFUGO_SECRET (must equal Centrifugo's token_hmac_secret_key), CENTRIFUGO_API_KEY (must equal Centrifugo's api_key), CENTRIFUGO_HOST/API URL and path. These are two different credentials — the secret signs JWTs, the API key authenticates server-side publishes.config/broadcasting.php — the package registers a centrifugo driver; point your default connection at it, or add a named connection and set BROADCAST_CONNECTION=centrifugo (BROADCAST_DRIVER pre-11).config.json — allowed origins, allow_subscribe_for_client, and channel namespaces. Laravel can't do this for you.First use case — prove the loop end to end:
class OrderShipped implements ShouldBroadcast
{
public function broadcastOn(): array
{
return [new Channel('orders')];
}
}
// client
const centrifuge = new Centrifuge('ws://localhost:8000/connection/websocket');
centrifuge.subscribe('orders', ctx => console.log(ctx.data));
event(new OrderShipped($order)); // publish() hits Centrifugo's /api
Then immediately do the private-channel case (token endpoint + Broadcast::channel callback), because public channels need no auth and will hide all your credential mistakes until production.
Where to look first in the source: the service provider (how the driver is registered and what it binds), the broadcaster class's auth() / broadcast() methods, and the Centrifugo client wrapper. Those three files are the whole package; the rest is config and docs.
Standard event broadcasting. Nothing package-specific: ShouldBroadcast events, Channel/PrivateChannel/PresenceChannel in broadcastOn(), broadcastAs() for a short event name (recommended — FQCNs in the wire payload are noisy and leak namespace structure), and broadcastWith() to trim the payload.
Token issuing is the piece you own. Centrifugo doesn't authenticate users against Laravel; it verifies a JWT you mint. Expose a small authenticated endpoint:
Route::post('/centrifugo/token', fn (Request $request) => [
'token' => Centrifugo::generateToken((string) $request->user()->id),
]);
Then keep the TTL short (config token_ttl) and have the frontend re-fetch on centrifuge.on('disconnected') rather than trusting a long-lived token.
Channel authorization stays in Laravel. Broadcast::channel('orders.{orderId}', fn ($user, $orderId) => ...) is evaluated by the broadcaster's auth() path. Centrifugo's own allow_subscribe_for_client / namespace settings decide whether a client may subscribe at all; the Laravel callback decides whether this user may subscribe to this channel. Both layers apply — a permissive Centrifugo namespace plus a strict Laravel callback is fine; the reverse is a hole.
Publishing from outside an event. For jobs, commands, or webhook handlers where modelling an event is overkill, call the client directly:
Centrifugo::publish('orders.42', ['event' => 'status', 'data' => [...]]);
Wrap this in a small Realtime/CentrifugoPublisher service of your own so tests can swap it; don't sprinkle the facade through domain code.
Queue your broadcasts. Broadcasting goes over HTTP; a slow Centrifugo (restart, GC pause, network blip) in the request path is a latency tax. implements ShouldBroadcast + queue worker is the default; use ShouldBroadcastNow only for genuinely latency-critical paths, and give it a real timeout/retry policy.
Namespace your channels by domain. orders.{id}, users.{id}.notifications, presence.chat.{room}. Centrifugo namespaces let you tune history size, presence, and client-side publish per prefix — that's an ops lever you can't use if everything lives in flat channel names.
Multi-connection setups. If you're migrating from Pusher, keep both connections in config/broadcasting.php and flip per-event with broadcastVia() / per-environment via BROADCAST_CONNECTION. Roll out one feature at a time.
Testing. Fake at the event layer (Event::fake() + assert dispatched/broadcast), and for the client call itself either point CENTRIFUGO_HOST at a stub server or fake the underlying HTTP client if your installed client version routes through Laravel's HTTP client. Do not write tests that hit a live Centrifugo — that's exactly the flakiness class v3.0.2 removed from the package's own suite (they dropped httpstat.us for timeout/retry tests).
CENTRIFUGO_SECRET = token_hmac_secret_key (JWT signing, HS256). CENTRIFUGO_API_KEY = api_key (HTTP API header). The classic bug: token endpoint works, but publishes 401 — or vice versa. Verify by hitting /api with curl and decoding a token at jwt.io before blaming the package.sub must be a string. Centrifugo rejects/complains on integer subjects. Cast it: (string) $user->id. Same string must be used everywhere you compare user ids in Broadcast::channel callbacks.toOthers() won't exclude the sender. Pusher's protocol carries a socket_id that the server filters on; Centrifugo has no such socket id in the broadcast payload. The socket field travels through as ordinary data. If you need sender-exclusion, send the sender's id in the payload and filter client-side, or use a separate private channel for the originator./api), method names, and response envelopes; v5 also trimmed some legacy methods. Pin your Centrifugo image tag in Docker and match the package's configured API path to it. If publishes silently no-op, log the raw response body — the package surfaces errors unevenly across versions.chat, presence) is stripped/rewritten server-side; a private- prefix must align between your Laravel channel, the Broadcast::channel registration, and Centrifugo's namespace config. Mismatches manifest as "token valid, subscription denied with no obvious reason."exp and Centrifugo's client_expired_close_delay / client_stale_close_delay interact. Short tokens without a client refresh path = users silently going dark after an hour. Test a forced expiry locally.false. In a queued broadcast, swallowing means lost messages — add your own logging around the broadcaster if you need delivery guarantees, or enable Centrifugo history and reconcile.broadcastWith() deliberately.Upgrade/Connection header pass-through and long proxy_read_timeout; the HTTP publish path needs the same TLS/host you configured. Half-broken setups look like "broadcasts work in staging but not through the load balancer."token_ttl/API path; re-bind the client in a service provider if you need custom Guzzle options (proxies, mTLS); and remember the driver is registered via Broadcast::extend, so you can register a subclass under a second driver name rather than forking.composer update is enough; the only reason to care is if you're targeting Laravel 13 and were blocked by the ^13.0 constraint. Take the opportunity to bump orchestra/testbench/phpunit in your own dev dependencies to match.How can I help you explore Laravel packages today?