Skyline

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.

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.

'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.
'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.

'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.

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:

'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.

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.

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:

'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.

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.

Queue control, not just queue monitoring.

Skyline is a drop-in replacement for Laravel Horizon that lets you act on what you see — pause a queue, jump a job to the front, drain a backlog. $139 once, every app you run it on.

Buy Skyline — $139 once

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