A move from Horizon changes two things at once: Redis gives way to the Queen broker as the place where jobs wait, and Horizon’s PHP master gives way to a Queen supervisor. Your job classes move untouched. The stored backlog does not move with them: Redis jobs, Horizon’s history, its metric snapshots and its tags are not imported. The steps below give each backend an explicit window of ownership, which is what makes the move safe to start and safe to undo. The pitfalls use the words of how Queen runs Laravel jobs; read that first.
- Check the prerequisites, from the platform and the broker to an inventory of what you use from Horizon.
- Install Queen beside Horizon, with
QUEUE_CONNECTIONstill on Redis. - Map the configuration of
config/queue.phpandconfig/horizon.phpintoconfig/queen.php. - Run a canary, one idempotent job class on Queen, through success, failure, retry, delay, a killed worker and a deploy.
- Roll out by draining and switching, or by sending new jobs to Queen while Horizon drains the old ones.
- Keep a way back until the rollback window closes.
Then read the pitfalls and tick the acceptance checklist.
Check the prerequisites
- PHP 8.3 or newer. The supervisors, lease renewal and prefork need Unix with the
pcntlandposixextensions; native Windows is not supported. The queue connection alone needs neither. - A Queen broker the application can reach. One node is a
docker runaway (quickstart); for production, run a cluster. - A process manager (systemd, Kubernetes, Supervisor) to restart the Queen master if it exits.
- A private state directory for the supervisor, owned by the user that runs it, mode
0700, outside the web root (prepare it). - A cache store whose locks every worker host can see, such as Redis or the database, for the
failed-job lock.
arraylocks hold inside one process andfilelocks inside one host. Your Redis cache can stay. - Idempotent jobs. Both systems deliver at least once (at-least-once delivery).
Before you change a connection, write down what the application uses from Horizon beyond running
workers: job tags and silenced jobs, wait-time notifications, retained throughput and runtime
snapshots, dashboard access and multi-host visibility, horizon:clear, the failed-job workflows in
your runbooks, per-supervisor pause and continue, and any deploy automation that calls
horizon:terminate. Queen covers tags, wait notifications, per-job metrics and multi-host
visibility with its own monitoring. Silenced jobs, job lists,
batches in the dashboard, Slack and SMS routes and per-supervisor controls have no equivalent yet,
so decide where each of those will live before the cutover. Queen or
Horizon compares the two feature by feature.
Install Queen beside Horizon
-
Install the package and publish its configuration.
composer require queen-mq/php-client php artisan vendor:publish --tag=queen-config -
Point the Queen connection at the broker, and keep the default connection on Redis.
QUEUE_CONNECTION=redis QUEEN_URL=http://queen.internal.example:6632 QUEEN_CONSUMER_GROUP=laravel QUEEN_PREFETCH=1 QUEEN_ACK_BATCH=1 QUEEN_FAILED_JOBS_LOCK_STORE=redis QUEEN_SUPERVISOR_STATE_DIRECTORY=/srv/example/queen-supervisor-stateAdd
QUEEN_BEARER_TOKENwhen the broker requires a credential (the install section says which). -
Create the state directory as the user that will run the supervisor.
install -d -m 0700 /srv/example/queen-supervisor-state -
Choose the engine. The PHP engine runs from the package with
php artisan queen:supervise. The Rust engine needs an explicit installation (run the Rust engine):php artisan queen:supervisor-install vendor/bin/queen-supervisor --php php --artisan artisan -
Add the master to the process manager, but start no production pool before the canary passes.
Map the configuration
Queen uses Horizon’s concepts with snake_case names. The algorithms are our own implementations, though, so a pool configured to the same numbers will not make exactly the same decisions.
From config/queue.php
| Redis connection | Queen | Note |
|---|---|---|
QUEUE_CONNECTION=redis |
QUEUE_CONNECTION=queen |
change it at the cutover |
connection (the Redis connection) |
QUEEN_URL or QUEEN_URLS, and QUEEN_BEARER_TOKEN |
the driver talks to the broker over HTTP |
queue |
QUEEN_QUEUE (default default) |
queue names stay the same |
retry_after |
QUEEN_RETRY_AFTER (default 90) |
the lease; read the pitfall before you copy the value |
block_for |
QUEEN_BLOCK_FOR (default 0) |
keep 0 when workers serve a comma-separated priority list |
after_commit |
QUEEN_AFTER_COMMIT |
same meaning |
| none | QUEEN_CONSUMER_GROUP (default laravel) |
one group per application and environment |
| none | QUEEN_PARTITIONS (default 64, up to 1,024) |
the stripes of every queue |
From config/horizon.php
| Horizon | Queen | Note |
|---|---|---|
use |
QUEEN_URL or QUEEN_URLS, and QUEEN_BEARER_TOKEN |
Horizon’s Redis connection becomes the broker’s address |
prefix |
QUEEN_JOB_METRICS_NAMESPACE, QUEEN_TAGS_NAMESPACE, QUEEN_SUPERVISOR_COORDINATION_NAMESPACE, QUEEN_SUPERVISOR_REMOTE_STATUS_NAMESPACE |
only when several applications share a broker; their jobs are kept apart by queue names |
path, domain, middleware |
QUEEN_DASHBOARD_PATH (default queen), QUEEN_DASHBOARD_DOMAIN, dashboard.middleware |
the panel is off until QUEEN_DASHBOARD_ENABLED=true |
Gate viewHorizon |
Gate viewQueenDashboard |
production access is denied until you define it |
waits |
waits |
the same connection:queue => seconds shape; schedule queen:check-waits every minute |
trim.recent, trim.pending, trim.completed |
none | Queen keeps no job lists |
trim.failed, trim.recent_failed |
failed_jobs with queue:prune-failed |
pruning removes the dead-letter entries too |
trim.monitored |
QUEEN_TAGS_RETENTION_MINUTES (default 1440) |
how long a job with a monitored tag stays listed |
silenced, silenced_tags |
none | |
metrics.trim_snapshots and the horizon:snapshot schedule |
job_metrics (QUEEN_JOB_METRICS, on by default) |
every worker records live, kept a day; drop the schedule |
fast_termination |
none | queen:supervisor terminate drains for up to shutdown_grace |
memory_limit |
none | it restarts Horizon’s master; Queen has no such setting |
defaults and environments |
supervisor.supervisors |
one set of pools; vary values per environment with env() |
watch (horizon:listen) |
none | in development, restart the master after a change |
Horizon::routeMailNotificationsTo() |
notifications.mail (QUEEN_NOTIFY_MAIL) |
comma-separated addresses |
Horizon::routeSlackNotificationsTo(), routeSmsNotificationsTo() |
a listener for Queen\Laravel\Events\LongWaitDetected |
send the alert anywhere from the listener |
| Horizon running on several hosts | supervisor.coordination (QUEEN_SUPERVISOR_COORDINATION) |
replicas share the target of every pool |
Supervisor options
| Horizon supervisor | Queen pool | Note |
|---|---|---|
connection |
connection |
change the value from redis to queen |
queue |
queues |
always an array |
balance: auto |
balance: auto |
the total and the split per queue follow the backlog |
balance: simple |
balance: simple |
fixed processes, evenly spread |
balance: false |
balance: off |
the ordered queue list on every worker |
autoScalingStrategy |
strategy |
size or time; Horizon’s published configuration uses time, Queen defaults to size |
processes |
processes |
mostly for simple |
minProcesses |
min_processes_per_queue |
Horizon’s minimum applies per queue; min_processes bounds the whole pool |
maxProcesses |
max_processes |
more workers than stripes on one queue find nothing to lease |
balanceMaxShift |
balance_max_shift |
add fast_scale_up to close half the gap per cycle |
balanceCooldown |
balance_cooldown |
with event_driven, increases come every second |
maxJobs, maxTime |
max_jobs, max_time |
worker recycling |
timeout, tries, memory, sleep, rest, force |
the same names | the same worker settings |
nice |
none | keep OS priority outside Queen |
an array backoff |
none | the supervisor takes one integer |
| none | consumer_group, retry_after |
per pool; default to QUEEN_CONSUMER_GROUP and QUEEN_RETRY_AFTER |
For strict priority such as high,default, use balance=off, prefetch=1 and a non-blocking
queue scan (block_for 0). Auto balancing allocates by measured pressure and ignores queue order.
Run a canary
Keep the application’s default connection on Redis while you prove the Queen path.
-
Send one idempotent job explicitly to Queen.
RebuildSearchIndex::dispatch($tenantId) ->onConnection('queen') ->onQueue('queen-canary'); -
Run one fixed Queen worker.
php artisan queue:work queen --queue=queen-canary --timeout=60 --tries=3 -
Walk the whole lifecycle. Check the side effect and a delayed dispatch. Fail a job on purpose: it must appear in
queue:failedand in the broker’s dead-letter queue, andqueue:retrymust clear both. Kill a worker in the middle of your code and check that the idempotent result is still right after the redelivery. -
Start one Queen master, with the PHP engine or the Rust one, and require
php artisan queen:supervisor status --checkto pass. -
Stop it as a deploy would. Run
php artisan queen:supervisor terminatewhile the longest real job runs, and check that the job either finished or ran again.
Throughput is a poor canary. Check attempts, delay and backoff, timeouts, failed-job sync, the deploy drain and a broker that is briefly unreachable, with your own jobs.
Roll out
There are two safe shapes for the cutover.
Drain, then switch
Use it when a short pause in dispatching is acceptable. It has the simplest ownership boundary.
- Stop or pause the producers, without pausing Horizon’s workers.
- Let Horizon drain every Redis queue in scope.
- Check that the Redis queues are empty and no reserved job remains.
- Terminate Horizon gracefully.
- Deploy
QUEUE_CONNECTION=queenand start the Queen supervisor. - Resume the producers and check Queen’s status, backlog and failed jobs.
Send new jobs to Queen, then drain the old ones
Use it when producers can pick a connection explicitly for a while.
- Deploy code that sends new jobs to
queenwhile the existing Redis jobs stay with Horizon. - Run Horizon and Queen workers side by side, each on its own backend.
- Watch Redis until every old queue and reserved set is empty.
- Run
php artisan horizon:terminateand remove the temporary routing. - Keep Queen as the default connection.
Never let both systems consume the same logical work through a duplicated dispatch. If a temporary double write cannot be avoided, the application needs its own stable idempotency key and a plan to reconcile.
Change the deploy scripts
Replace php artisan horizon:terminate with php artisan queen:supervisor terminate, or send the
master SIGTERM. It drains its workers for up to shutdown_grace seconds and exits, and the process
manager starts a fresh one with the new code. On Kubernetes, keep terminationGracePeriodSeconds
above shutdown_grace, and run one replica with a Recreate strategy unless coordination is on
(Kubernetes).
Roll back without losing the new backlog
A rollback changes where new jobs go. It does not move the jobs Queen already holds back into Redis.
- Stop new Queen dispatches, or route them back to Redis.
- Keep the Queen workers running until their backlog is empty, or keep that backlog on purpose for a later recovery window.
- Restart Horizon before Redis takes dispatches at full rate again.
- Check both failed-job indexes and both backends before you remove Queen’s credentials or state.
Keep the Queen package, the connection configuration and the deployment path until the rollback window closes. Removing the workers first turns a reversible routing decision into stranded work.
Pitfalls
Jobs longer than shutdown_grace
At a deploy the master sends its workers SIGTERM and waits shutdown_grace seconds, 75 by
default, before SIGKILL. A job still running then is stopped, and it runs again once its lease
expires. The startup check compares shutdown_grace only with each pool’s timeout, so a job class
with a longer $timeout of its own gets past it; Horizon has the same exposure through the process
manager’s stop timeout. For each job that can outlive the grace, raise shutdown_grace above its
runtime and terminationGracePeriodSeconds above shutdown_grace, or split it into chunks that
each end within the grace (a job that does one chunk and dispatches the next). The dashboard’s
tuning advice names the classes whose
longest run exceeds the grace.
retry_after with and without lease renewal
On Redis retry_after must exceed the longest job, so a Horizon configuration may carry a large
value. On Queen, pick one of two profiles:
| Without lease renewal (the default) | With lease renewal | |
|---|---|---|
What retry_after sets |
the longest a job may run | how soon the job a crashed worker was running runs again |
| Checked at startup | a pool’s timeout is shorter than retry_after |
the same, and the renewal timing fits inside retry_after |
A job class whose own $timeout exceeds retry_after |
refused, see below | runs, its lease renewed every third of retry_after |
| Prefetch | 1 only | up to 1,000, and pop_ahead |
Laravel’s own timeout still stops a job in both profiles; a job that must run longer than the
pool’s timeout declares a longer $timeout in its class.
Without renewal, the driver refuses a job whose own $timeout is not shorter than retry_after.
The worker reports the error and leaves the job, which comes back after its lease expires and is
refused again: it never runs and never reaches tries. Lower the job’s $timeout, raise
retry_after, or turn renewal on.
A large retry_after also costs more on Queen than on Redis. When a worker dies, its lease holds
the whole stripe, so the jobs behind its job wait up to retry_after seconds, unless its renewer
hands its prefetched batch back at once. With renewal on, keep the default of 90 seconds.
A long job delays its stripe
A job waits for the job ahead of it in its stripe, so a long job on a queue of short ones delays the
short jobs that hashed to its stripe, even while other workers are idle. On Redis that never
happens. Move long jobs to a queue, and a pool, of their own. More workers than stripes on one queue
gain nothing: raise QUEEN_PARTITIONS for a queue that more than 64 workers serve
(queues and stripes).
Failed jobs also live in the broker’s dead-letter queue
A final failure now writes two records, Laravel’s failed_jobs row and the broker’s dead-letter
entry, and keeps them in step (failed jobs).
- Use Laravel’s commands (
queue:failed,queue:retry,queue:forget,queue:prune-failed). They remove the dead-letter entry and the Laravel row together. - Do not delete
failed_jobsrows with SQL. Their dead-letter entries stay, until the queue’s retention passes them if the queue has any. - Do not retry a Laravel job from the dead-letter page of the Queen dashboard. Laravel owns the failed index, the attempt reset and the cleanup.
- With more than one process or host, set
QUEEN_FAILED_JOBS_LOCK_STOREto a cache store whose locks every host can see. - Through the broker’s embedded proxy, deleting a dead-letter entry is queue administration, so the
connection’s API key needs the
adminscope for these commands to work. - A delayed job whose timer the broker cannot deliver at all ends in the dead-letter queue under
the group
__timer__, with nofailed_jobsrow. It is rare; watch that group in the Queen dashboard anyway.
Clearing a queue
php artisan horizon:clear and php artisan queue:clear redis delete the waiting, delayed and
reserved jobs of a Redis queue. On a Queen connection, php artisan queue:clear queen prints
Clearing queues is not supported on [QueenQueue] and deletes nothing: Queen has no atomic clear
across waiting jobs, live leases and Laravel’s timers yet. Update the runbooks that clear a queue.
Let the workers drain it instead, and deploy a change that makes an unwanted job return at once
when it must not do its work.
Horizon’s tags, metrics and notifications
| Horizon | Queen | What to change |
|---|---|---|
Automatic tags and tags() |
the same tags, set at dispatch | nothing; monitor them on the panel’s Tags page |
Monitored tags, kept trim.monitored minutes |
jobs with a monitored tag, listed for QUEEN_TAGS_RETENTION_MINUTES (a day) |
at most 50 monitored tags, and 20 tags of up to 128 bytes per job |
| Silenced jobs and tags | none | no replacement yet |
Metrics from the scheduled horizon:snapshot |
recorded live by every worker into the broker, kept a day | remove the horizon:snapshot schedule |
waits, from Horizon’s estimate |
waits, from the broker’s age of the oldest waiting job |
schedule queen:check-waits every minute |
| Mail, Slack and SMS routes | mail through QUEEN_NOTIFY_MAIL, anything else through a listener |
move listeners of Laravel\Horizon\Events\LongWaitDetected to Queen\Laravel\Events\LongWaitDetected |
The dashboard at /horizon, Gate viewHorizon |
the panel at /queen, Gate viewQueenDashboard |
turn it on with QUEEN_DASHBOARD_ENABLED=true |
| none | a Prometheus endpoint at /queen/metrics |
optional, for KEDA or an HPA: QUEEN_METRICS_ENABLED=true and a QUEEN_METRICS_TOKEN |
Monitoring and alerts covers each of them.
Acceptance checklist
- The canary covered successful, delayed, retried and failed jobs.
- Every job with an external side effect is idempotent.
retry_afteris longer thantimeoutand the lease covers the real runtime, or lease renewal is on.- No job class has its own
$timeoutat or aboveretry_afterwhile lease renewal is off. shutdown_gracecovers the longest job andterminationGracePeriodSecondscoversshutdown_grace, or the long jobs run in chunks.- Long jobs have a queue of their own.
- Prefetch is still 1, or lease renewal was tested with the broker failing.
- The Queen state directory is private, owned by the application’s user, mode
0700. - Exactly one Queen master owns each application and consumer group, or every replica has coordination on.
- The outer process monitor restarts the master after an unexpected exit.
- Who owns the Redis backlog and who owns the Queen backlog is explicit through the cutover and the rollback.
- Runbooks use Laravel’s failed-job commands and no longer rely on
horizon:clear. - Every Horizon tag, metric, notification or operator action the team uses has a replacement.