iamfarhad/laravel-rabbitmq
Native ext-amqp RabbitMQ queue driver for Laravel: multi-host configuration, connection and channel pooling with health checks, publisher confirms, quorum, lazy and priority queues, delayed messages, dead-letter routing, Horizon and Octane support, plus Artisan queue management commands.
This driver only makes sense if you can install ext-amqp in the PHP runtime that actually runs your workers. Confirm that first — there is no pure-PHP fallback, and the failure mode is a hard Class AMQPConnection not found at boot, not a graceful degradation.
Install path (about 10 minutes):
# Debian/Ubuntu
sudo apt-get install -y librabbitmq-dev libssh-dev
sudo pecl install amqp
php -m | grep amqp
composer require iamfarhad/laravel-rabbitmq
php artisan vendor:publish \
--provider="iamfarhad\\LaravelRabbitMQ\\LaravelRabbitQueueServiceProvider" \
--tag="config"
Then point Laravel at it and get a broker running locally:
QUEUE_CONNECTION=rabbitmq
RABBITMQ_HOST=127.0.0.1
RABBITMQ_PORT=5672
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
RABBITMQ_VHOST=/
RABBITMQ_QUEUE=default
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.13-management
php artisan rabbitmq:consume --queue=default --num-processes=1
Where to look first, in this order:
config/rabbitmq.php after publishing — this is the real API surface; the README only highlights it. Read the whole file before dispatching anything, especially hosts, exchange, queues, and the pool block.docs/installation.md — Docker/Sail/Alpine/GitHub Actions recipes. This is where you discover you also need libssh-dev, which the README does mention but is easy to skim past.docs/production.md — Supervisor/systemd/K8s examples plus the heartbeat and prefetch baseline. Read this before your first deploy, not after.docs/recipes.md — copy-paste for delay, quorum, priority, DLX, Horizon, Octane.docs/comparison-vladimir-yuldashev.md — useful if you are migrating off the more common package.First use case to validate: pick one hot queue where you already feel the pain (a Redis queue that stalls under bursty load, or a database queue you are embarrassed by). Move only that queue by dispatching with ->onQueue('podcasts') and running a dedicated rabbitmq:consume --queue=podcasts. Do not flip QUEUE_CONNECTION=rabbitmq globally on day one — see the exchange gotchas below for why a global flip with a non-empty RABBITMQ_EXCHANGE is the single most common way messages silently disappear.
Before you commit: note the project size (33 stars, effectively one maintainer). Pin the version in composer.json, and check SUPPORT.md — Laravel 12/13 are CI-verified; 10 and 11 are best-effort and cannot be installed without disabling Composer's advisory policy.
The value proposition is that nothing about your job classes changes:
dispatch(new ProcessPodcast($podcast));
dispatch(new ProcessPodcast($podcast))->onQueue('podcasts');
dispatch(new ProcessPodcast($podcast))->delay(now()->addMinutes(10));
dispatch(new RecordEvent($event))->onConnection('rabbitmq_analytics');
Keep ShouldQueue, $tries, $backoff, $timeout, failed() exactly as they are. If you find yourself reaching for the facade to publish a job, you have probably drifted away from the Queue API and lost retry/failure semantics.
This is the pattern that scales best. Rather than one giant rabbitmq connection with a dozen queue configs, define a connection per workload class, each with its own exchange, durability, and queue type:
'connections' => [
'rabbitmq' => [
'driver' => 'rabbitmq',
'queue' => 'default',
],
'rabbitmq_analytics' => [
'driver' => 'rabbitmq',
'queue' => 'analytics',
'exchange' => 'analytics-events',
'quorum' => true,
],
],
Settings resolve per connection, falling back to the rabbitmq connection and then to package defaults — so rabbitmq_analytics does not accidentally inherit rabbitmq's exchange. Set RABBITMQ_CONNECTION_NAME so you can tell them apart in the RabbitMQ management UI; without it, every connection from every app looks identical in the connections tab.
For anything beyond a toy, publish through a named exchange with a routing-key template:
RABBITMQ_EXCHANGE=jobs
RABBITMQ_EXCHANGE_TYPE=topic
RABBITMQ_EXCHANGE_ROUTING_KEY=jobs.%s
Queue emails then publishes with routing key jobs.emails, and the driver declares the exchange, declares the queue, and binds them. Add extra bindings per queue when one queue needs to hear from several sources:
'queues' => [
'orders' => [
'bindings' => [
['exchange' => 'events', 'exchange_type' => 'topic', 'routing_key' => 'order.*'],
],
],
],
Turn on confirms with the mandatory flag in production so unroutable publishes fail loudly:
RABBITMQ_PUBLISHER_CONFIRMS_ENABLED=true
RABBITMQ_PUBLISHER_CONFIRMS_MANDATORY=true
poll (default) — basic_get. Matches Laravel's worker lifecycle expectations. Safest for long jobs, mixed queues, and --stop-when-empty cron drains.consume — basic_consume push delivery. Higher throughput on hot queues. Prefer one queue per process and scale with Supervisor numprocs, containers, or K8s replicas.Use consume for a dedicated, always-warm, short-job queue. Use poll for everything else, including queues consumed by a scheduled --stop-when-empty worker.
[program:rabbitmq-podcasts]
command=php /app/artisan rabbitmq:consume --queue=podcasts --consume-mode=consume
numprocs=4
autostart=true
autorestart=true
stopwaitsecs=3600
Scale by processes, not by prefetch. A single-threaded worker with RABBITMQ_PREFETCH_COUNT=1 is correct; bumping prefetch only helps short I/O-bound jobs, and everything prefetched beyond the job in flight sits unacked behind it — a crash turns it into redelivery, not throughput.
RABBITMQ_WORKER=horizon
Set and restart workers. Octane pool reset is similarly opt-in; if you run Octane with long-lived workers, verify the reset is wired, otherwise persistent connections survive across requests in ways you did not intend.
php artisan rabbitmq:queue-declare quorum-orders --quorum=1
php artisan rabbitmq:queue-declare critical --priority=10
php artisan rabbitmq:queue-declare bulk --lazy=1
php artisan rabbitmq:exchange-declare jobs --type=topic
php artisan rabbitmq:queue-purge orders --force
php artisan rabbitmq:pool-stats --json --watch --interval=5
The --watch --json pool stats output is the right thing to scrape during an incident. Topology declaration commands belong in your deploy pipeline so queue type and durability are reproducible rather than clicked into the management UI.
For RPC, one-off publishes, and queue introspection outside the job lifecycle:
use iamfarhad\LaravelRabbitMQ\Facades\RabbitMQ;
RabbitMQ::size('orders');
RabbitMQ::declareQueue('orders');
RabbitMQ::publishToExchange('events', $payload, 'order.created');
The facade resolves your default queue connection when it is a RabbitMQ connection, otherwise the connection literally named rabbitmq — which surprises people on multi-connection setups. Pass the connection explicitly when in doubt.
The package's own suite requires a real broker and ext-amqp (no skipped tests). For your application tests, do not fight this: Queue::fake() and assert dispatch as usual, and cover the RabbitMQ-specific behaviour (routing keys, bindings, DLX) in a small integration suite against a containerised broker on the non-default port. phpunit.xml upstream defaults to 5673 precisely so it does not collide with a local 5672.
The default exchange ignores your routing key. With RABBITMQ_EXCHANGE empty, publishing goes through the default exchange, which routes on the literal queue name. RABBITMQ_EXCHANGE_ROUTING_KEY is silently ignored. This is fine and requires zero setup — but it means your routing-key template does nothing until you set an exchange, and half your team will believe otherwise.
Publisher confirms ack unroutable messages. Without a binding, the broker discards the message and the confirm still comes back positive. You get a green dashboard and zero processed jobs. This is the #1 failure mode. Enable RABBITMQ_PUBLISHER_CONFIRMS_MANDATORY=true and verify the binding exists before you trust a deploy.
Duplicate bindings duplicate every message. If you hand-created a binding that differs from what the driver will create (same exchange, different routing key), you get two deliveries of every message. On upgrade, diff your manual bindings against the queues.*.bindings config.
Delays are bucketed and rounded up. ->delay() works without any plugin, via a per-TTL delay queue that dead-letters back to the target. Each distinct TTL needs its own queue, so delays are rounded up into buckets — never early, but potentially up to RABBITMQ_DELAY_QUEUE_GRANULARITY (default 1000ms) late. Set it to 1 for exact TTLs if your volume can tolerate the queue cardinality, or enable RABBITMQ_DELAYED_PLUGIN_ENABLED=true and install rabbitmq_delayed_message_exchange if you need many distinct or sub-second delays.
Read timeout flips meaning between modes. In poll: set RABBITMQ_READ_TIMEOUT to at least twice RABBITMQ_HEARTBEAT_CONNECTION so a half-open TCP connection cannot hang a worker while still leaving room for heartbeat frames. In consume: set it to 0. A non-zero read timeout aborts a blocking basic_consume whenever the queue is merely idle — you will get workers that crash on quiet queues and work fine under load, which is a miserable thing to debug.
Prefetch does nothing in poll mode. basic.qos governs basic_consume only. Tuning RABBITMQ_PREFETCH_COUNT and wondering why nothing changes is a rite of passage.
--stop-when-empty silently switches to poll mode. basic_consume only evaluates stop conditions on delivery, so an empty queue would block forever. The driver detects the combination, falls back to poll for that run, and logs why. Cron-drain workers therefore behave differently from long-running ones — expected, but do not build a latency assumption on it.
rabbitmq:pool-stats reports only its own process. Pools are per-process; the artisan process's pool is empty-ish and says nothing about your workers. Use it for the CLI process, and scrape workers by other means (or run the command inside the worker container).
ext-pcntl is required only for --num-processes > 1. The error message is clear, but it appears at runtime rather than install time. Either install the extension or hardcode --num-processes=1 in your Supervisor config so a missing extension fails at boot instead of mid-shift.
Queue type is a declaration-time property. --quorum, --priority, --lazy cannot be changed by redeclaring — RabbitMQ rejects a redeclare with mismatched arguments. Quorum queues also do not support every feature (notably priority). Changing type means delete-and-recreate; plan it, and use the declare commands in your pipeline so the pipeline is the source of truth.
Laravel 10/11 is not CI-verified. If you are still there, this package is a bet. Upgrade the framework first; the README is explicit that those lines are uninstallable without disabling Composer's advisory policy anyway.
Pin and watch the changelog. Single maintainer, 33 stars, zero dependents. Check CHANGELOG.md on every bump, read UPGRADE.md/MIGRATION.md before minor upgrades, and keep a rollback path to your previous queue driver for one release cycle. The upside is real — native performance, pooling, and features the Composer-only alternatives do not have — but you are an early adopter, and your deploy discipline should reflect that.
Extension points worth knowing: queues.*.bindings for multi-source fan-in, per-connection config overrides for isolated topologies, and the facade's RPC/exchange helpers for service-to-service calls that fall outside the job model. Everything else is the Laravel Queue API, which is exactly the point.
How can I help you explore Laravel packages today?