evaneos/burrow
Evaneos Burrow is an AMQP library for PHP that works with both php-amqplib and the pecl amqp C extension. Declare exchanges, bind queues, publish async messages, and build consumer daemons.
Before you write a line of code, do a constraint check. Burrow's last release is v4.6.0 (2021-10-05). Run composer require evaneos/burrow and watch what Composer says about PHP and php-amqplib. If it resolves cleanly on your runtime (PHP 8.2/8.3, php-amqplib v3), proceed. If it demands --ignore-platform-reqs, stop and decide now whether you're forking — do not adopt a package that needs platform-req suppression on day one.
composer require evaneos/burrow
composer show evaneos/burrow # confirm the installed version and its requires
Bring up a broker locally (the README's suggested image is enough):
docker run -d -p 5672:5672 -p 15672:15672 rabbitmq:3-management
# management UI at http://localhost:15672 — you will live in this UI while debugging
Where to look first, in order:
src/Driver/DriverFactory.php — this is the whole package in one file. Read the config keys it accepts (host/port/user/pwd, plus the driver-selection key and SSL options) and confirm how it chooses between php-amqplib and ext-amqp. Everything else is built on the driver it returns.examples/ — the README's snippets are lifted from here; the examples directory is more complete than the README and is the fastest way to see a working publisher + consumer pair.src/Handler/HandlerBuilder.php and the evaneos/daemon package (QueueHandlingDaemon, Worker) — this is where the consumption semantics actually live. Burrow delegates the run loop; most surprises come from evaneos/daemon, not Burrow.src/Event/ and src/Metric/ — small, legible, and the main extension points you'll actually use.First use case — pick the cheapest one. Don't start with a consumer. Start with a one-off topology script plus a publish, because it validates connection, credentials, exchange/queue declaration, and message serialization in about twenty lines:
$driver = DriverFactory::getDriver([
'host' => 'localhost', 'port' => '5672',
'user' => 'guest', 'pwd' => 'guest',
]);
$driver->declareExchange('orders');
$driver->declareAndBindQueue('orders', 'orders.notifications');
$publisher = new AsyncPublisher($driver, 'orders');
$publisher->publish(json_encode(['id' => 42]), 'orders.created', ['content_type' => 'application/json']);
Check the queue depth and message body in the RabbitMQ management UI. If that works, the rest of the package will behave.
There is no Laravel integration. No service provider, no publishable config, no queue driver, no Horizon bridge. Your first real task is to create config/burrow.php and a small BurrowServiceProvider that binds a driver as a singleton and exposes a publisher behind your own interface. Do that before writing feature code, or DriverFactory calls will metastasize across the codebase.
Bind the driver once, lazily, and never expose it. Under php-fpm you get a connection per request, which is fine but wasteful; under a long-running worker you want one connection for the process. A singleton binding gives you both, and lets you swap a fake in tests:
// AppServiceProvider::register()
$this->app->singleton(DriverInterface::class, fn () => DriverFactory::getDriver(config('burrow.connection')));
$this->app->singleton(MessagePublisher::class, fn ($app) => new BurrowPublisher(
new AsyncPublisher($app->make(DriverInterface::class), config('burrow.exchange'))
));
Define MessagePublisher::publish(string $event, array $payload): void yourself and treat Burrow's classes as an implementation detail. This is the single most valuable decision you'll make with this package — it makes the eventual migration to php-amqplib-direct or a real Laravel RabbitMQ driver a one-class change.
Publish: serialize yourself, always set headers. Burrow publishes raw strings; there's no serializer. Pick JSON and be consistent, and always send content_type, because the consumer side sees only bytes:
final class BurrowPublisher implements MessagePublisher
{
public function publish(string $event, array $payload): void
{
$this->publisher->publish(
json_encode($payload, JSON_THROW_ON_ERROR),
$event,
['content_type' => 'application/json', 'x-retry' => 0] // array headers: requires >= 4.6.0
);
}
}
Consume: keep the consumer a thin adapter, resolve the real work from the container. The consumer runs inside a long-lived CLI process, so anything you construct at boot is stale forever:
final class OrderCreatedConsumer implements QueueConsumer
{
public function consume(Message $message): void
{
app(HandleOrderCreated::class)(json_decode($message->getBody(), true, 512, JSON_THROW_ON_ERROR));
}
public function getQueueName(): string { return 'orders.notifications'; }
}
Wire the worker as an artisan command, not a raw script. You want it in supervisor, with the same env loading as the app:
// php artisan burrow:consume
$driver = app(DriverInterface::class);
$handler = (new HandlerBuilder($driver))->async()->build(app(OrderCreatedConsumer::class));
$worker = new Worker(new QueueHandlingDaemon($driver, $handler, 'orders.notifications'));
$worker->run();
Treat HandlerBuilder as a policy decision, not boilerplate. The builder composes the handler stack that decides what happens on exception. The legacy default behaviour is ContinueOnFailureHandler, which the README itself now calls dangerous — it lets the worker keep consuming after an error instead of letting the message fail. For anything that touches money or state, build a stack that nacks (or drops to a dead-letter exchange) on failure, and make the consumer idempotent so a redelivery is safe. If you need retry, build it as a handler (inspect a retry header, republish with an incremented counter and a delay queue) rather than expecting the library to provide it.
Topology as a deploy step, never at request time. Put declareExchange / declareAndBindQueue / delete operations behind a dedicated php artisan mq:setup command (they're idempotent) and run it from your deploy pipeline. Declaring exchanges on the hot path couples request latency to broker admin operations and masks drift.
Events and metrics are your observability story — use them. Burrow emits DaemonStarted, DaemonStopped, MessageReceived, MessageConsumed via league/event. Two patterns that pay off immediately:
$emitter = new \League\Event\Emitter();
$emitter->useListenerProvider(new SendMetricListenerProvider(
MetricServiceFactory::create('dogstatsd', $config, ['service' => 'orders', 'env' => app()->environment()])
));
// bridge into Laravel's dispatcher so the rest of the app can listen too
$emitter->addListener(new class(app('events')) { /* forward to Laravel events */ });
The built-in metrics (daemon.message_received, daemon.message_consumed, daemon.message_processing_time) give you throughput and latency for free; received - consumed is your stuck-consumer alarm.
Use SyncPublisher deliberately, not as a default. Sync means the publish path blocks waiting on the broker. That's appropriate for a CLI/admin path or a genuine RPC-style flow, and a poor fit for "publish from a web request." Decide per call site.
Test against a real broker, and against your interface in unit tests. Burrow's abstractions are thin enough that mocking them proves nothing. Keep a docker compose service with RabbitMQ in CI, run one publish→consume round-trip per driver you support (php-amqplib and, if you care, ext-amqp), and use your own MessagePublisher fake everywhere else.
failed_jobs, and worker supervision for free. Burrow is only defensible if the requirement is genuinely transport portability across php-amqplib/ext-amqp, or the surrounding app is not Laravel.php-amqplib and ext-amqp paths are not behaviourally identical — ack/nack timing, confirm mode, heartbeat and connection handling all differ. SSL via a config array landed in 4.5.0 for php-amqplib only; on the PECL path you must construct the driver manually. If you rely on the C extension for performance, run your contract tests on both paths in CI or you'll discover the differences in production.>=4.6.0 and don't pass scalars where the code expects an array.ContinueOnFailureHandler is a trap. It exists purely for backward compatibility with pre-4.0 behaviour. It suppresses failures rather than surfacing them; unacked messages then pile up against the prefetch limit and the consumer silently stalls. Watch unacked in rabbitmqctl list_queues name messages_ready messages_unacknowledged — a climbing unacked count is the classic symptom.QueueHandlingDaemon is shown with two different constructor signatures, and the events section links MessageStopped.php while naming the class MessageConsumed. Trust src/ over the README, and verify the queue-name argument the daemon takes versus what QueueConsumer::getQueueName() returns — a mismatch means your worker happily consumes the wrong queue and looks healthy while doing nothing.DB::purge() between messages) since the process never reboots.rabbitmqctl list_queues name messages_ready messages_unacknowledged consumers → your MessageReceived/MessageConsumed metric delta → the event listener that logs $message->getBody() and headers. Nine times out of ten it's a routing key or a missing binding, not the library.DriverFactory) for connection pooling or instrumentation; custom handlers in the HandlerBuilder stack for retry/DLQ/logging; custom listeners on the league/event emitter for tracing; a custom metric backend if StatsD/DogStatsD don't fit. All four are small interfaces — prefer extending there over editing vendor/.How can I help you explore Laravel packages today?