kismia/centrifugo-bundle
Symfony bundle for integrating the Centrifugo real-time messaging server, providing services and configuration to publish events and enable websocket-based live updates in PHP applications.
The honest framing first: this is a Symfony bundle, not a Laravel package. There is no service provider, no facade, no auto-discovery, and no config/centrifugo.php shipping in the box. The Symfony bundle (Kismia\CentrifugoBundle\CentrifugoBundle) registers a DI service that wraps the Centrifugo HTTP API behind config keys (typically api_url, api_key, secret, timeout). For a Laravel team the value is as a reference implementation you port in ~30 minutes, or as a signal to use the underlying Centrifugo HTTP API directly. With 11 stars, zero dependents, and a 0.085 health score, do not make this a runtime dependency of a Laravel app — vendor it, mirror it, or write the ~80 lines yourself.
Where to look first when you clone it:
DependencyInjection/Configuration.php — the canonical list of config keys and defaults. This is your config/centrifugo.php blueprint.DependencyInjection/CentrifugoExtension.php — how the service is assembled (URL normalization, HTTP client choice, header injection).Service/Centrifugo.php (or similar) — the actual API surface: publish, broadcast, presence, history, disconnect, maybe unsubscribe. That method list is your target interface.Minimal Laravel port — the first thing you build:
// config/centrifugo.php
return [
'url' => env('CENTRIFUGO_API_URL', 'http://centrifugo:8000/api'),
'api_key' => env('CENTRIFUGO_API_KEY'),
'secret' => env('CENTRIFUGO_TOKEN_SECRET'),
'timeout' => env('CENTRIFUGO_TIMEOUT', 2),
];
// app/Services/CentrifugoClient.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
class CentrifugoClient
{
public function __construct(private array $config) {}
public function publish(string $channel, array $data): array
{
return Http::withHeaders(['X-API-Key' => $this->config['api_key']])
->timeout($this->config['timeout'])
->post($this->config['url'], [
'method' => 'publish', // v3-style; see Gotchas for v5
'params' => ['channel' => $channel, 'data' => $data],
])
->throw()
->json();
}
}
// app/Providers/CentrifugoServiceProvider.php
public function register(): void
{
$this->mergeConfigFrom(base_path('config/centrifugo.php'), 'centrifugo');
$this->app->singleton(CentrifugoClient::class,
fn ($app) => new CentrifugoClient($app['config']['centrifugo']));
}
First use case: publish to a channel after a domain event (chat message, order status, notification) and hand the browser a connection JWT. That pair of features is 90% of real Centrifugo usage; everything else (presence, history) is opt-in and needs server config you probably haven't enabled yet.
Publish-after-commit is the default pattern. Never publish from inside a transaction handler — subscribers will receive an event for a row they can't yet read.
class PublishChatMessage
{
public function handle(MessageSent $event): void
{
DB::afterCommit(fn () => app(PublishToCentrifugo::class)
->dispatch("chat:{$event->message->room_id}", [
'id' => $event->message->id,
'author' => $event->message->user_id,
'body' => $event->message->body,
]));
}
}
Queue the publish. Centrifugo HTTP is fast but it is still a network hop; do it in a ShouldQueue job so a slow Centrifugo (or a redeploy) doesn't add latency to your request path. Use retry(3) with backoff, and make payloads idempotent (id field, idempotency_key param on v5) so retries are safe.
Issue JWTs from Laravel, not from the frontend. Centrifugo's connection token is a JWT signed with token_hmac_secret_key (or RSA private key). sub is the user ID; info is JSON merged into presence; channels restricts subscriptions.
public function connectionToken(string $userId, array $info = []): string
{
$now = time();
return JWT::encode([
'sub' => $userId,
'iat' => $now,
'exp' => $now + 3600,
'info' => $info, // or b64info if you want it base64'd
], config('centrifugo.secret'), 'HS256');
}
For private channels ($ or # prefixed), also mint a subscription token containing the channel claim — clients pass it to subscribe(). Same secret, different token.
Wrap the client in your own interface. interface RealtimePublisher { public function publish(string $channel, array $data): void; }. Bind the Centrifugo implementation in the service provider. This makes testing trivial (bind an in-memory recorder) and makes a future swap to Pusher/Ably/Soketi a config change, not a refactor.
Laravel-native front door. Let Laravel events and broadcasting be the API your app calls; a single listener fans out to Centrifugo. It keeps channel naming, payload shape, and token logic in one place instead of scattered across controllers.
Presence and history need both sides. Calling presence($channel) returns nothing useful unless the channel's namespace enables presence: true and history_size/history_ttl in Centrifugo's own config. Treat these as server-config features, not client features.
Testing. Http::fake() in Laravel, Mockery for the Symfony original. Assert the outgoing body matches the Centrifugo API shape you're actually targeting (this is where version drift bites — see below). Add one integration test against a real Centrifugo container in CI; a docker run centrifugo/centrifugo with a throwaway config is cheap and catches header/endpoint mistakes that fakes never will.
POST /api with {"method": "...", "params": {...}}. Centrifugo v4/v5 expose per-method endpoints (POST /api/publish) with a flat body and X-API-Key. This bundle likely targets the older shape. Confirm which one your Centrifugo is running before copying method names — a 404 or a 200-with-error body will both look like "silent failure."{"error": {...}} for API-level failures (bad channel, disabled namespace, malformed data). ->throw() will not catch them. Check $response['error'] yourself or you'll ship a broken feature that "works" in logs.api_key (HTTP API auth) and token_secret (token_hmac_secret_key, JWT signing) are distinct, live in different places in Centrifugo config, and rotating one without the other silently locks out clients vs. backends. Most misconfiguration traces back to using one where the other is expected.data must be JSON-serializable. PHP arrays with mixed keys become objects; empty arrays become [] not {} and can break clients expecting an object. Cast payloads explicitly.Http client is GuzzleHttp\Client under the hood, so behavior is similar. Set a short timeout (2–3s) and a connect_timeout; Centrifugo can be slow on cold start. Never let a realtime publish block a user request.DB::afterCommit or dispatchAfterCommit on the job.chat:{roomId}, #user:{id}, private-org:{id}) and document it. Private prefixes ($, #) trigger subscription-token checks; public channels don't. Retrofit later is painful because clients already assume the naming.bundles.php and configure under the bundle's own root key (kismia_centrifugo:). If you're in Laravel, remember there is no package:discover — manual mergeConfigFrom and a singleton are all you get.docs/reference/ folder or a private namespace, and don't spread its types through your codebase. The value is the shape of the client, not the package itself.curl -X POST $CENTRIFUGO_API_URL -H "X-API-Key: $KEY" -d '{"method":"publish","params":{"channel":"test","data":{"ping":1}}}' first → then Laravel's HTTP client with Http::dd() → then Centrifugo's server logs (debug: true in its config). Most failures are endpoint shape, API key, or channel namespace permissions, in that order.RealtimePublisher interface in Laravel. Also expose a Centrifugo::fake()-style test double early — once teams start calling it from model observers, a fake is the only way to keep the suite fast.How can I help you explore Laravel packages today?