happyr/stomp-bundle
Symfony bundle that wraps the Stomp PHP client, adding configuration and service wiring for STOMP-compatible message brokers. It lets Symfony applications send messages to queues or topics on ActiveMQ, RabbitMQ, and similar messaging servers through a simple service.
Read the verdict before the code. This is a Symfony2-era bundle (registered via AppKernel::registerBundles(), wired with DependencyInjection/Configuration.php and Resources/config/services.xml) that wraps fusesource/stomp-php. It has one star, zero dependents, and no maintenance. You cannot composer require it into a Laravel app — there is no service provider, no config/ publish, no facade. So "getting started" means one of two things:
app/config/config.yml:happyr_stomp:
broker: "tcp://localhost:61613" # ActiveMQ Classic / Artemis / RabbitMQ STOMP plugin
username: "guest"
password: "guest"
Where to look first, in order:
composer.json — PHP floor and symfony/symfony constraint. If it pins ~2.x or PHP >=5.3, the decision is closed.DependencyInjection/Configuration.php — the only source of truth for config keys.Resources/config/services.xml — the client service id (likely happyr_stomp.client) and whether it's a shared singleton.FuseSource\Stomp\Stomp API — the bundle is a thin wrapper; your day-to-day calls are the library's.First use case in Laravel terms — replicate what the bundle does with a provider binding a configured client, then consume:
// config/stomp.php
return [
'broker' => env('STOMP_BROKER', 'tcp://localhost:61613'),
'username' => env('STOMP_USERNAME', 'guest'),
'password' => env('STOMP_PASSWORD', 'guest'),
];
// app/Providers/StompServiceProvider.php
public function register(): void
{
$this->app->singleton(\FuseSource\Stomp\Stomp::class, function ($app) {
$c = $app['config']['stomp'];
return new \FuseSource\Stomp\Stomp($c['broker'], $c['username'], $c['password']);
});
}
That is essentially the whole package's value: "a pre-configured Stomp object injected from the container." Do not expect queues, retries, serialization, or dead-lettering.
Before any of this, run the two-day spike: an Artisan command connecting to the real broker over the real network (TLS and auth included). Confirm broker, port, and credentials before designing anything.
Inject the client, not the container. Business code takes Stomp as a constructor dependency; only the provider knows how it was built.
class OrderNotifier
{
public function __construct(private \FuseSource\Stomp\Stomp $stomp) {}
public function notify(array $order): void
{
$this->stomp->send(
'/queue/orders',
json_encode($order),
['persistent' => 'true']
);
}
}
Producer pattern — wrap publishing behind your own interface. This is the single most valuable thing you take from the package: an application-owned MessagePublisher interface with a StompPublisher implementation. It keeps the transport swappable (to AMQP, to Redis) when you inevitably revisit the choice, and it keeps FuseSource\Stomp\* types out of your domain layer.
interface MessagePublisher { public function publish(string $destination, array $payload): void; }
Bind it in the provider and let OrderNotifier depend on the interface.
Consumer pattern — a supervised Artisan command with explicit ack. Do not hand-roll a while (true) into a controller. Put it in an Artisan command with explicit ack and a bounded lifetime.
class StompConsume extends Command
{
protected $signature = 'stomp:consume {queue} {--max-time=3600}';
public function handle(\FuseSource\Stomp\Stomp $stomp): int
{
$stomp->subscribe('/queue/'.$this->argument('queue'), ['ack' => 'client-individual']);
$deadline = time() + (int) $this->option('max-time');
while (time() < $deadline && $frame = $stomp->readFrame()) {
try {
ProcessOrder::dispatchSync(json_decode($frame->body, true));
$stomp->ack($frame);
} catch (\Throwable $e) {
report($e);
$stomp->ack($frame); // or nack/quarantine — you own this decision
}
}
$stomp->unsubscribe();
return self::SUCCESS;
}
}
Workflow integration tips:
client-individual ack, not auto or plain client, so one bad message doesn't ack the batch behind it. The bundle gives you no dead-letter concept — you build it (re-publish to /queue/orders.failed with a retry counter header before acking).--max-time (or a message counter) so PHP memory growth and stale connections are periodically reset.stomp:health Artisan command that connects and publishes a probe to /queue/health — run it on a schedule and alert on failure. This is the only reliable detector of "worker alive, connection dead.".env, mirroring the bundle's environment-aware config. One connection block per environment; no dynamic reload.type header so consumers can route without decoding bodies they don't own.The framework mismatch is structural, not cosmetic. Symfony bundles register via Bundle classes and DI extensions; Laravel's kernel never invokes those. Laravel uses Symfony components, not the Symfony framework. There is no adapter path — you reimplement, you don't port.
Composer will likely refuse it. Symfony 2.8 is EOL (Nov 2018); constraints will almost certainly block PHP 8.x. Check composer.json before spending further time. If it won't install, the decision is made for you.
The service is a shared singleton — and STOMP is connection-oriented. Long-lived processes hold one connection. After a broker restart, network partition, or idle timeout, the socket dies and readFrame() starts returning false in a loop. Your loop above exits on falsy frames; a naive while (true) loop spins at 100% CPU forever. Always bound the loop and treat "no frame" as a reconnect trigger.
Silent consumer death is the classic failure. Worker process healthy, connection dead, queue backing up invisibly. Without the health probe and queue-depth alerting, you find out from customers. Add both before production.
Message loss on ack. With auto ack, a crash mid-processing drops the in-flight message — and there's no dead-letter concept in this bundle. Use client-individual with explicit ack after success, and a quarantine destination for failures.
Poison messages loop forever. No retry counter exists. Read a retry-count header, increment, re-publish, and quarantine past N attempts.
TLS/credential rotation means worker restarts. No dynamic reload. Plan rotations as deploys, and make sure Supervisor stop signals let in-flight messages finish acking.
Debugging tips:
setReadTimeout to avoid blocking forever on an empty queue.content-length framing) is exactly what a mock gets wrong./queue/name vs /topic/name vs ActiveMQ's queue:// — a mismatch produces silent no-consumption, not an error.Extension points are yours to invent. The bundle exposes a client; everything else (publisher interface, retry policy, DLQ, health check, serialization) is application code you own. Estimate from the package's scope: ~1–2 engineering days for a production-worthy adapter, plus a few days/year in steady state — unbounded during a PHP major upgrade.
The honest recommendation: don't adopt. If the broker allows AMQP, use a maintained Laravel queue driver and get retries, failed-job storage, and visibility for free. If STOMP is non-negotiable (ActiveMQ/Artemis already in the estate), skip this bundle and take the current stomp-php or enqueue/stomp directly behind your own interface — you inherit a maintained client's issues list instead of this one's silence. Keep this package as a checklist for what the adapter must cover, not as a dependency in your composer.json.
How can I help you explore Laravel packages today?