Skyline

Everything Skyline adds to Laravel Horizon

Each group below starts from the problem somebody on call actually has and then lists what answers it. Everything Horizon already does keeps working as before, and the docs cover each feature down to its config keys.

New in 1.4

A ShouldBeUnique job stops dispatching, and Laravel never mentions it.

While the lock is held, dispatch() returns as though it worked. Nothing is queued, no job is failed, and not one line is written anywhere. With the default uniqueFor of 0 the lock has no expiry, so the work can be gone for days before anyone notices it is missing. We built the Locks & Limits screen because finding a stranded laravel_unique_job: key meant going in with redis-cli, and releasing it safely meant knowing which owner token held it.

Dispatches return, but nothing reaches the queue

The lock is still held. Clearing a queue, deleting the job or running horizon:clear leaves it behind, and a uniqueFor of 0 means it never expires on its own.
On the screen: The lock is listed with how long it has been held, flagged when its job is already gone, and released by a button that checks the owner first.

The job ran twice anyway

Only dispatch() checks uniqueness. A chain, a batch or a direct Queue::push() queues a second copy without taking the lock, which Laravel has never done and Skyline does not change.
On the screen: The held lock beside a skipped-dispatch count that is not moving while copies keep running, which is what tells the two cases apart.

"has been attempted too many times", but handle() never ran

retryUntil() is fixed at dispatch, so a delay or a backlog can use the whole window up before a worker ever picks the job up.
On the screen: RetryWindowExpired in place of that message, saying how late the pickup was, plus the until-processing lock released instead of stranded.

Work quietly disappears under load

A rate limiter or ThrottlesExceptions released the job until its tries ran out, and a release looks like any other release to Horizon.
On the screen: Releases counted per limiter with the reason attached, once you import the middleware from Laravel\Horizon\Middleware.
without Skyline
$ redis-cli --scan --pattern 'laravel_unique_job:*'
laravel_unique_job:App\Jobs\ImportFeed:42

$ redis-cli ttl 'laravel_unique_job:App\Jobs\ImportFeed:42'
(integer) -1
The Locks & Limits screen listing held ShouldBeUnique locks, each with how long it has been held, when it expires, the job holding it and how many dispatches it has skipped, beside a Release button.
Every held lock, the dispatches it skipped, and a Release button.

The same screen is on the live dashboard, running real jobs with no signup.

The rest of what a bad queue day needs.

Unique jobs

Seeing the stuck lock is half of it. You still have to clear it.

Forcing a lock open with Cache::lock($key)->forceRelease() is how this usually gets fixed, and it is the wrong tool: it frees whichever lock is under that key, including one a newer dispatch has just taken. These are the mechanisms behind the screen above, and the reason the release is safe to hand to whoever is on call (laravel/framework #36927 and #47761, Horizon #1678).

How Skyline handles unique locks →

Locks & Limits screen

Lists the unique locks your dispatches hold, each with its job, how long it has been held, when it expires and how many dispatches it has skipped. A lock whose job already completed, failed or disappeared is flagged, and Release frees it after checking it still has the owner the page showed. If another dispatch took the lock in the meantime, the release is refused.

Skipped dispatches counted per job

Every dispatch discarded over a held lock is counted for the past hour and the past day, and the 50 most recent are listed with their lock key. A job that has stopped running without an error shows up as a skip count that keeps climbing.

Stranded until-processing locks released

Laravel releases a ShouldBeUniqueUntilProcessing lock just before handle() runs and skips the release when the job fails, so a job refused on pickup keeps it. A delayed job picked up after its retryUntil() deadline is the usual case. Skyline releases the lock if the failed job still owns it, logs that it did, and replaces "has been attempted too many times" with a message saying the job never ran.

Locks released on delete

Deleting a job, emptying a queue and running horizon:clear each release the ShouldBeUnique lock of every job they remove. The release goes through the framework's own unique-lock code, so a job's uniqueVia() store is respected.

storage/logs/queue.log
[unique-job:42] dispatch skipped — a ShouldBeUnique lock is already held; the job was not queued.
[job:8813] failed before it started processing, which Laravel's unique-lock cleanup does not cover for ShouldBeUniqueUntilProcessing jobs; Horizon released its unique lock so the job can be dispatched again (reason=unique_lock_stranded).
The Locks & Limits screen listing held ShouldBeUnique locks with their age, expiry, queued job and skipped-dispatch count.
Held unique locks, and what each one has skipped.
Skipped dispatches and rate limiter release counts for the past hour and day.
Skipped dispatches and rate limiter releases.

Debugging

A job stopped running and nothing says why.

A queued job passes through half a dozen states and Laravel logs almost none of them. The expensive failures raise no exception of their own: a job that ran twice because its reservation expired, or one a rate limiter kept releasing until it ran out of tries.

Job lifecycle logging →

Job lifecycle logging

Set log_channel and Skyline writes a line each time a job is queued, reserved, released, retried, timed out, completed or failed. Every line carries the job id, so grepping for it gives you that job's whole history. Release lines include the reason, and a dispatch dropped by a unique lock or by WithoutOverlapping is logged as a warning.

Previous Attempts panel

If a job throws on its first attempt and times out on its third, Horizon shows only the timeout. Skyline keeps the exception and stack trace from earlier attempts and lists them on the job's page. It keeps the most recent one by default; attempt_exceptions raises the limit.

Expired reservations logged

When a job is still running as its retry_after window runs out, Redis hands it to a second worker and it runs twice. Skyline logs each job whose reservation expired, and php artisan horizon warns at startup about any supervisor whose timeout is not below its connection's retry_after.

Rate limiter and throttle releases named

Import RateLimited or ThrottlesExceptions from Laravel\Horizon\Middleware instead of the framework and every release carries its reason, counted per limiter on the Locks & Limits screen. The exception ThrottlesExceptions caught, which Laravel does not keep, is shown under Previous Attempts.

storage/logs/queue.log
[job:8813] queued onto [default] (delayed 30s)
[job:8813] reserved from [default] and started processing.
[job:8813] released back to [default] (reason=rate_limited, delay=15s)
[job:8813] was still reserved on [default] when its reservation expired (retry_after=90s) and has been put back on the queue.
[job:8813] failed on [default] and will not be retried (RuntimeException).

Metrics & insights

Workers keep hitting their memory limit and nothing says which job is doing it.

Horizon counts how many jobs ran and how long they took. It measures nothing about what they cost, so a worker replaced over its memory limit every ten minutes looks exactly like a healthy one.

What insights measure, and what they do not →

Metrics charts across all queues

Horizon draws a sparkline per queue and per job class, which at ten queues and forty classes is fifty charts. Skyline plots one line across all queues, adds workload, wait and failure trends for the last 24 hours, and puts a sortable per-queue and per-job table underneath.

Memory and CPU per job class

Set HORIZON_INSIGHTS=true and the metrics table gains peak memory, heap growth and CPU time for every job class. They are measured around each job inside the worker, at about two microseconds a job and no extra Redis calls. A class whose CPU time sits far below its runtime is waiting on a database or an API, and more workers will help it.

Worker fleet memory and CPU

Every ten seconds each supervisor reads the resident memory and CPU time of its own worker processes. The dashboard charts the total across every machine over the last 24 hours, idle workers and framework boot included, beside the workload chart.

Worker restarts recorded with a reason

Each worker that dies and is replaced is recorded against its supervisor and queue with why it stopped: memory, timed_out, max_jobs, max_time or crashed. A deploy does not count. A climbing memory line next to a class with heavy heap growth names the job responsible.

Prometheus endpoint and Grafana dashboard

Turn on the endpoint and every number on the dashboard is available to Prometheus, labelled by queue and job class, which means you can alert on it. vendor:publish --tag=horizon-grafana writes a 27-panel Grafana dashboard that reads from it.

Throughput and runtime charts aggregated across all queues.
Throughput and runtime, aggregated.
The Breakdown by Job table on the metrics screen, listing each job class with its peak memory, CPU time and heap growth.
Peak memory, CPU and heap growth per job class.

Queue contents

A queue is 40,000 jobs deep and you can't see which jobs.

Horizon gives you a count per queue. The jobs behind that count sit in one pending list covering every queue, fifty to a page, and the only filter is a tag you registered before the trouble started.

Queue drill-down and search →

Job list per queue

Click a queue on the dashboard to list the jobs waiting in it, in order, with the arguments each was dispatched with. Open one to see its full payload.

Search by class name or argument value

"podcast" finds every ProcessPodcast. "checkout_id: 3" finds the jobs dispatched with a checkout id of exactly 3, and leaves checkout 30 out. Arguments are recorded when a job is pushed, so jobs queued before you installed Skyline match on class name only.

Separate Scheduled and Retries tabs

Horizon puts both in one delayed list, where a queue that keeps failing and backing off looks the same as a queue with scheduled work in it. Skyline splits them on attempt count. A delayed job that has never run is scheduled; one released after a failure is a retry.

Read-only MCP server

Install laravel/mcp and an AI agent can ask why the emails queue is backed up, or find a failed job by its arguments and read the exception, from the same data the dashboard reads. It can look and cannot touch: no tool deletes, retries or pauses anything.

The jobs waiting inside the slow queue, each with the argument it was dispatched with, above a search box.
The jobs inside one queue, with their arguments.

Dashboard controls

Acting on what the dashboard shows still means SSH and redis-cli.

In Horizon, stopping one queue means running horizon:pause-supervisor on the box, which stops every queue that supervisor serves. Removing a job means typing Redis commands by hand.

Pause a single queue

Pause one queue from the dashboard and the others keep processing. Its jobs stay queued until you resume it. We use it when a downstream API is down and only one queue calls it.

Perform Now for delayed jobs

Scheduled and retrying jobs get a Perform Now button, on the list and on the job's page. The job runs straight away instead of waiting out its backoff, which is what you want once the fix is deployed.

Delete a job

Pending, scheduled and retrying jobs have a Delete button on their row and on their detail page, behind a confirmation. Once a worker has reserved a job the delete is refused with a 409, so nothing is left half-run. Completed and failed jobs have no button, because they are records rather than queue entries.

Empty a queue

Empty clears the ready list and the delayed set. It deletes in batches, so clearing a large queue doesn't block Redis while it runs.

The Skyline dashboard's Current Workload table, with Pause and Empty buttons on the fast and slow queues.
Pause and Empty, per queue, in Current Workload.
Scheduled jobs listed with a Perform Now control on each row.
Perform Now on a scheduled job.

Queue priority

With strict priority, a busy high queue starves every queue after it.

With balance => false a worker only reaches a queue once every queue to its left is empty. If high always has work waiting, low is never checked.

How the weighted draw works →

Weighted queues

Give each queue a weight in queueWeights and, on every poll, the worker picks the order it checks them in at random, in proportion to the weights. At 3:2:1 high goes first about half the time, and low is still checked on every poll.

Front-of-queue dispatch

Add the InteractsWithFrontOfQueue trait to a job and call onFront() when you dispatch it. That job goes to the head of its queue and is the next one a worker takes, without setting up a separate high-priority queue.

config/horizon.php
'supervisor-1' => [
    'connection' => 'redis',
    'queue'      => ['high', 'default', 'low'],
    'balance'    => false,
    'queueWeights' => [
        'high'    => 3,
        'default' => 2,
        // 'low' omitted, so it keeps a weight of 1
    ],
],

Give a queue more attention without starving the rest.

With balance => false, a worker drains high completely before it looks at low. That is fine until high is never empty, at which point everything under it starves.

A queueWeights map replaces that with a proportional draw. On every poll the worker builds a fresh ordering, picking queues without replacement with probability proportional to their weight.

high
×3 · 50%
default
×2 · 33%
low
×1 · 17%

How often each queue is checked first. Applies when balance => false.

When Horizon on its own is fine.

We would rather you did not buy this than bought it and found it changed nothing. Two things make Skyline pointless:

  • One or two queues that never back up, and no ShouldBeUnique or rate-limited jobs. Weighted queues and a view of held locks solve problems you do not have.

  • You need to know how deep a queue is, never what is inside it. Horizon's dashboard already answers that.

If both hold, Horizon on its own is the right call. The day one of them stops being true, switching is a single composer require.

How the features behave

Why did my Laravel job fail with "has been attempted too many times" without ever running?

The worker checks retryUntil() and the attempt count when it picks a job up, before handle() runs, so a job can fail without executing. Either the retryUntil() deadline passed while the job sat in the queue, because it is fixed at dispatch rather than at pickup, or middleware such as WithoutOverlapping or RateLimited released the job until its tries ran out. Skyline reports the first case as RetryWindowExpired, saying the job never ran and how late the pickup was, and keeps each middleware release reason under Previous Attempts so the second case is visible too.

Does ShouldBeUnique work with job chains, batches and queue:retry?

Not in Laravel on its own, and Skyline does not change that. Only dispatch() takes the unique lock, so the first job of a chain, batched jobs, Queue::push() and queue:retry all queue a unique job without it, and a later dispatch is not blocked by them. What Skyline gives you is a way to see it: the Locks & Limits screen shows every lock a dispatch holds and how many dispatches it has skipped, so duplicates coming in through those paths are findable instead of invisible.

How is Skyline different from Laravel Nightwatch or an APM?

Skyline is queue control, not telemetry. An APM tells you something is wrong. Skyline is the layer that lets you do something about it: pause the queue, run the retry now, drain the backlog. Pair it with whatever you already use for observability. Nothing overlaps and nothing has to be replaced.

What are weighted queues?

In a supervisor running with balance => false, workers serve queues in strict left-to-right priority: the first queue is drained completely before the second is looked at. That works until the first queue is never empty, at which point everything below it starves. A queueWeights map replaces that with a proportional draw. On every poll the worker builds a fresh ordering, drawing queues without replacement with probability proportional to their weight, so at 3:2:1 the high queue is checked first about 50% of the time, default about 33% and low about 17%. Queues without an entry default to a weight of 1.

Does Skyline work with non-Redis queue drivers?

Skyline monitors Redis queues, just like Horizon. Features that rely on Redis list semantics do not apply to the sync or database drivers.

All of it replaces one Composer package.

No code changes and no migration, and the price is the same for every app you run it on.

Buy Skyline for $99

Secure checkout by Anystack. 30 days to change your mind.