# Configuration reference

> Every config key Skyline adds on top of the stock config/horizon.php.

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

---

Skyline reads the same `config/horizon.php` as Horizon. Every upstream option — `path`, `prefix`, `middleware`, `waits`, `silenced`, `environments` and the rest — behaves exactly as documented in the [Horizon documentation](https://laravel.com/docs/horizon). This page covers only the keys Skyline *adds*.

All of them have working defaults. A Horizon config file copied across unchanged is a valid Skyline config file.

## Reference

| Key | Env | Default | Purpose |
| --- | --- | --- | --- |
| `log_channel` | `HORIZON_LOG_CHANNEL` | `null` | Log channel that receives [job lifecycle events](https://boring-observability.dev/skyline/docs/job-lifecycle-logging). `null` uses the application default channel. |
| `attempt_exceptions` | — | `1` | How many previous-attempt failure reasons to retain per job. `0` disables recording. |
| `job_arguments.enabled` | `HORIZON_JOB_ARGUMENTS` | `true` | Whether the constructor arguments a job was dispatched with are captured, shown and searched. The block's other keys mask and bound what is stored. |
| `cancel_expires` | — | `60` | Minutes a stopped job's cancellation flag lives, so a copy migrated back after its worker was killed is still refused. |
| `auto_tags` | `HORIZON_AUTO_TAGS` | `true` | Whether jobs without a `tags()` method are tagged with the Eloquent models they carry. `false` keeps only the tags jobs declare themselves. |
| `release_stranded_unique_locks` | `HORIZON_RELEASE_STRANDED_UNIQUE_LOCKS` | `true` | Release the lock a `ShouldBeUniqueUntilProcessing` job leaves behind when it fails before it starts, if the job still owns it. |
| `lock_insights` | `HORIZON_LOCK_INSIGHTS` | `true` | Store held unique locks, skipped dispatches and middleware releases for the [Locks & Limits](https://boring-observability.dev/skyline/docs/locks-and-limits) screen. |
| `trim.delayed` | — | `10080` | Minutes to retain delayed-job tracking data (7 days). |
| `trim.reserved` | — | `10080` | Minutes to retain reserved-job tracking data, behind the **Reserved** tab. Not written to the published file. |
| `metrics.ema_alpha` | — | `0.05` | Smoothing factor for the runtime and wait-time moving averages. Between 0 and 1. |
| `metrics.snapshot_lock` | — | `300` | Seconds a `horizon:snapshot` lock is held, to de-duplicate concurrent snapshots. Not written to the published file. |
| `insights.enabled` | `HORIZON_INSIGHTS` | `false` | Record what each job class costs in memory and CPU, what the worker fleet uses, and why workers are replaced. See [Insights](https://boring-observability.dev/skyline/docs/insights). |
| `insights.skip_first_job` | — | `true` | Leave each worker's first job out of the cost averages — it pays for framework boot and autoloading. |
| `trends.interval` | — | `15` | Minutes per trend bucket, and the workload sampling cadence. Also the bucket size for the restart and worker resource charts. |
| `trends.retention` | — | `24` | Hours of trend history to keep and display. |
| `prometheus.*` | `HORIZON_PROMETHEUS_*` | disabled | The scrape endpoint, its IP allowlist, path, domain, extra middleware and metric prefix. See [Prometheus metrics](https://boring-observability.dev/skyline/docs/prometheus-metrics). |
| `alerts.enabled` | `HORIZON_ALERTS` | `false` | Turn on the alert pipeline. See [alerts](#alerts) below and [Alerts](https://boring-observability.dev/skyline/docs/alerts). |
| `alerts.channels.*` | `HORIZON_ALERT_MAIL`, `HORIZON_ALERT_SLACK`, `HORIZON_ALERT_SMS`, `HORIZON_ALERT_WEBHOOK` | unset | Where alerts go. Empty channels fall back to `Horizon::routeMailNotificationsTo()` and the other routing helpers. |
| `mcp.enabled` | `HORIZON_MCP_ENABLED` | `true` | Register the read-only MCP server when `laravel/mcp` is installed. See [MCP server](https://boring-observability.dev/skyline/docs/mcp-server). |
| `mcp.web.*` | `HORIZON_MCP_WEB_ENABLED`, `HORIZON_MCP_PATH`, `HORIZON_MCP_DOMAIN` | disabled | The HTTP endpoint for remote agents, its path, domain and token middleware. Off by default; the local stdio server is unaffected. |
| `mcp.exception_length` | — | `4000` | Characters of a stack trace an MCP tool returns before truncating, unless the agent asks for the whole trace. |
| `environments.*.*.queueWeights` | — | `[]` | Per-supervisor map of queue name to weight. Only valid when `balance` is `false`. |

## log_channel

Routes Skyline's job lifecycle log lines to a dedicated channel, so a full end-to-end job trace does not drown your application log. Leave it `null` to use the default channel.

```php
'log_channel' => env('HORIZON_LOG_CHANNEL'),
```

Because each event is emitted at a level appropriate to its severity, the channel's own log level is the volume dial: point it at a channel with level `error` to see only failures, or `debug` for a full trace of every job. The full event list is on the [Job lifecycle logging](https://boring-observability.dev/skyline/docs/job-lifecycle-logging) page.

## attempt_exceptions

When a job throws or times out but still has retries left, Skyline records why that attempt failed and shows the history under a **Previous Attempts** panel on the job's dashboard page. Because each entry can carry a full stack trace, the number retained per job is capped.

```php
'attempt_exceptions' => 1,
```

The default of `1` keeps only the most recent failure reason. Raise it to retain more history — the oldest entries beyond the limit are dropped — or set it to `0` to turn the feature off entirely.

## job_arguments

Skyline records the constructor arguments each job was dispatched with, shows them under the job name in every listing and in an **Arguments** panel on the job's page, and matches them in the search box (`checkout_id: 3`). The syntax and the matching rules are on [Dashboard operations](https://boring-observability.dev/skyline/docs/dashboard-operations#search).

```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,
],
```

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 yields the arguments of the object it wraps rather than the framework wrapper's.

| Key | Default | Purpose |
| --- | --- | --- |
| `enabled` | `true` | Capture arguments at all. When `false` nothing is stored and search falls back to the class name. |
| `hidden` | ten fragments | Case-insensitive substrings of an argument's name whose value is stored as `[hidden]`. Your list *replaces* the default rather than extending it, so repeat the entries you want to keep. |
| `ignored` | `[]` | Property names dropped from every job. These are *added* to the bookkeeping names Skyline already strips. |
| `max_depth` | `4` | Deepest level of nesting kept. `0` lifts the configured limit, but nesting is still capped at 20 levels. |
| `max_items` | `25` | Entries kept per array; the rest collapse into an `...` entry counting what was dropped. `0` means no limit. |
| `max_string` | `200` | Characters kept per string value before it is truncated with an ellipsis. `0` means no limit. |
| `max_length` | `4096` | Bytes the whole encoded set may occupy. Trailing arguments are dropped until it fits and the stored set is marked `__truncated`. `0` means no limit. |

The two list options differ in kind, which is easy to get wrong: `ignored` extends Skyline's own list of stripped property names, while `hidden` replaces the default fragments outright. Setting `'hidden' => ['ssn']` therefore stops masking passwords and tokens.

> **Masking is not redaction**
>
> Hidden values are masked in the listing rows, the Arguments panel and the stored field, but the job's **Data** panel still renders the serialized command exactly as it was queued, as it does in upstream Horizon. Treat this as noise reduction, and keep secrets out of job payloads themselves.

> **Applies to new pushes**
>
> Arguments are captured when a job is pushed, so only jobs dispatched after the setting changes carry them. Jobs already on the queue keep matching on class name alone.

## cancel_expires

Stopping an in-progress job from the dashboard kills its worker, which leaves the job reserved in Redis. When the queue later migrates that reserved entry back onto the queue, Skyline has to refuse it rather than run it a second time, so stopping sets a cancellation flag the job is checked against.

```php
'cancel_expires' => 60,
```

The value is in minutes and sets how long that flag lives. It must comfortably outlive one reserved-job migration cycle, so keep it above the connection's `retry_after` and above your longest job `timeout`. The default of an hour clears both for most applications. Raising it costs one short-lived Redis key per stopped job.

## auto_tags

Every job pushed onto the queue carries tags. A job that defines a `tags()` method supplies its own; a job that does not is tagged by reflection over its properties, which records `App\Models\User:1` for every Eloquent model — and every model inside an Eloquent collection — the job holds.

Those tags feed the **Monitoring** tab and the tag filter on the **Failed Jobs** screen. An application that uses neither still pays for them:

- **Payload bytes.** Tags live inside the job payload, so each one is written to the queue list, copied into the reserved set while the job runs, and kept in the job hash for as long as your `trim` settings retain it. A tag like `App\Models\User:12345` costs roughly 26 bytes per copy.
- **A Redis key per model instance.** Each failed job writes one `failed:{tag}` sorted set per tag, held for `trim.failed` minutes at around 170 bytes each including key and dict overhead. 100k failed jobs carrying two model tags can leave 200k such keys behind for a week.
- **Reflection on every push,** a few microseconds per job, paid on the dispatching request rather than on the worker.

```php
'auto_tags' => env('HORIZON_AUTO_TAGS', true),
```

Set it to `false` to drop the model-derived fallback and keep only the tags your jobs declare. Explicit `tags()` methods on jobs, mailables, notifications, events and listeners are untouched, and so are `silenced_tags` and monitoring for those tags. The listener path composes the two, so declared tags on a listener or its event still merge as before.

> **Applies to new pushes**
>
> Jobs already on the queue keep the tags they were pushed with. The setting takes effect for jobs dispatched after the change, so the Monitoring tab and failed-job tag filter keep working on the backlog until it drains.

## release_stranded_unique_locks

Laravel releases a `ShouldBeUniqueUntilProcessing` lock just before the job runs, and never on failure. A job failed before it runs, such as one picked up after its `retryUntil()` deadline, keeps its lock, and every later dispatch of it is skipped until the lock expires. With the default `uniqueFor` of `0` that is never.

```php
'release_stranded_unique_locks' => env('HORIZON_RELEASE_STRANDED_UNIQUE_LOCKS', true),
```

When enabled, Skyline releases the lock only if it is still held by the owner the failed job's own dispatch took it with, in one atomic compare-and-delete. Either way the stranded lock is logged. Set it to `false` to keep the framework's behaviour and rely on the log line. See [Unique job locks](https://boring-observability.dev/skyline/docs/unique-job-locks#stranded).

## lock_insights

Records which unique locks are held, which dispatches a held lock skipped, and how often Skyline's rate-limiting and overlap middleware released or dropped a job, for the [Locks & Limits](https://boring-observability.dev/skyline/docs/locks-and-limits) screen.

```php
'lock_insights' => env('HORIZON_LOCK_INSIGHTS', true),
```

Each unique dispatch and lock release costs one write to Horizon's Redis connection. Turning it off stores nothing for the screen, and keeps the lifecycle log lines and release reasons. The `stranded_lock` and `limiter_drop` [alerts](#alerts) read what it records, so they go quiet without it.

## insights

Records what each job class costs in memory and CPU, how much memory and CPU all workers use together, and how often workers die and are replaced. Everything under this key is **off by default**.

```php
'insights' => [
    'enabled' => env('HORIZON_INSIGHTS', false),
    'skip_first_job' => true,
],
```

Job cost is measured inside the worker around each run, so it costs a couple of microseconds and no extra Redis round trips; fleet memory and CPU are sampled by each supervisor from its worker processes every ten seconds. Workers read the flag when they boot, so run `php artisan horizon:terminate` after changing it.

`skip_first_job` applies to job cost only: a worker's first job pays for framework boot and for autoloading everything it touches, which belongs to whichever class came off the queue first rather than to the class itself. Restart and worker resource history share the `trends` window below, so those charts line up with the workload and wait charts beside them. See [Insights](https://boring-observability.dev/skyline/docs/insights) for what each figure means and what it will not tell you. The `worker_crash_loop` and `memory_restarts` [alerts](#alerts) need it, because they count the restarts it records.

## alerts

Off until you set `HORIZON_ALERTS=true`. A config file published before 1.5 has no `alerts` block at all, and a missing `enabled` key counts as off, so upgrading never starts paging anyone by surprise.

```php
'alerts' => [
    'enabled' => env('HORIZON_ALERTS', false),

    'channels' => [
        'mail' => env('HORIZON_ALERT_MAIL'),
        'slack' => env('HORIZON_ALERT_SLACK'),
        'sms' => env('HORIZON_ALERT_SMS'),
        'webhook' => env('HORIZON_ALERT_WEBHOOK'),
        'webhook_headers' => [],
        'webhook_timeout' => 5,
    ],

    'interval' => 60,               // how often the checks run, fleet-wide
    'cooldown' => 900,              // how long before a still-firing alert repeats
    'suppress_after_deploy' => 300, // notifications withheld after a deploy
    'history' => 200,               // entries kept for horizon:alerts --history

    'routes' => [
        // 'critical' => ['slack', 'sms', 'webhook'],
        // 'warning' => ['slack'],
    ],

    'checks' => [
        'queue_wait' => ['enabled' => true, 'for' => 60, 'threshold' => 60, 'queues' => []],
        'queue_not_draining' => ['enabled' => true, 'for' => 300, 'threshold' => 900, 'window' => 600, 'queues' => []],
        'failure_rate' => ['enabled' => true, 'threshold' => 10, 'window' => 300, 'per' => 'queue', 'queues' => [], 'jobs' => []],
        'job_timeout' => ['enabled' => true, 'threshold' => 5, 'window' => 600, 'jobs' => []],
        'limiter_drop' => ['enabled' => true, 'threshold' => 10, 'circuit_open' => 0, 'groups' => []],
        'reservation_expired' => ['enabled' => true, 'severity' => 'critical'],
        // ... one entry for each of the sixteen checks
    ],
],
```

Every check under `checks` takes `enabled`, and most take `for`, the seconds a condition has to hold before anyone hears about it. Any check also accepts `severity` and `cooldown` to override its defaults. `routes` narrows which channels each severity goes to; a severity left out goes to every configured channel. The checks, their thresholds and the webhook payload are on the [Alerts](https://boring-observability.dev/skyline/docs/alerts) page.

Since 1.5.1, four checks also take overrides beside their global `threshold`: `queues` on `queue_not_draining` and `failure_rate`, `jobs` on `failure_rate` and `job_timeout`, and `groups` on `limiter_drop`. A zero turns the check off for that queue, job class or limiter. See [Thresholds for one queue or one job class](https://boring-observability.dev/skyline/docs/alerts#per-queue).

A published config file only holds the checks that existed when you published it. The package fills in the rest with their defaults, so the four checks added in 1.5.1 run once alerts are on, without a new entry. Add an entry only to change one, or to turn it off.

## mcp

Skyline registers a read-only [MCP server](https://boring-observability.dev/skyline/docs/mcp-server) so AI agents can inspect queue health, find and read jobs and read metrics. Nothing is registered unless [`laravel/mcp`](https://github.com/laravel/mcp) is installed, whatever is set here.

```php
'mcp' => [
    'enabled' => env('HORIZON_MCP_ENABLED', true),

    'web' => [
        'enabled' => env('HORIZON_MCP_WEB_ENABLED', false),
        'path' => env('HORIZON_MCP_PATH'),     // defaults to "{horizon.path}/mcp"
        'domain' => env('HORIZON_MCP_DOMAIN'), // defaults to horizon.domain
        'middleware' => [],                    // e.g. ['auth:sanctum']
    ],

    'exception_length' => 4000,
],
```

The local server runs over stdio through `php artisan mcp:start skyline` and reaches no further than the machine running it. The web server is an HTTP endpoint and is off unless you turn it on; requests to it must pass the `viewHorizon` gate, like the dashboard, so `web.middleware` is where you list whatever authenticates your agents' tokens. It is applied ahead of the gate check.

> **The gate lets everyone through locally**
>
> In the `local` environment `viewHorizon` admits everyone, exactly as it does for the dashboard. Don't enable `mcp.web` on a machine reachable from outside while `APP_ENV=local`.

## trim.delayed and trim.reserved

Skyline indexes delayed jobs so the **Scheduled** and **Retries** views can show them with their real next-run time, and reserved jobs so the **Reserved** tab can show what workers currently hold. Both indexes are trimmed on the same principle as Horizon's other retention settings:

```php
'trim' => [
    'recent' => 60,
    'pending' => 60,
    'completed' => 60,
    'recent_failed' => 10080,
    'failed' => 10080,
    'monitored' => 10080,
    'delayed' => 10080,
    'reserved' => 10080,
],
```

`delayed` and `reserved` are the Skyline additions; the rest are upstream. Values are in minutes, and the default of `10080` is seven days for both. Trimming runs periodically from the `php artisan horizon` master process, so it requires no separate scheduled command.

`reserved` is read with that default but is not written to the published config file, so add the line yourself to change it. A reserved entry is cleared as soon as its job finishes; the retention only bounds entries whose worker died without reporting back, which is why it can sit at the failed-job retention without growing.

## metrics

```php
'metrics' => [
    'trim_snapshots' => [
        'job' => 24,
        'queue' => 24,
    ],

    'ema_alpha' => 0.05,
],
```

`ema_alpha` is the smoothing factor of the exponential moving average behind Skyline's runtime and wait-time estimates. It must sit between 0 and 1. Lower values produce a stable average that shrugs off outliers; higher values react faster to a genuine change in job duration. The default of `0.05` is deliberately conservative — a single pathological job should not move the estimate much.

## trends

Configures the time-series behind the dashboard's workload, wait-time and failure trend charts.

```php
'trends' => [
    'interval' => 15,
    'retention' => 24,
],
```

`interval` is the size of each bucket in minutes, and doubles as the sampling cadence for the workload and wait series. `retention` is how many hours of history to keep and display. Together they set the number of points on the chart: the defaults give 96 buckets across 24 hours. Shortening the interval gives a finer chart at the cost of more Redis keys and a busier render.

> **Trends need the master process**
>
> Workload and wait samples are taken from the `php artisan horizon` master process loop, not from a scheduled command. Failure counts are recorded as jobs fail. If the master process is not running, no workload samples are recorded for that period — but nothing else breaks.

## prometheus

Skyline can export the measurements behind the dashboard's graphs in the Prometheus text format. The endpoint is off until you turn it on, has no user to authenticate, and is guarded by an IP allowlist that admits only the loopback addresses by default:

```php
'prometheus' => [
    'enabled' => env('HORIZON_PROMETHEUS_ENABLED', false),
    'path' => env('HORIZON_PROMETHEUS_PATH'),
    'domain' => env('HORIZON_PROMETHEUS_DOMAIN'),
    'allowed_ips' => array_filter(array_map('trim', explode(
        ',', (string) env('HORIZON_PROMETHEUS_ALLOWED_IPS', '127.0.0.1,::1')
    ))),
    'middleware' => [],
    'prefix' => env('HORIZON_PROMETHEUS_PREFIX', 'horizon'),
],
```

Each key, the exported metric list, the trusted-proxy caveat behind the allowlist and the Grafana dashboard are covered on [Prometheus metrics](https://boring-observability.dev/skyline/docs/prometheus-metrics).

## queueWeights

A per-supervisor map that turns strict left-to-right queue priority into a proportional policy. It is only valid when that supervisor runs with `'balance' => false`.

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

See [Weighted queues](https://boring-observability.dev/skyline/docs/weighted-queues) for the semantics, the exception raised when it is combined with a balancing strategy, and the `block_for` notes.


## Common questions

### Do I need to change config/horizon.php to use Skyline?

No. Every key Skyline adds has a working default, and a Horizon config file copied across unchanged is a valid Skyline config file. The new keys are opt-in refinements, not requirements.

### What is ema_alpha in the Skyline metrics config?

It is the smoothing factor of the exponential moving average behind the runtime and expected-wait-time estimates, and it must lie between 0 and 1. Lower values weight history more heavily, so a single pathological job barely moves the estimate; higher values react faster to a genuine change in job duration. The default of 0.05 favours stability.

### How do I turn off automatic job tagging in Skyline?

Set auto_tags to false in config/horizon.php, or HORIZON_AUTO_TAGS=false in the environment. That drops only the model-derived tags Skyline generates for jobs without a tags() method; explicit tags() methods, silenced_tags and monitoring for declared tags keep working. It applies to jobs pushed after the change, so jobs already on the queue keep the tags they carry.

### How do I stop Skyline storing sensitive job arguments?

The job_arguments.hidden list holds case-insensitive name fragments whose values are stored as [hidden], and it covers passwords, secrets, tokens, card numbers and private keys out of the box. Your own list replaces that default rather than extending it, so repeat the entries you want to keep. Setting job_arguments.enabled to false, or HORIZON_JOB_ARGUMENTS=false, turns argument capture off entirely. Masking is noise reduction, not redaction: the job Data panel still shows the serialized command as it was queued, so keep real secrets out of job payloads.
