react/stomp
React/STOMP is a STOMP client for ReactPHP, connecting PHP apps to brokers like ActiveMQ and RabbitMQ with non-blocking, event-driven I/O. It supports subscriptions, acknowledgements, transactions, and heartbeats, so messaging fits smoothly into your ReactPHP event loop.
react/stomp is a STOMP client built on ReactPHP's event loop. It is not a Laravel queue driver — Laravel ships AMQP, Redis, SQS, database and beanstalkd drivers, but nothing that speaks STOMP. So the package earns its place in exactly two situations:
rabbitmq_stomp plugin) and other services in the org (Java, .NET) publish/consume there.If you just need queued jobs inside Laravel, use laravel-queue-rabbitmq or Redis instead. Reach for this package when you need to be a STOMP client, not when you need a queue.
Install and broker prep
composer require react/stomp
rabbitmq-plugins enable rabbitmq_stomp, then expose port 61613. Default credentials guest/guest only work from localhost.61613 by default; Artemis needs the stomp acceptor added to broker.xml.Minimal working client
$loop = React\EventLoop\Loop::get();
$factory = new React\Stomp\Factory($loop);
$client = $factory->createClient([
'host' => env('STOMP_HOST', '127.0.0.1'),
'port' => (int) env('STOMP_PORT', 61613),
'vhost' => env('STOMP_VHOST', '/'),
'login' => env('STOMP_LOGIN', 'guest'),
'passcode' => env('STOMP_PASSCODE', 'guest'),
]);
$client->connect()->then(function (React\Stomp\Client $client) {
$client->subscribe('/queue/orders', function (React\Stomp\Frame $frame) use ($client) {
echo $frame->body, PHP_EOL;
$client->ack($frame);
}, ['ack' => 'client']);
});
$loop->run();
Your first real use case: an Artisan command that consumes a queue and writes to the database, or a topic subscriber that dispatches a Laravel event. Write that before touching WebSockets — it exercises connect, subscribe, ack and error paths with the least moving parts.
Daemon command with graceful shutdown. The loop owns the process, so this cannot extend Illuminate\Queue\Console\WorkCommand. Bind signals so queue:restart-style deploys don't orphan connections:
class ConsumeStomp extends Command
{
protected $signature = 'stomp:consume {destination=/queue/orders}';
public function handle()
{
$loop = React\EventLoop\Loop::get();
$client = (new React\Stomp\Factory($loop))->createClient(config('stomp.connection'));
$client->connect()->then(function ($client) {
$client->subscribe($this->argument('destination'), function ($frame) use ($client) {
try {
ProcessOrder::dispatchSync(json_decode($frame->body, true, 512, JSON_THROW_ON_ERROR));
} finally {
$client->ack($frame); // ack even on failure only if you have a DLQ strategy
}
}, ['ack' => 'client-individual']);
});
$loop->addSignal(SIGTERM, fn () => $client->disconnect());
$loop->addSignal(SIGINT, fn () => $client->disconnect());
$loop->run();
}
}
Publish from a one-shot script. For a controller or job that fires one message and exits, use React\Async\await() so you don't have to think in callbacks:
use function React\Async\await;
$client = (new React\Stomp\Factory(React\EventLoop\Loop::get()))->createClient(config('stomp.connection'));
await($client->connect());
$client->send('/topic/order.shipped', json_encode($payload), [
'content-type' => 'application/json',
'persistent' => 'true',
]);
WebSocket fan-out (the canonical use case). Keep one STOMP client per process and fan messages out to connections held by Ratchet:
$factory->createClient($opts)->connect()->then(function ($client) use ($server) {
$client->subscribe('/topic/pricing', function ($frame) use ($server) {
foreach ($server->getConnections() as $conn) {
$conn->send($frame->body);
}
});
});
Bridge to Laravel's event system. Inside the frame callback you have the full container — event(new OrderShipped($data)), Cache::put(...), Log::channel('stomp')->info(...) all work. Prefer dispatching a job over doing heavy work inline; see the blocking gotcha below.
Config publishing. Don't hardcode options. Create config/stomp.php reading env(), and instantiate a singleton factory in a service provider so tests can swap it.
Reconnect supervisor. Reconnect on connect() rejection and on unexpected socket close. Back off exponentially, and re-subscribe inside the reconnect callback — subscriptions do not survive a new connection:
$connect = function () use (&$connect, $client, $loop, $backoff) {
$client->connect()->then(function ($c) {
$c->subscribe('/queue/orders', $handler, ['ack' => 'client']);
}, function (Throwable $e) use (&$connect, $loop, &$backoff) {
$loop->addTimer($backoff = min($backoff * 2, 30), $connect);
});
};
Acknowledgement modes. ack => 'auto' (default) is fire-and-forget — fine for telemetry, dangerous for order processing. Use 'client' or 'client-individual' for at-least-once delivery, and be aware that ack() is the only way the broker forgets the message.
Testing. Extract a StompPublisher interface with a send(string $destination, string $body): void method; unit-test against a fake. Use a docker-compose ActiveMQ or rabbitmq:3-management with the STOMP plugin for integration tests, and assert via a subscriber on the same destination rather than introspecting the broker.
Everything must yield. A synchronous 300ms Eloquent query inside a frame callback stalls every other connection on that loop. For a fan-out server this is the number one production bug. Either keep handlers to dispatch() calls, or run separate worker and publisher processes.
No built-in reconnection or heartbeat watchdog. The library connects once. Idle connections behind an ELB/NAT die silently and the loop happily keeps running. Add an application-level ping (send('/topic/health', ...) every N seconds) or a $loop->addTimer that tears down and reconnects after N minutes of silence.
Unacked messages accumulate server-side. If you subscribe with ack => 'client' and forget $client->ack($frame) on an exception path, the broker's prefetch window fills and consumption stops — with no error anywhere. Wrap handlers in try/finally deliberately; decide explicitly whether a failed message is acked, nacked (broker-specific ack values), or left unacked to redeliver.
unsubscribe() needs the same id header. STOMP 1.2 requires a subscription id; if you subscribe with ['id' => 'orders-worker'] you must pass the identical header to unsubscribe(). Mismatches fail silently on some brokers.
Destination naming is broker-specific. /queue/foo and /topic/foo map to queues/topics, but RabbitMQ's STOMP adapter also uses /exchange/<name>/<routing.key> for exchange routing. Getting this wrong yields a connection that works and receives nothing.
persistent: true is a header, not a config. A message published without it is dropped on broker restart. Set it explicitly on every send() that matters.
Promise version drift. react/stomp 1.x and 2.x differ in react/promise generation (React\Promise\Deferred vs. React\Promise\PromiseInterface return types) and PHP minimums. Pin the version and be aware that await() requires react/async, which is a separate install.
Keep payloads small. Bodies are strings held in memory in the frame parser. Put large artifacts in S3 and pass a URI in the body. Also set content-type explicitly — brokers and consumers treat untyped bodies inconsistently.
Memory creeps in long-running daemons. The container accumulates resolved instances, model event listeners, and query logs. Periodically DB::disconnect(), avoid Model::observe() registration inside handlers, and consider gc_collect_cycles() on a timer.
Debugging. STOMP frames are plain text, so tshark -i lo -f 'tcp port 61613' -A or a socat -v proxy shows you exactly what's on the wire. On the consumer side, log $frame->headers once — the message-id, subscription and ack headers are what you need to replay. The ActiveMQ web console and RabbitMQ management UI both show queue depth and unacked counts, which is how you diagnose the ack problem above.
Maintenance risk. 114 stars, zero listed dependents, low activity. Treat it as vendored infrastructure: read the ~1k lines once, keep a fork or a compatibility test in CI, and isolate it behind your own StompPublisher/StompConsumer interfaces so a swap to enqueue/stomp or a raw clue/socket-raw implementation is a one-file change.
Extension points. The Factory accepts your own LoopInterface, so you can share one loop across STOMP, Redis and an HTTP server in the same process. Subclassing React\Stomp\Client is possible but brittle — prefer decorating it with reconnect/retry/metrics behaviour rather than extending.
How can I help you explore Laravel packages today?