# Changelog

> Every released version, with its date and what changed in it.

Source: https://boring-observability.dev/skyline/docs/changelog
Section: Reference — Skyline for Laravel documentation
Updated: 2026-10-03

---

Every released version of Skyline, newest first, with what changed in each. Releases are published to the private Composer registry at `laravel-skyline.composer.sh`; `composer update boring-o11y/laravel-skyline` moves you to the newest release your version constraint allows.

Skyline follows semantic versioning within the `1.x` line: patch releases are fixes and additive features that cost you nothing to take, and anything that changes existing behaviour is called out under **Upgrade notes** on the release that carries it. There have been five such changes so far — the four new alert checks in [1.5.1](#v1-5-1), the stranded unique-lock release in [1.4.0](#v1-4-0), the `worker_output` removal in [1.3.4](#v1-3-4), the Prometheus label rename in [1.3.2](#v1-3-2) and the per-queue pause rework in [1.1.1](#v1-1-1).

## All releases

| Version | Date | Headline |
| --- | --- | --- |
| [**1.5.1**](#v1-5-1) | 3 Oct 2026 | Four more alerts, and per-queue thresholds |
| [**1.5.0**](#v1-5-0) | 27 Sep 2026 | Alerts, and overlap locks on Locks & Limits |
| [**1.4.1**](#v1-4-1) | 22 Sep 2026 | What a single run cost, and failed jobs on the Jobs screen |
| [**1.4.0**](#v1-4-0) | 17 Sep 2026 | Locks & Limits, insights, and an MCP server |
| [**1.3.5**](#v1-3-5) | 11 Sep 2026 | The dashboard works on a phone |
| [**1.3.4**](#v1-3-4) | 8 Sep 2026 | Skyline branding, and a per-queue max wait |
| [**1.3.3**](#v1-3-3) | 5 Sep 2026 | Job arguments, and search across them |
| [**1.3.2**](#v1-3-2) | 31 Aug 2026 | Prometheus label rework for balanced queue pools |
| [**1.3.1**](#v1-3-1) | 25 Aug 2026 | Stopped jobs fail immediately |
| [**1.3.0**](#v1-3-0) | 5 Aug 2026 | Prometheus endpoint and Grafana dashboard |
| [**1.2.5**](#v1-2-5) | 4 Aug 2026 | Stop a running job from the dashboard |
| [**1.2.4.1**](#v1-2-4-1) | 29 Jul 2026 | Pin the replaced Horizon version |
| [**1.2.4**](#v1-2-4) | 20 Jul 2026 | Bulk job actions and the Reserved tab |
| [**1.2.3**](#v1-2-3) | 6 Jul 2026 | Lifecycle logging covers the whole lifecycle |
| [**1.2.2**](#v1-2-2) | 4 Jul 2026 | JSON worker output |
| [**1.2.1**](#v1-2-1) | 29 Jun 2026 | Fix per-queue metric snapshot reads |
| [**1.2.0**](#v1-2-0) | 27 Jun 2026 | Previous-attempt failures and lifecycle logging |
| [**1.1.3**](#v1-1-3) | 8 Jun 2026 | Atomic metric snapshots |
| [**1.1.2**](#v1-1-2) | 2 Jun 2026 | Chart rendering no longer freezes under load |
| [**1.1.1**](#v1-1-1) | 2 Jun 2026 | Trends, weighted queues, front-of-queue dispatch |
| [**1.1.0**](#v1-1-0) | 28 May 2026 | Redis Cluster, delete a job, empty a queue |
| [**1.0**](#v1-0) | 27 May 2026 | First release |

## 1.5.1 — 3 October 2026

**Added — four more alert checks.** These cover jobs that go wrong without failing, which is why the failure rate never saw them. `reservation_expired` fires when a job was still reserved after its `retry_after` ran out, so it will run a second time. It reports once per queue with a count, and says whether the job's timeout or the worker dying was the cause. `job_timeout` fires when one job class times out five times in ten minutes. `limiter_drop` fires when Horizon's `RateLimited` or `WithoutOverlapping` middleware dropped ten jobs in an hour without running them, or `ThrottlesExceptions` deleted them with `deleteWhen()`. A dropped job is marked completed, so nothing else notices. `memory_restarts` fires when workers keep being recycled for their memory limit, and names the job classes that held on to the most heap. That makes sixteen built-in checks. See [Alerts](https://boring-observability.dev/skyline/docs/alerts).

**Added — per-queue and per-class thresholds.** A scheduled command that drops ten thousand jobs on a bulk queue used to set off `queue_not_draining`, although a backlog that takes hours is normal there. `queue_not_draining` and `failure_rate` now take a `queues` map, and `failure_rate` also takes a `jobs` map for when it counts per class. `job_timeout` takes `jobs` and `limiter_drop` takes `groups`. A zero turns the check off for that one queue, class or limiter. `horizon:alerts` warns about an entry that names no running queue.

**Added — timeout counters in Prometheus.** Timeouts are now counted per job class and per queue, and exported as `horizon_job_timed_out_total` and `horizon_queue_timed_out_total`.

**Added — Laravel 13.34.** `horizon:forget --queue=NAME` deletes one queue's failed jobs, from Horizon and from the failed job table, to match the framework's new `queue:flush --queue`. The `#[CountCrashesAsExceptions]` attribute is hidden from a job's arguments. On 13.34, the `worker_crash_loop` and `reservation_expired` alerts suggest it when a job may be what is killing workers, and `job.reservation_expired` says when the lost attempt counts towards the job's `maxExceptions`. Laravel 13.34 also calls `interrupted()` on a job that times out. Skyline now logs that as a timeout only, not also as `job.interrupted`.

**Fixed — the retry of a timed out job reported as an expired reservation.** A timeout kills the worker and leaves the job reserved, so its retry always comes back through the reservation queue. Each retry was logged as a `job.reservation_expired` warning, after the timeout had already been reported. It is now an info `job.migrated` line with `reason=timeout`, as long as the timeout was below `retry_after`. A timeout at or above it can still run the job twice, so it is still reported.

**Fixed — flat resource charts on the per-job and per-queue metrics screens.** The Peak Memory, CPU and Heap Growth charts on `/metrics/jobs/{class}` and `/metrics/queues/{name}` drew a line at zero, while the table on the listing screen showed the real figures. They now chart the recorded data.

> **Upgrade notes**
>
> If alerts are on, the four new checks are on too. `job_timeout`, `limiter_drop` and `memory_restarts` send warnings, and `reservation_expired` sends a critical alert. Set a check's `enabled` to `false` under `horizon.alerts.checks` to keep the 1.5.0 behaviour. `memory_restarts` needs `HORIZON_INSIGHTS=true` and stays quiet without it.
>
> If you count `job.reservation_expired` lines, expect fewer: retries after a timeout are now logged as `job.migrated`. `RecordJobTimeout`'s constructor takes a `JobRepository` as well, which only matters if you build it with `new`.

## 1.5.0 — 27 September 2026

**Added — alerts.** Horizon sends one notification, for a long queue wait, and never says when the wait ended. Skyline now has an alert pipeline with twelve checks: Horizon not running at all, a queue waiting too long, stalled, falling behind or left paused, jobs failing in bulk per queue or per job class, workers crash-looping, a unique or overlap lock blocking every dispatch of its job, a supervisor `timeout` that will run jobs twice, supervisors and the master running out of memory, and workers that would not launch. Each condition has to hold for its window before anyone hears about it, repeats at most every 15 minutes, and sends a recovery message with how long it lasted. Notifications are held back for five minutes after a deploy while the checks keep counting. Every alert carries a severity (`critical`, `warning` or `info`) that opens its subject line and can be routed to its own channels, so only a critical alert sends the SMS. Delivery is mail, Slack, SMS and a new JSON webhook for PagerDuty, Opsgenie or your own service. `horizon_down` runs from a scheduled `horizon:check`, outside the fleet it watches. `horizon:alert:test` sends a test down one route and `horizon:alerts` shows what is firing, with `--history` and `--clear`. You can register checks of your own with `Horizon::alertCheck()`. It is all off until `HORIZON_ALERTS=true`. See [Alerts](https://boring-observability.dev/skyline/docs/alerts).

**Added — `WithoutOverlapping` locks on Locks & Limits.** A worker killed while a job runs under `WithoutOverlapping` never reaches the framework's `finally`, and without `expireAfter()` that overlap lock has no expiry, so every later instance of the job is released or dropped for good. Skyline's drop-in middleware now indexes the lock while the job runs, lists it on the Held Locks card with an Overlap badge, marks it stranded once its job is no longer running, and lets you release it with the same owner check unique locks get. The framework's own middleware is still invisible; import it from `Laravel\Horizon\Middleware`.

**Added — pause and resume Horizon from the dashboard.** The Status tile has a Pause/Resume button. It sends the pause through each master's Redis command queue rather than a signal, so it reaches every host, where `horizon:pause` only reaches the one it runs on. Resume also lifts `queue:pause --all`, so "Active" means workers are taking jobs. Queues you paused one at a time stay paused.

**Added — the global queue pause is visible.** Laravel 13.25's `queue:pause --all` made every queue on the dashboard show as paused with no sign of why, and each queue's Resume button did nothing. The dashboard now shows an "All queues are paused" banner with a Resume All button, and pausing and resuming the switch is logged as `queues.paused` and `queues.resumed`.

**Added — debounced jobs.** A job superseded by a newer `#[DebounceFor]` dispatch is deleted without running, and Skyline used to record that as a completion. It is now logged as `job.debounced`, a dispatch the max wait forced through as `job.debounce_max_wait_reached`, and a Debounced Jobs card on Locks & Limits counts dispatched, superseded and max-wait jobs for the past hour and day. Needs Laravel 13.6.

**Added — newer Laravel queue events.** On Laravel 13.25 and later, skipped unique dispatches are read from the framework's `UniqueJobSkipped` event, which also covers locks on a `uniqueVia()` store. A job told to stop during a deploy (`JobInterrupted`, 13.7) is logged as `job.interrupted` and its release shows as "Released after the worker was told to stop". The restart chart's tooltip shows the memory a worker exited with and how many jobs it ran (13.18). A push the `failover` driver sent away from a Horizon connection is logged as `queue.failed_over`, since the job otherwise never appears in Horizon.

**Fixed — timeouts that leave the worker running.** With Laravel 13.33's `Worker::$killOnTimeout = false` a timed-out attempt was logged twice, promised a recovery that never happened, and lost its Timed Out badge. It is now logged once, stored as a timeout with the trace of where the job was, and its release is attributed to the timeout. A worker that exits with the application's own timeout exit code is recorded as `timed_out` rather than `crashed`.

> **Upgrade notes**
>
> Nothing changes until you set `HORIZON_ALERTS=true`. A published `config/horizon.php` without an `alerts` block keeps working with the defaults; copy the block from the package's own config file when you want to tune a check. Once alerts are on, Horizon's own long wait notification stands down in favour of the `queue_wait` check.
>
> A debounced job that was superseded no longer writes a `job.completed` line. If you count those lines, expect the number to drop on queues that use `#[DebounceFor]`.

## 1.4.1 — 22 September 2026

**Added — what a single run cost, on the job's page.** The insights in 1.4.0 measure peak memory, heap growth and CPU per job class and per queue, which tells you which class is expensive but not what the one run that fell over actually used. The same three figures are now stored on the job as it completes and shown on its page. They ride in the write that already records the completion, so there is no extra Redis call, and they expire with the job record. CPU is shown in milliseconds below a second. Failed jobs are not measured, as in the aggregates, and a job that was not measured shows nothing rather than a zero. Still off until `HORIZON_INSIGHTS=true`.

**Changed — failed jobs live on the Jobs screen.** The standalone Failed Jobs screen duplicated the Failed tab the Jobs screen already had, so the sidebar entry now opens `/jobs/failed` and the old screen is gone. The tab gains the one thing only the old screen had: search by tag. A failed job's page moves to `/jobs/failed/{id}`. `/failed` and `/failed/{id}` redirect, so bookmarks keep working.

**Changed — built on Horizon 5.50.** Skyline now replaces `laravel/horizon` 5.50.0, up from 5.49.0, which includes upstream's logarithmic auto-scaling strategy.

**Fixed — tag search on failed jobs.** Paging through a tag repeated a row at the top of every page, and the Next button could disappear partway through when an older job had expired before its tag entry. On a single queue's failed tab, a tag search returned failures from every queue. All three are fixed.

**Fixed — queue-owned properties shown as job arguments.** `uniqueFor`, `debounceFor`, `debounceOwner` and `uniqueLockOwner` are set by the queue, not by the job's constructor, but the job page listed them as arguments and search indexed them. They are now hidden with `tries`, `delay` and `batchId`. `uniqueId` and `debounceId` stay visible, because you choose those values and searching for them is useful.

**Fixed — the newer screens on a phone.** Locks & Limits, the metrics breakdown and the charts arrived after the 1.3.5 responsive pass. On a phone the Locks & Limits tables pushed the Release button off screen, and the charts were too short to read. The tables now fit the screen without scrolling, and charts get a taller aspect ratio and fewer axis labels below 768px.

## 1.4.0 — 17 September 2026

**Added — a Locks & Limits screen.** Unique locks and rate-limiting middleware are where queues fail most quietly: a dispatch skipped over a held lock leaves no trace, a lock with no TTL outlives a killed worker forever, and `RateLimited` and `ThrottlesExceptions` release jobs with a plain `release()` that Horizon cannot attribute. The new screen at `/horizon/locks` lists the held unique locks — flagging the stranded ones, with an owner-checked Release button — the dispatches each lock has skipped, and the jobs Horizon's middleware released or dropped. Drop-in `RateLimited`, `RateLimitedWithRedis`, `ThrottlesExceptions` and `ThrottlesExceptionsWithRedis` attribute their own releases and drops, delegating to the framework rather than reimplementing it. Storage is bounded and best-effort, and `lock_insights` turns it off. See [Locks & Limits](https://boring-observability.dev/skyline/docs/locks-and-limits).

**Added — the queue failures Laravel handles silently.** Three framework behaviours leave a job dropped, duplicated or blocked without saying so, and each is now reported. `retryUntil()` is fixed at dispatch, so a delay that carries a job past its own deadline is logged when the job is dispatched, and a job refused on pickup is recorded as `RetryWindowExpired` rather than the misleading "has been attempted too many times". A job migrated back from the reserved set — meaning its worker died or it outlived `retry_after`, and it will run twice — is now a warning on every Laravel version, whatever the job's own `$timeout`, and `php artisan horizon` warns at startup about any supervisor whose timeout is not below its connection's `retry_after`. And because `CallQueuedHandler::failed()` never releases a `ShouldBeUniqueUntilProcessing` lock, a job that fails before it starts used to strand that lock and silently skip every later dispatch; the dispatch that takes a lock now records its owner in the payload, and Skyline releases the lock only while that owner still holds it.

**Added — insights: what each job costs, and why workers are being replaced.** Per job class and per queue, workers now record the peak memory a run needed, the heap it left behind and the CPU it burned. A worker runs one job at a time and Horizon already brackets each one with events inside that process, so this is a delta around a single run — no process scraping and no extra Redis round trips, at roughly two microseconds a job. The three do not share an aggregation on purpose: peak keeps the window maximum, because averaging peaks would smooth away the one heavy run that causes a restart; growth is summed so a frequent small leak outranks a rare large one; CPU is a moving average, which is what keeps `cpu / runtime` meaningful. Worker restarts are tracked per supervisor, queue group and reason, with the reason coming from the worker itself and a worker that never got to say recorded as `crashed`. Supervisors and masters also persist when they started, so uptime now shows on the dashboard, on `horizon:supervisors` and in the export. It is all off until `HORIZON_INSIGHTS=true`.

**Added — worker memory and CPU across the fleet.** Job cost measures each job from inside the worker; this measures the fleet from outside. Every ten seconds each supervisor reads the resident memory and CPU time of its own worker processes, from `/proc` on Linux or `ps` elsewhere, and the dashboard charts the average total memory held and the average number of cores in use over the same 24 hours as the workload chart. It catches what the per-job figures leave out — idle workers, framework boot, and workers still finishing a job after being scaled down. A bucket nothing was sampled in is drawn as a gap rather than as a fleet using nothing. Also behind the insights flag. See [Insights](https://boring-observability.dev/skyline/docs/insights).

**Added — a read-only MCP server for AI agents.** With [`laravel/mcp`](https://github.com/laravel/mcp) installed, Skyline registers eight tools — `queue-overview`, `list-supervisors`, `list-jobs`, `get-job`, `get-metrics`, `get-trends`, `list-batches` and `get-batch` — so an agent can answer "why is the `emails` queue backed up?" from the same data the dashboard reads. The local stdio server runs through `php artisan mcp:start skyline`; the HTTP endpoint at `{horizon.path}/mcp` is opt-in and sits behind your token middleware and the `viewHorizon` gate, in the same order as the dashboard. No tool retries, stops, deletes or pauses anything, and job payloads are never exposed — agents see the stored, masked arguments and an allowlist of payload metadata. `laravel/mcp` is suggested rather than required, because it needs a newer PHP and Laravel than Skyline itself supports. See [MCP server](https://boring-observability.dev/skyline/docs/mcp-server).

**Fixed — a delayed job's next run was derived from the wrong thing.** The dashboard unserialized the job payload and read its `delay` property, which arrives in three shapes where the code understood two. A `DateInterval` survives serialization as its separate parts rather than as a moment, so `->delay(CarbonInterval::minutes(10))` came out as a delay of zero and showed a next run equal to the time the job was pushed. None of it needed deriving: the repository already stamps `available_at` from a delay the queue itself parsed, so the derivation is gone and every view reads that field through one helper. The two "Delayed" badges are fixed with it — they tested for upstream's `pending` status, which this fork replaced with a distinct `delayed` one, so the badge never appeared on a delayed job and did appear on a job already migrated back to pending. Jobs delayed before `available_at` existed show no next run rather than a wrong one, and age out within the delayed job TTL.

**Fixed — job class names were unreadable in wide tables on phones.** Below the `md` breakpoint every table cell could break anywhere, which let the name column of a many-column table collapse to a single character and render job classes vertically. Name cells now keep a minimum width, the metrics table shows the class base name, and the Locks & Limits tables hide their secondary columns on phones and restate them under the name.

> **Upgrade notes**
>
> **Stranded unique locks are now released.** `release_stranded_unique_locks` defaults to `true`, so a `ShouldBeUniqueUntilProcessing` job that fails before it starts no longer leaves its lock behind. The release is an owner-checked compare-and-delete — it frees the lock only while the failed job's own dispatch still holds it — so it cannot free a lock a newer dispatch has taken. If you were relying on that lock surviving a failure, set `HORIZON_RELEASE_STRANDED_UNIQUE_LOCKS=false`; the stranded lock is logged either way.
>
> **Two new defaults write to Redis.** `lock_insights` is on, and costs one write per unique dispatch and per lock release on Horizon's own Redis connection. `insights` is off, and stays off until you set `HORIZON_INSIGHTS=true`. Neither needs a migration, and turning either off later leaves what was recorded in Redis until `php artisan horizon:clear-metrics`.
>
> **The MCP server registers itself.** If your application already requires `laravel/mcp`, the local stdio server appears without any configuration — `HORIZON_MCP_ENABLED=false` registers nothing. The HTTP endpoint stays off until you turn it on.

## 1.3.5 — 11 September 2026

**Changed — the dashboard is responsive.** It used to be locked to a desktop layout, with a `min-width` of 1140px on the page and Bootstrap's grid breakpoints overridden so the columns never reflowed. On a phone you got a shrunken desktop page, or one you had to scroll sideways. Both locks are gone and the standard breakpoints are back.

**Added — a collapsible navigation menu.** Below 992px the sidebar folds behind a menu button in the header and closes again when you pick a screen. While it is closed, its links are out of the tab order, and the button reports its state through `aria-expanded`.

**Changed — tables fit narrow screens.** The dashboard's stat tiles sit two across on small screens and four across from 992px up. On narrow screens, job listings hide their timestamp columns and show the times under the job name. Long class names and tags wrap instead of widening the table. The action column is never hidden, so Retry, Stop, Perform Now, Empty and the row checkboxes are all reachable on a phone without scrolling sideways.

Nothing changes at desktop widths. The layout from 992px up is the same as in 1.3.4.

## 1.3.4 — 8 September 2026

**Changed — the dashboard is branded as Skyline.** The header reads **Skyline** followed by the installed version, with the Skyline mark and favicon in place of Horizon's, and names the upstream release it is built on beside it — *running on Laravel Horizon v5.49.0*. Both numbers are read at runtime rather than written down: the Skyline version comes from the release Composer actually resolved, and the Horizon one from the `replace` entry in the package's own manifest, which is the thing satisfying your `laravel/horizon` requirement.

**Fixed — the dashboard's max wait time named a pool rather than a queue.** The figure came from the wait-time calculator's per-supervisor view, which reports a balanced supervisor covering `high` and `default` as a single `high,default` key carrying the worst wait of the group. The dashboard labels that number with a queue name, so on any supervisor serving more than one queue the label named the pool instead of the queue actually holding the oldest job. The dashboard now reads a per-queue view in which every key names one concrete queue, and a queue served by two pools keeps its worst wait rather than appearing twice. The long-wait notifications are unchanged — they are configured per pool, which is the right unit for them.

**Changed — built on Horizon 5.49.** Skyline now replaces `laravel/horizon` 5.49.0, up from 5.48.3.

> **Upgrade notes**
>
> **Removed — `worker_output`.** Horizon 5.49 ships a `--json` flag on `horizon:work` and `horizon:supervisor`, so structured worker output is no longer Skyline's to configure and the `worker_output` key added in [1.2.2](#v1-2-2) is gone along with `HORIZON_WORKER_OUTPUT`.
>
> If you were running with `worker_output=json`, set `'json' => true` on the supervisor in `config/horizon.php` instead — the master threads it down as `--json` to both the supervisor and the worker command, so the output is the same one-object-per-line stream. It is now a per-supervisor setting rather than a global one, which means one pool can emit JSON while another stays on the CLI table. A leftover `worker_output` key is ignored rather than an error, so nothing breaks on the way past — the workers simply fall back to CLI output until you move the setting.

## 1.3.3 — 5 September 2026

**Added — job arguments.** Every job listing now shows the constructor arguments the job was dispatched with underneath the job name, and the job's detail page gains an **Arguments** panel with the full set. Arguments are read from the serialized command with the queue's own bookkeeping properties (`tries`, `delay`, `backoff`, `chained`, `batchId` and friends) stripped out, so only what your application passed in is left. A job holding an Eloquent model is stored as the model class and its key, and a queued mailable, notification, listener or broadcast event shows the arguments of the object it wraps rather than the framework wrapper.

**Added — search over arguments.** The search box on every job listing now matches the job class name *and* the stored arguments, where it previously matched the class name alone. A phrase is a list of terms that must all hold:

| Search | Matches |
| --- | --- |
| `SendInvoice` | jobs whose class name, argument names, or argument values contain "SendInvoice" |
| `checkout_id: 3` | jobs with an argument named `checkoutId` / `checkout_id` / `checkout.id` whose value is exactly `3` |
| `email: acme` | jobs with an `email` argument containing "acme" — a non-numeric value matches as a substring |
| `name: "Ada Lovelace"` | quote a value containing spaces |
| `SendInvoice checkout_id: 3` | both must hold |

Argument names match loosely on case and separators, and a nested value can be named by any trailing run of its path — `id`, `checkout_id` and `order.checkout.id` all name `order.checkout.id`. Numeric values must match exactly, so `checkout_id: 3` never returns checkout 30, and integer ids beyond 253 still compare digit for digit.

**Added — the `job_arguments` config block.** Capture is on by default and bounded:

```php
// config/horizon.php
'job_arguments' => [
    'enabled' => env('HORIZON_JOB_ARGUMENTS', true),
    'hidden' => [
        'password', 'secret', 'token', 'api_key', 'apikey', 'authorization',
        'credit_card', 'card_number', 'cvv', 'private_key',
    ],
    'ignored' => [],
    'max_depth' => 4,
    'max_items' => 25,
    'max_string' => 200,
    'max_length' => 4096,
],
```

Any argument whose name contains one of the `hidden` fragments is stored as `[hidden]`; `ignored` adds your own property names to the list stripped from every job. Note that masking covers the listing rows, the Arguments panel and the stored field — the job's **Data** panel still renders the serialized command exactly as it was queued, as it does in upstream Horizon, so treat this as noise reduction rather than a redaction guarantee.

**Added — `auto_tags`.** Jobs without a `tags()` method are tagged automatically with every Eloquent model they carry. Those tags drive the Monitoring tab and the tag filter on the Failed Jobs screen, and cost payload bytes, reflection on every dispatch, and one `failed:{tag}` Redis key per model instance for each failed job. Set `auto_tags` to `false` (or `HORIZON_AUTO_TAGS=false`) to keep only the tags your jobs declare themselves; explicit `tags()` methods, `silenced_tags` and monitoring are unaffected.

**Added — the `arguments` field** on every job-listing API response, alongside `id`, `name`, `queue`, `payload` and `status`.

> **Only new jobs**
>
> Arguments are captured when a job is pushed, so only jobs dispatched after upgrading are searchable by argument. Jobs already on the queue keep matching on class name.

## 1.3.2 — 31 August 2026

**Changed — the per-job Prometheus label is now `job_class`, not `job`.** Prometheus reserves `job` for the scrape config's `job_name` and renames any label a target exposes under that name to `exported_job` — which made every series in Grafana come back as `horizon`, with the class buried in a label the dashboard never asked for.

**Changed — queue series are split per queue.** A supervisor running `'queue' => ['high', 'default']` with `'balance' => false` serves both queues from one worker pool, which the dashboard shows as a single `high,default` row. The export now gives each queue its own series, tagged with the `group` it is balanced in. Queue length, oldest pending job and the paused flag are genuinely per queue; `horizon_queue_processes` repeats the pool's size on every queue in the group (deduplicate with `max by (group)`), and `horizon_queue_time_to_clear_seconds` is each queue's share of the group estimate (`sum by (group)` brings you back to the group figure).

**Changed —** the bundled Grafana dashboard was updated to match both of the above.

**Added —** release reasons are surfaced in the dashboard's job views.

> **Upgrade notes**
>
> Any PromQL you wrote against 1.3.0 or 1.3.1 that selects or groups on the per-job `job` label needs updating to `job_class`. Re-import the bundled Grafana dashboard to pick up both changes at once.

## 1.3.1 — 25 August 2026

**Changed —** stopping an in-progress job now records the failure as soon as its worker is killed. Previously the job was failed at pop time, whenever its reservation happened to be migrated back, so the dashboard could sit for a full `retry_after` window showing a job as running after you had stopped it.

**Fixed —** compatibility with Laravel 12.11 and later, whose `createPayload()` takes an additional delay argument.

**Changed —** the replaced upstream version moved to `laravel/horizon` 5.48.3.

## 1.3.0 — 5 August 2026

**Added — a Prometheus scrape endpoint.** The measurements behind the dashboard's metric graphs, plus workload and worker process counts, are exported in the Prometheus text exposition format at `/horizon/prometheus`. It is **off by default** and no route exists until you set `HORIZON_PROMETHEUS_ENABLED=true`.

**Added — the `prometheus` config block**, covering the path, domain, metric name prefix, additional middleware, and the IP allowlist that guards the endpoint in place of the `viewHorizon` gate — a Prometheus server has no session to authenticate with. The allowlist defaults to loopback only and accepts exact addresses, CIDR ranges, or `*`.

**Added — a bundled Grafana dashboard**, publishable with the `horizon-grafana` asset tag.

**Changed —** `horizon:snapshot` now folds the window it closes into a never-reset totals hash, so throughput, failures and retries export as true Prometheus counters that survive a snapshot reset. `rate()` and `increase()` behave correctly across snapshots, and nothing is double counted.

**Added —** the `ExportsMeasurements` contract, kept separate from `MetricsRepository` so an application binding its own metrics repository is not broken by the new methods. The exporter omits the per-job and per-queue families when the bound repository does not implement it.

## 1.2.5 — 4 August 2026

**Added — stop a running job.** An in-progress job can be stopped from the dashboard, which signals the worker executing it. `POST /api/jobs/stop/{id}` stops one job and `POST /api/jobs/stop` stops several.

**Added — `cancel_expires`.** A stopped job is flagged so that any copy migrated back after its worker is killed is refused rather than run. This option sets how long (in minutes) that flag lives, and should comfortably exceed your longest `retry_after` / `timeout` window. Defaults to `60`.

**Changed — attempt history records releases too.** `attempt_exceptions` previously recorded exceptions and timeouts; it now also records a third type, `release`, covering the releases no exception explains — a manual `$job->release()`, or middleware releasing the job before it runs. Only one entry is kept per attempt, and the release is dropped when an exception already covers that attempt.

## 1.2.4.1 — 29 July 2026

**Fixed —** the package declared `replace: {"laravel/horizon": "self.version"}`, which told Composer that Skyline 1.2.4 satisfied a `laravel/horizon` requirement of `^1.2` — a version of Horizon that does not exist. The replaced version is now pinned to the upstream release Skyline actually forks (`5.48.1`), so applications and packages depending on `laravel/horizon: ^5.0` resolve correctly.

**Added —** Horizon's dev-only commands are registered again.

## 1.2.4 — 20 July 2026

**Added — bulk job actions.** Retry, Perform Now and Delete now accept a set of job ids, so you can act on a selection rather than one row at a time: `POST /api/jobs/retry`, `POST /api/jobs/perform` and `DELETE /api/jobs`.

**Added — the Reserved tab** and its `GET /api/jobs/reserved` endpoint, listing jobs a worker holds right now.

**Changed —** the job screens were reorganised around the tab set the dashboard has today: the per-queue screen became the In Progress view, and the separate Retries screen folded into it.

## 1.2.3 — 6 July 2026

**Added — lifecycle logging covers the whole lifecycle.** 1.2.0 logged the transitions nothing else reported — unique-lock discards and releases. This release adds the rest: a job being queued, reserved, migrated from the delayed set, completed, and failed. Every line still carries the job id, and the channel's own log level is the volume dial.

**Added — the unique job lock releaser.** A `ShouldBeUnique` job deleted from the queue or dropped out-of-band used to leave its `laravel_unique_job:*` lock held until the TTL expired, silently discarding every subsequent dispatch. The lock is now released when the job leaves the queue.

## 1.2.2 — 4 July 2026

**Added — `worker_output`.** Set it to `json` (`HORIZON_WORKER_OUTPUT=json`) and workers print one structured JSON object per line — job id, uuid, connection, queue, status, attempts and duration — instead of the human-readable terminal table. Requires Laravel 11 or later; the default `cli` is unchanged.

## 1.2.1 — 29 June 2026

**Fixed —** the per-queue metrics read the wrong end of the snapshot series (`zrange … -1, 1` rather than `-1, -1`), so queue runtime and throughput could fall back to stale or empty values on the dashboard.

## 1.2.0 — 27 June 2026

**Added — previous-attempt failure reasons.** When a job throws or times out but still has retries left, that reason was previously lost — you saw a job sitting in Retries with nothing explaining why. Skyline now records it and shows the history under a **Previous Attempts** panel on the job's page, with the attempt number, the type, the timestamp, and the full stack trace for exceptions. The new `attempt_exceptions` option caps how many are kept per job (default `1`, `0` disables).

**Added — job lifecycle logging.** The new `log_channel` option (`HORIZON_LOG_CHANNEL`) points at the channel that should receive lines for the transitions nothing else reports: jobs discarded at dispatch because a `ShouldBeUnique` lock was held, and jobs released back to the queue with the reason attributed.

**Added — a drop-in `WithoutOverlapping` middleware.** Import `Laravel\Horizon\Middleware\WithoutOverlapping` instead of the framework's — otherwise identical — and releases caused by an overlap are distinguished from manual ones in the logs.

## 1.1.3 — 8 June 2026

**Changed —** metric snapshots are taken in a single atomic Lua call that reads each metrics hash, resets it, appends the snapshot and trims the series in one pass. Previously a snapshot spanning many queues could interleave with in-flight measurements and lose a window.

**Changed —** the aggregate metric charts were reworked so each plots one max-across-all line, which stays readable however many queues you run.

## 1.1.2 — 2 June 2026

**Fixed —** the dashboard's line charts froze the tab under heavy throughput. The component deep-watched its data, which made Vue traverse Chart.js's own internal element graph on every refresh, and re-animated every chart on each poll. The watcher is now shallow and animations are off.

## 1.1.1 — 2 June 2026

**Added — workload and failure trends.** A `trends` config block, a `GET /api/trends` endpoint, and dashboard charts showing how workload and failures moved over time. `interval` sets the bucket size and workload sampling cadence in minutes; `retention` sets how many hours of history to keep.

**Added — weighted queues.** With `'balance' => false`, workers serve the listed queues in strict left-to-right priority. An optional `queueWeights` map on the supervisor softens that into a proportional policy: a queue weighted `2` is checked roughly twice as often as one weighted `1`, so high-priority work is favoured without starving anything.

**Added — front-of-queue dispatching.** Add the `InteractsWithFrontOfQueue` trait to a job and call `dispatch($job)->onFront()` to have it `LPUSH`ed onto the head of the ready list, so it is the next job a worker pops.

**Changed — per-queue pausing moved onto Laravel's native queue-pause API.** The previous implementation kept its own pause state in Redis and signalled supervisors; it now calls `QueueManager::pause()`, which the workers themselves honour.

> **Upgrade notes**
>
> Per-queue pausing now requires a framework version with the native queue-pause API and a shared cache store. Where either is missing, the endpoint responds `409 Conflict` rather than reporting a pause that would not take effect. Global and per-supervisor pause are unaffected.

## 1.1.0 — 28 May 2026

**Added — Redis Cluster support.** Every repository now runs its multi-command batches through a cluster-aware helper that issues a transaction on a clustered connection and a pipeline otherwise, and the Horizon connection is configured differently for a cluster than for a standalone server.

**Added — delete a job and empty a queue** from the dashboard, with a confirmation modal: `DELETE /api/jobs/{id}` and `DELETE /api/queues/{connection}/{queue}`. Only pending and delayed jobs can be deleted — a job a worker has already reserved is rejected with `422` rather than left half-executed.

## 1.0 — 27 May 2026

First public release. Skyline forks Laravel Horizon and adds the tabbed Jobs view with per-queue drill-down and job-class search, pausing and resuming at three levels of granularity, Perform Now for delayed jobs, and improved delayed-job tracking and wait-time reporting. Everything Horizon does, it still does — see [Migrating from Horizon](https://boring-observability.dev/skyline/docs/migrating-from-horizon).

## Staying current

```bash
# Move to the newest release your constraint allows
composer update boring-o11y/laravel-skyline

# Check what you are on
composer show boring-o11y/laravel-skyline

# List every published version
composer show boring-o11y/laravel-skyline --all
```

Nothing else is needed after an upgrade. Skyline serves its dashboard assets from the package itself, so there is no publish step to repeat — `horizon:publish` exists only to tell you it is no longer required. A release that adds a config option ships it with a working default, so republishing `config/horizon.php` is optional; copy the new block across from the package's own config file when you want to change it from the default.


## Common questions

### What is the latest version of Skyline for Laravel?

1.5.1, released on 3 October 2026. It adds four alert checks for jobs that go wrong without failing: a job whose reservation expired so it will run twice, a job class that keeps timing out, jobs dropped by rate-limiting or overlap middleware, and workers recycled for their memory limit. That makes sixteen built-in checks. Thresholds can now be set per queue and per job class, timeouts are exported to Prometheus, and horizon:forget takes --queue. The release before it, 1.5.0 on 27 September 2026, introduced alerts: checks from Horizon not running at all to stalled queues, failure spikes, crash-looping workers and stranded locks, each with a severity and a recovery message, sent over mail, Slack, SMS or a JSON webhook.

### How do I update Skyline to the latest version?

Run composer update boring-o11y/laravel-skyline. That moves you to the newest release your version constraint allows — a constraint of ^1.1 covers every 1.x release. Then run php artisan horizon:publish to refresh the dashboard assets, which a stock Laravel composer.json already does for you through its post-update-cmd hook.

### Are there breaking changes between Skyline versions?

Four so far, all within the 1.x line and all narrow. 1.4.0 turns on release_stranded_unique_locks, so a ShouldBeUniqueUntilProcessing job that fails before it starts no longer leaves its lock held; the release is owner-checked and can be turned off with HORIZON_RELEASE_STRANDED_UNIQUE_LOCKS=false. 1.3.4 removed the worker_output config key: Horizon 5.49 ships a --json flag on horizon:work, so set 'json' => true on the supervisor in config/horizon.php instead. 1.3.2 renamed the per-job Prometheus label from job to job_class, so PromQL written against 1.3.0 or 1.3.1 needs updating. 1.1.1 moved per-queue pausing onto Laravel's native queue-pause API, which now responds 409 where the framework version or cache store cannot support it. Nothing else has changed existing behaviour.
