A Laravel queue backend, a worker supervisor, and a standalone PHP client for Queen MQ.
Your jobs stay ordinary Laravel jobs. What changes underneath them is the backlog — Redis becomes the Queen broker and its own replicated log — and the control plane, where Horizon's PHP master becomes a Rust one.
composer require queen-mq/php-clientDocumentation · Migrate from Horizon · PHP client reference · PHP 8.3 / 8.4 · Apache-2.0
Horizon Queen
─────── ─────
dispatch() ──► Redis dispatch() ──► Queen
│ │
horizon master ──┤ 49.1 MiB PHP queen-supervisor ─┤ 7.0 MiB Rust
│ │
horizon:work ────┘ queue:work queen ─┘
Resident memory of the master process through a 45-minute soak on a Linux server, from the
Laravel benchmark. It is a control-plane
number, not a whole-stack claim: Queen still runs a broker, with prefork a fork server holds the
booted Laravel beside the Rust master, and Horizon runs horizon:supervisor beside its own. The PHP
engine's master held 58.5 MiB.
On one 16-vCPU Linux server, with a broker and a Redis that both fsync every write:
- 32 workers completed 2,753 jobs/s of 10 ms jobs against Horizon's 1,124.
- 64 forked workers and their supervisor used 225 MiB against Horizon's 1,967 MiB, about 27 MiB less per worker.
Diagnostic results; every lane and its limits are on the benchmark page.
One ordered lane per entity, not per shard. Redis gives you a queue. Queen gives you a FIFO
partition per ordering key — customer:4471, account:9, device:aa:bb — created by the first
push that names it. One customer's jobs never queue behind another customer's.
The backlog lives on disk, in the broker's replicated log. The broker answers a push once it is fsynced — on a three- or five-node cluster, once a majority of the nodes have it on disk. One binary and one data directory per node, and no external database to run next to it.
A control plane that is not a Laravel application. The Rust supervisor loads Artisan once to
resolve configuration, then leaves only Rust and your ordinary queue:work processes resident:
7.0 MiB against 49.1 MiB for Horizon's master through a 45-minute soak.
Smaller workers. With prefork, Laravel boots once and every worker is forked from it, sharing the framework and the opcache: a forked worker kept 1.7 MiB of private memory against 28.9 MiB for a Horizon worker. The memory a job allocates stays the worker's own, so the saving is about 27 MiB per worker, not a ratio. The Rust master also renews the workers' leases itself, so prefetching workers need no helper process.
More jobs per worker. The acknowledgement of a job and the pop for the next batch can travel while a job runs, so a worker does not wait for the broker's fsync between jobs.
Built for Kubernetes. Coordinated replicas split every pool's target, and a Prometheus endpoint gives HPA or KEDA the backlog to scale pods on.
Metrics without a snapshot command. Per-job-class throughput and runtime, monitored tags and long-wait alerts are recorded by every worker into the broker, across every host.
Stay on Horizon if you need silenced jobs, job lists, batches, Slack or SMS notification routes, or per-supervisor controls. The honest, itemized comparison is Queen or Horizon.
Preview. The queue driver is usable on its own. The supervisor and dashboard are preview features and Unix-only. Read Production checks before replacing Horizon.
Package discovery registers the service provider, the Queen facade and a queen queue
connection. You do not have to touch config/queue.php.
composer require queen-mq/php-client
php artisan vendor:publish --tag=queen-configQUEUE_CONNECTION=queen
QUEEN_URL=http://127.0.0.1:6632
QUEEN_QUEUE=default
QUEEN_CONSUMER_GROUP=laravelNothing changes at the call site.
GenerateInvoice::dispatch($invoiceId);php artisan queue:work queen --queue=default --timeout=60 --tries=3That is the whole integration. Dispatch, middleware, --tries, backoff, failed_jobs and the
JobFailed event all behave as they do today. Keep your current systemd, Kubernetes or Supervisor
unit — Queen's own supervisor is optional and comes later on this page.
The one rule that matters: retry_after in config/queen.php (90 seconds by default) is the
Queen lease. It must be longer than the worker timeout and longer than your slowest job. A job that outlives its lease gets redelivered
while it is still running.
Add QUEEN_BEARER_TOKEN when the broker requires it. Give each application and environment its own
QUEEN_CONSUMER_GROUP: two applications sharing one group share one cursor and split the work.
Redis jobs, Horizon history, metrics and tags do not move. Queen workers cannot drain a Redis backlog and Horizon workers cannot drain a Queen one, so every safe migration gives each backend an explicit ownership window.
Same concepts, snake_case names, independent implementations.
config/horizon.php |
config/queen.php |
|
|---|---|---|
connection: redis |
connection: queen |
|
queue |
queues |
always an array |
balance: auto |
balance: auto |
dynamic total and per-queue allocation |
balance: simple |
balance: simple |
fixed processes, evenly spread |
balance: false |
balance: off |
ordered queue list on every worker |
autoScalingStrategy |
strategy |
size or time |
minProcesses |
min_processes_per_queue |
Horizon's minimum is per queue; min_processes bounds the pool |
maxProcesses |
max_processes |
|
balanceMaxShift |
balance_max_shift |
add fast_scale_up to close half the gap per cycle |
waits |
waits |
schedule queen:check-waits every minute |
tags(), monitored tags |
same tags(), Tags page |
|
horizon:snapshot metrics |
job_metrics |
live, nothing to schedule |
balanceCooldown |
balance_cooldown |
add event_driven to grow as soon as jobs arrive |
maxJobs / maxTime |
max_jobs / max_time |
worker recycle limits |
timeout tries memory sleep rest force |
same names | |
nice |
— | keep OS priority outside Queen |
array backoff |
— | the supervisor takes one integer |
Matching names are not matching algorithms. For strict priority such as high,default, use
balance=off with prefetch=1; auto allocates by measured pressure, not by queue order.
Leave the default connection on Redis while you prove the path.
RebuildSearchIndex::dispatch($tenantId)
->onConnection('queen')
->onQueue('queen-canary');php artisan queue:work queen --queue=queen-canary --timeout=60 --tries=3Throughput is not the gate. Verify attempts and backoff, a deliberate failure through Laravel, a worker killed inside user code, failed-job synchronization, deployment drain, and the broker being unreachable — with your own jobs.
Drain, then switch — simplest ownership boundary, costs a dispatch pause. Stop producers, let
Horizon empty every Redis queue, confirm no reserved job remains, terminate Horizon, deploy
QUEUE_CONNECTION=queen, resume.
Route new, drain old — no pause. Ship code that sends new jobs to queen, run both sets of
workers side by side, watch Redis to zero, then php artisan horizon:terminate and remove the
routing flag.
Full runbook, including rollback: Migrate from Horizon.
By default jobs spread deterministically over 64 partitions, so they run concurrently without creating a partition per job. When a business entity needs its own ordered lane, say so:
use Queen\Laravel\Contracts\QueenPartitionable;
final class RebuildCustomer implements QueenPartitionable
{
public function __construct(public string $customerId) {}
public function queenPartition(): string
{
return 'customer:' . $this->customerId;
}
}Every RebuildCustomer for one customer now runs in dispatch order, and customers never block each
other. Horizon, on Redis, has no equivalent.
The defaults keep Laravel's ordinary one-job-at-a-time reserve/delete boundary. They are the right
starting point for a migration. These are keys of config/queen.php; the same keys on the queen
connection in config/queue.php win over them.
| Key | Default | |
|---|---|---|
prefetch |
1 |
jobs claimed per broker request, or 'auto' to size each pop from the jobs' runtime |
ack_batch |
1 |
successful jobs committed together |
autopilot |
false |
lets the broker size the pop sweep width instead of partitions |
block_for |
0 |
long-poll seconds; 0 polls without blocking |
bulk_batch |
100 |
bound for Queue::bulk(), not for dispatch() |
lease_renewal |
false |
keeps the lease alive under a running job |
ack_async |
false |
sends each ACK without waiting; the answer is read after the next job |
pop_ahead |
false |
pops the next batch while the last job of a full batch runs |
Raising prefetch trades round trips for a wider redelivery window: a crash can redeliver the
unflushed batch, and a paused worker can sit on prefetched jobs until the lease expires. So the
connector rejects prefetch above 1 unless lease_renewal is true, however the worker was
started. When a worker crashes holding a batch, its lease renewer (the Rust master or the PHP
helper) hands back the jobs it had not started, without an extra attempt; a lost node still charges
one, so keep tries at 2 or more with prefetch above 1 or pop_ahead on.
autopilot is off here even though the SDK client enables pop autopilot by default: the
queue driver keeps sending the fixed partitions width, at most 64, so an upgrade changes
nothing on its own. Turn it on to let the broker size the sweep width per (queue, group) from ready-partition
pressure and ready age. The pop batch stays pinned to prefetch. It needs a broker on 1.2 or
later; an older one ignores the parameter and applies its own default width.
// config/queen.php
'prefetch' => 16,
'ack_batch' => 16,
'lease_renewal' => true,
'lease_renewal_interval' => 30,Renewal keeps the lease alive under the active job and fences the worker if it cannot. Under the
Rust supervisor on Linux the master renews the leases of all its workers; elsewhere each worker
starts one small PHP helper, on Unix CLI PHP. Delivery stays at least once either way — handlers
still need idempotency keys. process_limit still counts a slot for the helper, which a worker
starts when the master refuses it.
The safe delivery profile.
Requests go over the client's own kept-alive cURL handles, not Guzzle: with 32 workers and empty
jobs on the Linux server, 6,030 jobs/s against 5,441 and 1.03 against 1.41 ms of application CPU
per job.
QUEEN_SDK_HTTP_TRANSPORT=guzzle switches back; an HTTP proxy variable does so too.
ack_async and pop_ahead take the broker's round trip off the worker's path. A failed
asynchronous ACK is reported one job later and the job is delivered again; pop_ahead needs
lease_renewal, and ack_async needs ack_batch 1. With both, 32 workers on the Linux server
went from 1,879 to 2,794 jobs/s of 10 ms jobs.
The fast profile.
Keep prefetch=1 for long jobs, strict per-job acknowledgement, or comma-separated priority queues.
The optional replacement for Horizon's master. Two engines, one configuration, one control protocol: PHP is the readable reference, Rust is the one you deploy.
# PHP engine
php artisan queen:supervise
# Rust engine: an explicit, version-pinned deploy step. Composer never downloads it.
php artisan queen:supervisor-install
vendor/bin/queen-supervisor --php php --artisan artisan'supervisor' => [
'poll_interval' => 3,
'shutdown_grace' => 75, // must exceed every worker timeout
'state_directory' => storage_path('queen-supervisor'),
'supervisors' => [
'jobs' => [
'connection' => 'queen',
'consumer_group' => 'laravel',
'queues' => ['high', 'default'],
'balance' => 'auto', // auto | simple | off
'strategy' => 'time', // time | size
'min_processes' => 1,
'max_processes' => 20,
'min_processes_per_queue' => 1, // every queue stays warm
'fast_scale_up' => true, // close half the gap per cycle
'target_clear_seconds' => 60,
'balance_cooldown' => 3,
'balance_max_shift' => 2,
'timeout' => 60,
],
],
],strategy=size sizes the pool from queue depth and target_jobs_per_process. strategy=time
multiplies depth by observed job runtime to hit target_clear_seconds. Both engines cap restart
backoff, open a circuit after five consecutive crashes, and allow one probe after the cooldown.
Each pool reads these keys; the defaults are the ones of the default pool shipped in
config/queen.php.
| Pool key | Default | |
|---|---|---|
balance |
auto |
auto, simple or off |
strategy |
size |
size reads depth; time multiplies depth by observed runtime |
min_processes |
1 |
floor per pool |
max_processes |
10 |
ceiling per pool |
target_jobs_per_process |
10 |
jobs per process, size strategy |
target_clear_seconds |
60 |
drain target, time strategy |
default_runtime_seconds |
1 |
assumed runtime until samples exist |
balance_cooldown |
3 |
seconds between scaling decisions |
balance_max_shift |
1 |
processes added or removed per decision |
min_processes_per_queue |
0 |
auto only: workers every queue keeps without backlog |
fast_scale_up |
false |
close half of the gap to the target per decision |
scale_down_delay |
10 |
idle seconds before shrinking |
restart_backoff |
1 |
first restart delay |
restart_backoff_max |
30 |
backoff ceiling |
stable_after |
60 |
seconds before a restarted worker counts as stable |
Two switches of supervisor apply to the whole master: event_driven (false) wakes on new jobs
through a read-only long poll instead of the next poll, and lease_service (true; Rust engine on
Linux) renews the workers' leases in the master, while false keeps one helper per worker. Up to
2.0.0 the Rust master read QUEEN_SUPERVISOR_LEASE_SERVICE from its environment; supervisor
0.7.0, pinned by 2.1.0, reads only the key.
Control is engine-independent, through the local state directory:
php artisan queen:supervisor status --check # live, plus minimum serving capacity
php artisan queen:supervisor pause
php artisan queen:supervisor continue
php artisan queen:supervisor terminate
php artisan queen:supervisor-config --pretty # resolved config, credentials redactedSeveral replicas need coordination. Each master sizes its pools from the whole backlog, so two uncoordinated replicas on two hosts both scale to maximum. Set
QUEEN_SUPERVISOR_COORDINATION=trueon every replica: they register in the broker's key/value store and each runs an even share of every autoscaling pool's target, withmin_processesandmax_processesapplied per replica. Replicas coordinate when their broker, consumer group and queue set match; fixed pools are not split. Without coordination, run one replica with aRecreatestrategy.
Prefork workers. QUEEN_SUPERVISOR_PREFORK=true boots Laravel once in a fork server and forks
every worker from it, with the same arguments and environment a spawned worker gets. The server
opens no connection before forking, purges database and Redis connections in each child, and
SIGKILLs its workers if the master dies. A failed fork falls back to spawning. A pool's own
'prefork' => false spawns that pool's workers while the others fork (2.3.0). Needs ext-pcntl
and ext-posix. Enable opcache.enable_cli with prefork, where the fork server's opcache is shared
by every worker; without prefork, each worker keeps its own copy and opcache costs memory.
php artisan queue:restartmakes the master start a new fork server (Laravel 12), so the workers forked after a deploy run the new code;config/queen.phpchanges still needqueen:supervisor terminate.- On Linux the fork server refuses to serve when the booted application runs another thread (a gRPC or Kafka extension, an APM agent), and the master spawns workers. It warns about sockets the boot left open, which every forked worker would share.
- When not to use prefork.
Monitoring. The dashboard's Jobs and Tags pages, queen:check-waits with the
LongWaitDetected event and mail, and a Prometheus endpoint at /queen/metrics
(QUEEN_METRICS_ENABLED, QUEEN_METRICS_TOKEN) are described in
Monitoring and alerts.
Requires Unix with pcntl and posix. Windows is rejected explicitly rather than left to fail;
WSL runs the Linux artifact. Installer verification, air-gapped installs, Sigstore pinning and the
endpoint-failover read token are covered in
Worker supervisors.
A server-rendered local panel at /queen, disabled by default, showing supervisor health, pools,
restart state, sampled depth and failed-job metadata. Each section is its own page (/queen,
/queen/workload, /queen/supervisors, /queen/failed-jobs, /queen/configuration). Failed jobs
are paged newest first with a keyset cursor, never an OFFSET or COUNT(*), so a table with
millions of rows costs the same per page. The Workload page charts jobs completed, failed and
dispatched over the last hour, 6 hours, day or week from the broker's own per-queue counters
(GET /api/v1/analytics/queue-ops), so it adds no write to the job path.
QUEEN_DASHBOARD_ENABLED=trueThe panel refreshes in place with a small packaged script (header and main region only, with a
Pause auto-refresh control) and falls back to a <noscript> meta refresh without JavaScript.
If the web server answers every *.css or *.js from public/ without reaching PHP (a common
static-asset rule), publish the assets. The panel uses each copy only while it matches the package,
and falls back to its own routes otherwise:
php artisan vendor:publish --tag=queen-assets --forceEach failed job opens in a drawer (or as a page at /queen/failed-jobs/{id}) that shows why it
failed: the job class, maximum tries, the exception message and the stack trace (paths relative to
the application root), each with a Copy button, but never the payload.
In production it is deny-by-default even when enabled until the application defines the ability:
Gate::define('viewQueenDashboard', fn ($user) => $user?->canOperateQueues() === true);Controls are POST-only, CSRF-protected and carry the exact supervisor instance_id, so a stale page
cannot command a replaced master. Without remote status, the panel reads one local state directory.
Global backlog analytics and DLQ operations live in the Queen broker dashboard.
Dashboard reference.
Supervisor on another host. When the dashboard is served by other processes than the supervisor — Kubernetes web pods and a separate worker pod, for instance — either engine can also publish its status to the broker's key/value store:
QUEEN_SUPERVISOR_REMOTE_STATUS=trueSet it on every supervisor host and on the web hosts. The status goes under
supervisor.remote_status.key, one per application and environment, which defaults to a slug of
APP_NAME and APP_ENV (such as orders-production): hosts that share both values agree on it.
Each supervisor instance publishes into its
own slot under the key, so the dashboard lists every host or pod: a live local supervisor first,
then each published one with its host name, and totals over the live ones. Published instances are
read-only: pause, continue and terminate stay with php artisan queen:supervisor on their own
host. Liveness comes from the published heartbeat alone. When two running masters autoscale the
same queue and consumer group without coordinating, the dashboard warns. The
document is split across <key>/<instance_id>/head and <key>/<instance_id>/chunk/NNNN in the
queen-supervisor namespace, written in one transaction, so it never depends on the key/value value
ceiling. A <key>/head document from an earlier release is still read. Publishing is best effort
and budgeted into the heartbeat; a broker outage shows the supervisor as stale and never stops
supervision. The Rust engine publishes the same format from supervisor 0.3.0 (this package pins
0.6.0); 0.2.0 wrote the single <key>/head slot.
Key of supervisor.remote_status |
Default | |
|---|---|---|
enabled (QUEEN_SUPERVISOR_REMOTE_STATUS) |
false |
publish the status document |
key |
slug of APP_NAME and APP_ENV |
shared by every supervisor and web host of the application |
connection |
queen |
Queen connection whose broker and credentials are used |
namespace |
queen-supervisor |
key/value namespace |
interval |
poll_interval |
seconds between publishes; a state change publishes at once |
ttl |
2 × heartbeat_timeout, min 300 |
expiry of the published copy |
| Laravel surface | With Queen |
|---|---|
ShouldQueue, dispatch(), middleware, timeout, --tries, backoff |
unchanged |
| Delayed dispatch and backoff | Queen timers; release is atomic with the ack |
failed_jobs |
authoritative; Queen keeps a DLQ snapshot in sync |
queue:retry / forget / flush / prune-failed |
remove the Queen snapshot after a safe handoff |
| Delivery | at-least-once; a lease expiry or crash can redeliver |
Queue::size() / pendingSize() / reservedSize() |
consumer-group depth from the broker |
queue:clear |
not supported — no atomic clear across ready jobs, live leases and timers |
Retry a Laravel failed job with queue:retry, never with the generic Queen Admin::retryMessage():
only the Artisan command moves the payload attempts and both failure indexes together.
The same package is a plain PHP 8.3 client. No framework, no service container.
use Queen\Queen;
$queen = new Queen('http://localhost:6632');
$queen->queue('orders')->partition('customer-123')->push([
['data' => ['orderId' => 1, 'amount' => 100]],
])->execute();
// Without ->each(), the handler is given the whole claimed batch.
$queen->queue('orders')->group('processors')
->consume(function (array $messages) {
foreach ($messages as $message) {
processOrder($message['data']);
}
})
->execute();Beyond push and pop, the broker gives every client an atomic transaction that bundles the acknowledgement with what it causes — the idempotency idiom for a redelivered job:
$result = $queen->transaction()
->ack($message)
->kv('saga')->putIfAbsent($orderId, ['step' => 'reserved'], [
'ttlSeconds' => 86400,
'required' => true,
])
->queue('payments')->push([['data' => $charge]])
->timers('payments.timeout')->schedule($orderId, 900_000, ['orderId' => $orderId])
->commit();
if (($result['reason'] ?? null) === 'kv_precondition') {
// Somebody already did this one. Nothing was pushed, nothing was acked.
}A lost precondition is the expected outcome of a legitimate redelivery, so commit() returns that
verdict instead of throwing. It belongs in an if, not in a catch, and not in your error metrics.
The rest of the surface — buffered push, multi-partition pop, pop autopilot, conflation, the
KafkaConsumer-style consumer, key/value state, timers, the DLQ, the admin API, tracing and
wildcard consumption — is documented with every option in the
PHP client reference.
Every key is in the published config/queen.php, and
Map the configuration maps Horizon's settings onto them.
Since 2.1.0 the file reads 20 environment variables, the values that differ per environment or
deployment; every other setting is a plain value in the file, and you can add your own env()
where you need one. The
configuration reference
maps each variable that 2.0.0 read to its key. The settings worth knowing on day one:
| Setting | What it controls |
|---|---|
QUEEN_URL / QUEEN_URLS |
one endpoint, or a comma-separated list for failover |
QUEEN_BEARER_TOKEN |
broker authentication |
QUEEN_CONSUMER_GROUP |
the cursor identity; give each application its own |
retry_after |
lease seconds; must exceed the worker timeout |
QUEEN_PARTITIONS |
default fan-out for jobs without QueenPartitionable (64, up to 1024; one worker per stripe at a time) |
sync_failed_jobs |
keep true so Laravel commands clean the Queen DLQ too |
Behind the Queen proxy, HTTP 429 is retried transparently with jitter and a cap; HTTP 403 is
terminal. Both carry a machine-readable ErrorCode on Queen\Exceptions\HttpException.
The package is developed in the Queen monorepo under
clients/client-php and mirrored here on every push to master. Open issues and pull requests
against the monorepo.
composer install
vendor/bin/phpunitApache-2.0. See LICENSE.md.