wlogic/rabbitmq-bundle
A lightweight RabbitMQ integration bundle for PHP applications, providing configuration-driven connections, message publishing and consuming helpers, and simple AMQP setup so you can add reliable background jobs and event messaging without boilerplate.
Read this first, because this is not a Laravel package. wlogic/rabbitmq-bundle is a Symfony 2 bundle: it ships a Bundle class, a DependencyInjection/Configuration.php tree, and compiler passes that wire services via tags (rabbitmq.consumer, rabbitmq.callback, rabbitmq.producer). Laravel will never discover any of that. Treat the repo as a well-structured reference implementation of php-amqplib and borrow the three classes that matter:
RabbitMq/Producer.php — thin wrapper over AMQPExchange::publish() with content-type/delivery-mode/priority setters.RabbitMq/Consumer.php — callback + basic_consume loop, ack policy, memory limit, idle timeout.RabbitMq/RpcClient.php / RpcServer.php — correlation-id / reply-to request-reply, which is the genuinely hard part to get right by hand.Start with DependencyInjection/Configuration.php: it is the best documentation in the repo, listing every connection, exchange, queue, qos_options, and ssl option in one place, including which defaults are silently applied.
Minimal steps to get productive in a Laravel app:
composer require php-amqplib/php-amqplib — you want the transport, not the bundle. If you intend to actually autoload the bundle's classes (they are plain PHP, no Symfony base classes), require the package but never register it.config/rabbitmq.php:return [
'connections' => [
'default' => [
'host' => env('RABBITMQ_HOST', 'localhost'),
'port' => 5672,
'user' => env('RABBITMQ_USER', 'guest'),
'password' => env('RABBITMQ_PASSWORD', 'guest'),
'vhost' => '/',
'lazy' => true,
'connection_timeout' => 3.0,
'read_write_timeout' => 3.0,
'heartbeat' => 15, // the bundle default of 0 is a trap on long-idle workers
],
],
'producers' => [
'mail' => [
'connection' => 'default',
'exchange_options' => ['name' => 'mail', 'type' => 'topic'],
'content_type' => 'application/json',
'delivery_mode' => 2,
],
],
];
AMQPLazyConnection behaviour — connect on first publish(), not at boot — is worth reproducing by hand; eager connections in a service provider will slow every request and break php artisan commands that never touch the queue.{"user_id": 42} to the mail topic exchange from a UserRegistered listener, and consume it from a dedicated long-running command under Supervisor. Keep this separate from Illuminate\Queue; mixing the two on the same connection is where things get painful.Producer as a container singleton, published through a thin domain service. Do not inject AMQPExchange or AMQPChannel into controllers. The bundle exists precisely so that application code calls $producer->publish($payload, $routingKey) and nothing else.
$this->app->singleton(AMQPStreamConnection::class, function ($app) {
$c = config('rabbitmq.connections.default');
return $c['lazy']
? new AMQPLazyConnection($c['host'], $c['port'], $c['user'], $c['password'], $c['vhost'], false, 'AMQPLAIN', null, 'en_US', $c['connection_timeout'], $c['read_write_timeout'], null, true, $c['heartbeat'])
: new AMQPStreamConnection(/* ... */);
});
$this->app->bind(MailProducer::class, function ($app) {
$conn = $app->make(AMQPStreamConnection::class);
$ex = new AMQPExchange($conn->channel());
$ex->setName('mail');
$ex->setType(AMQP_EX_TYPE_TOPIC);
$ex->setFlags(AMQP_DURABLE);
$ex->declareExchange();
return new MailProducer($ex);
});
Declare topology once, at deploy time. The bundle gives you rabbitmq:setup-fabric; you need the Laravel equivalent. Write php artisan rabbitmq:declare that iterates the exchanges/queues arrays and declares exchanges, queues, and bindings idempotently, then run it in your deploy script. Never declare topology inside request-path producers.
Consumer command mirroring rabbitmq:consumer. The bundle's command flags (--memory-limit, --max-messages, --idle-timeout, --no-ack) map directly onto an Artisan command signature. Give yourself all four; you will need --max-messages to get graceful restarts under Supervisor and --memory-limit because PHP workers leak.
class ConsumeMail extends Command
{
protected $signature = 'rabbitmq:consume:mail
{--memory-limit=128 : MB before self-restart}
{--max-messages=0 : stop after N messages}
{--idle-timeout=0 : seconds before exiting when idle}';
public function handle(): int
{
$channel = app(AMQPStreamConnection::class)->channel();
$channel->set_qos(0, (int) config('rabbitmq.consumers.mail.qos.prefetch_count'), false);
$queue = new AMQPQueue($channel);
$queue->setName('mail');
$queue->setFlags(AMQP_DURABLE);
$queue->declareQueue();
$queue->bind('mail', 'mail.*');
$consumed = 0;
$queue->consume(function (AMQPEnvelope $msg, AMQPQueue $q) use (&$consumed) {
try {
$this->dispatchNow(new SendMailJob(json_decode($msg->getBody(), true)));
$q->ack($msg->getDeliveryTag());
} catch (\Throwable $e) {
report($e);
$q->nack($msg->getDeliveryTag(), AMQP_NOPARAM); // or requeue once, then dead-letter
}
if ($this->shouldStop(++$consumed)) { return false; }
}, AMQP_NOPARAM, $tag = null); // empty tag => broker-generated, unique per connection
}
}
Reuse the callback-registry idea instead of Symfony's tagged services. The bundle collects all services tagged rabbitmq.callback and registers them on the consumer. In Laravel, keep a consumers config array mapping queue name → handler class, resolve handlers from the container, and let one generic consume command serve all of them. This is the single most valuable structural borrow: it keeps one worker command instead of N near-identical ones.
RPC via RpcClient::addRequest() / getReplies() is the pattern to copy verbatim for synchronous request-reply. Two things to preserve: a per-request reply_to queue with an exclusive, auto-delete, server-named declaration, and correlation-ID matching on the reply. Do not roll this yourself with a shared reply queue — you will get cross-talk under concurrency.
Queue-size and lag monitoring: the bundle has no metrics. Because you own the connection, add an Artisan command that calls $channel->queue_declare($name, true, false) in passive mode to read message_count and consumer_count and pushes them to your metrics backend. Passive declare returns the counts without asserting arguments — this is the only safe way to inspect a queue you do not own.
Testing: wrap the producer in an interface and bind a fake in the test container that records messages. The consumer side is testable by extracting the callback body into an invokable handler class that takes a plain array payload — then you unit-test the handler with no broker at all. Reserve a real broker (Testcontainers / docker compose) for one or two integration tests.
%kernel.debug% and %kernel.environment%. If you copy config verbatim into config/rabbitmq.php, those strings become literal hostnames. Strip every %...% token.prefetch_count defaults to 0, which means "unlimited" — the broker will hand a single worker every message it has. A worker that dies mid-batch then requeues thousands at once. Set prefetch_count to 1–10 per consumer, per queue, and match it to your job latency.durable, auto_delete, or arguments closes the channel. The bundle's auto_setup_fabric hides this at boot and then explodes later from a different process. Pin the topology in one place and make sure it is byte-identical everywhere. To change args on a live queue, you must delete it (or declare a new name and migrate).read_write_timeout: 3 and a 30-second job, php-amqplib's own check can kill the connection. Either set heartbeat to roughly a third of read_write_timeout, or bump channel_rpc_timeout. Under Octane, connections are even longer-lived — never cache a connection across an Octane worker boot without a liveness check ($conn->isConnected()), because a silently dead TCP socket will accept a publish() and drop the message.basic_publish returns success even when no queue is bound. The bundle exposes the mandatory flag; you must handle basic.return (and basic.nack for confirms) to notice. Always give every exchange at least one binding, and consider publisher confirms with setConfirmCallback for anything you cannot afford to lose.delivery_mode: 2 + a confirm on publish. Miss any one and a broker restart loses the message. The bundle's producer defaults do not set delivery_mode: 2 unless you configure it.auto_ack: false (the sane default), a SIGKILL on your Supervisor worker returns everything unacked to the queue — good — but if your handler acked before the side effect completed, the message is gone. Ack last, after the DB transaction commits. Prefer nack with requeue=false plus a dead-letter exchange over infinite requeue=true poison-message loops.Consumer requires a PSR-3 logger, which in Symfony is injected. In Laravel, app('log') satisfies Psr\Log\LoggerInterface, so you can pass it straight into the bundle's classes if you do autoload them — one of the few places the two frameworks line up cleanly.getQueue() on the consumer returns the declared AMQPQueue; reusing it across consume() calls after a reconnect is unsafe. Build a fresh channel from the lazy connection on every reconnect.dispatchNow reuses the same app instance forever. Anything stateful (auth user, DB::reconnect, config overrides) leaks between messages. Call DB::purge() on reconnect and avoid $request-derived state in handlers.php-amqplib/php-amqplib security fixes. Vendor-lock the exact commit, keep the borrowed classes behind your own interfaces so you can swap in vladimir-yuldashev/laravel-queue-rabbitmq or a bespoke transport without touching handlers, and read the diff between this fork and upstream before trusting it in a payment path. If all you need is a queue driver, use a maintained Laravel-native package; the value here is the Producer/Consumer/RpcClient shape, not the code.How can I help you explore Laravel packages today?