Configuration reference
Every config key Skyline adds on top of the stock config/horizon.php.
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. 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. 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 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. |
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. |
alerts.enabled |
HORIZON_ALERTS |
false |
Turn on the alert pipeline. See alerts below and 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. |
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.
'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 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.
'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.
'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.
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.
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.
'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
trimsettings retain it. A tag likeApp\Models\User:12345costs roughly 26 bytes per copy. -
A Redis key per model instance. Each failed job writes one
failed:{tag}sorted set per tag, held fortrim.failedminutes 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.
'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.
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.
'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.
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 screen.
'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 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.
'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 for what each
figure means and what it will not tell you. The worker_crash_loop and memory_restarts
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.
'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 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.
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 so AI agents can inspect
queue health, find and read jobs and read metrics. Nothing is registered unless
laravel/mcp is installed, whatever is set
here.
'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.
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:
'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#
'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.
'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.
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:
'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.
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.
'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 for the semantics, the exception raised
when it is combined with a balancing strategy, and the block_for notes.