simple-bus/rabbitmq-bundle-bridge
Symfony bundle bridge that connects SimpleBus to RabbitMQ, making it easier to wire message buses to RabbitMQ queues for asynchronous command and event handling with minimal configuration.
This bundle is glue, not a framework. It wires the Publisher/Consumer abstractions of SimpleBus/AsynchronousBundle to the producers/consumers of OldSoundRabbitMQBundle. You need all three packages plus simple-bus/message-bus and simple-bus/serialization; the bridge itself is only a handful of classes.
1. Check installability before you plan anything — this is the step that changes most. The latest tag is v6.2.0, whose only change is dropping support for Symfony ^5.2; the package now expects Symfony ^5.3 (and the 6.x line the 6.x tags target). That is a constraint bump, not a feature release, so the practical rule is unchanged: verify against your lockfile before writing any config.
composer require simple-bus/rabbitmq-bundle-bridge --dry-run
composer why-not simple-bus/rabbitmq-bundle-bridge
Two outcomes matter:
^5.2: v6.2.0 is not installable for you. You are stuck on the previous 6.1.x tag — pin it explicitly ("simple-bus/rabbitmq-bundle-bridge": "6.1.*"). Note this is a minor release that removes a supported constraint, i.e. a BC break delivered as a non-major bump — do not let ^6.0 float blindly.^5.3 or 6.x: the bridge still installs, but that only proves the metadata resolves. Both upstreams have moved on (OldSoundRabbitMQBundle was renamed to php-amqplib/rabbitmq-bundle; SimpleBus is effectively frozen). If Composer refuses, stop — this is a codebase-compatibility decision, not a config problem.2. Register bundles (in order matters only for parameter resolution, not functionally):
// config/bundles.php
OldSound\RabbitMqBundle\OldSoundRabbitMqBundle::class => ['all' => true],
SimpleBus\AsynchronousBundle\SimpleBusAsynchronousBundle::class => ['all' => true],
SimpleBus\RabbitMQBundleBridgeBundle\SimpleBusRabbitMQBundleBridgeBundle::class => ['all' => true],
3. Read the source before you wire it. The repo is ~2 production classes (RabbitMQProducer, RabbitMQConsumer) plus a DI extension. Because there are effectively zero dependents in the wild, and the 6.x changelog is constraint bumps only (no docs refresh, no new extension points), treat the source and its Tests/ as the authoritative docs and confirm service ids with:
bin/console debug:container --show-private | grep -i rabbit_mq
4. First use case — publish a command asynchronously and consume it in a worker. Declare the transport in old_sound_rabbit_mq, point a producer at the bridge's publisher, and point the OldSound consumer's callback at the bridge's consumer (it implements ConsumerInterface, so it plugs in directly):
# config/packages/old_sound_rabbit_mq.yaml
old_sound_rabbit_mq:
connections:
default:
host: '%env(RABBITMQ_HOST)%'
user: '%env(RABBITMQ_USER)%'
password: '%env(RABBITMQ_PASSWORD)%'
vhost: '/'
producers:
order_commands:
connection: default
exchange_options: { name: order_commands, type: direct }
consumers:
order_commands:
connection: default
exchange_options: { name: order_commands, type: direct }
queue_options: { name: order_commands }
qos_options: { prefetch_size: 0, prefetch_count: 10, global: false }
callback: simple_bus_rabbit_mq_bundle_bridge.consumer
Then swap the Publisher injected into your async middleware for the bridge's RabbitMQ publisher, and run a worker:
bin/console rabbitmq:consumer order_commands -m 100 -vvv
If a message appears in the management UI but nothing happens in the worker, the problem is almost always the callback service id or the bus id the consumer dispatches to — log both at boot.
Two buses, two transports. The healthy pattern is one async command bus (direct exchange, one queue, one worker pool) and one async event bus (fanout exchange, one queue per subscriber group). Don't share a queue between commands and events — a poison command will block your event stream.
Producer side — keep the async bus a drop-in. SimpleBus's async middleware replaces the synchronous bus transparently, so controllers keep dispatching commands and never touch AMQP:
final class PlaceOrderHandler
{
public function __construct(private CommandBus $commandBus) {}
public function __invoke(PlaceOrder $command): void
{
// goes through the async middleware -> bridge publisher -> RabbitMQ
$this->commandBus->handle(new ReserveStock($command->orderId()));
}
}
Consumer side — one worker process, one queue, one bus. The bridge consumer receives an AMQPMessage, deserializes it via the SimpleBus serializer/deserializer, and hands the message to the configured message bus. Keep the dispatch path pure: no HTTP calls, no long transactions inside the handler; the handler is your retry boundary.
Wrap the handler in idempotency. AMQP is at-least-once. Track processed message ids (a processed_messages table with a unique key, insert-on-conflict) rather than trying to configure exactly-once.
Run workers under a supervisor with bounded message counts. -m 100 (or a memory guard) forces the process to recycle and papers over leaks in long-lived PHP — necessary because the bundle gives you no lifecycle hooks of its own.
[program:order_commands]
command=php /app/bin/console rabbitmq:consumer order_commands -m 100 --no-debug
autorestart=true
numprocs=4
stopsignal=TERM
Split queues by failure behavior. Configure a dead-letter exchange on queue_options.arguments for anything that shouldn't be retried forever; the bridge has no DLX logic, that is purely transport config.
Testing — replace the publisher, not the transport. In functional tests, bind Publisher to an in-memory implementation that records messages, and assert on the recorded set. For consumer tests, instantiate RabbitMQConsumer directly with a stub AMQPMessage and a real bus with spy handlers; you avoid needing a broker in CI entirely.
^5.2 — with no new features, no deprecations, no docs. Infer from that: no DLX helper, no retry API, no serializer contract work is coming. Pin versions in composer.json and put a comment explaining why.^5.2, v6.2.0 breaks you on a non-major bump. Pin 6.1.* (or exact-pin 6.2.0 if you're current) and treat this changelog as a list of platform requirements, not of behaviour changes — behaviour is unchanged, which is both reassuring and a warning that nothing is being fixed.OldSoundRabbitMQBundle naming matters. If you are on the renamed fork, service ids (old_sound_rabbit_mq.*) and class namespaces (OldSound\RabbitMqBundle\RabbitMq\ConsumerInterface) differ from what the bridge imports — you will need a shim or a fork.false from ConsumerInterface::execute() causes requeue in OldSound. An exception during deserialization (renamed class, schema change, bad JSON) will therefore loop forever. Catch Throwable around deserialization, log the raw body, and return true (ack) so a human can replay from the DLX.Legacy* shim if you must move them.qos_options.prefetch_count is per-channel; the default of 0 will happily give a worker thousands of unacked messages and blow memory during -m 100 recycling. Start at 10.why-not commands afterwards.rabbitmq:consumer -vvv for the callback and bus id → debug:container for the publisher/consumer service ids → check the message's content_type and body (empty body means the producer was handed the wrong object).ConsumerInterface that delegates to the bridge consumer after adding tracing/logging. Since no new extension points arrived in 6.2.0, assume you will own any such code indefinitely.How can I help you explore Laravel packages today?