In Laravel Horizon, how do the environments, defaults and supervisor entries in config/horizon.php decide which worker processes start on a server?
answer
- master, supervisors, workers
- --environment, then app.env
- first matching environments key wins
- defaults merged with array_replace_recursive
- maxProcesses 0 skips the supervisor
basics
~10 sHorizon picks the environments entry matching the app environment, merges each supervisor in it over the defaults block, and starts one supervisor per entry, each running its own Redis worker pool for its queues.
solid answer
~40 s`php artisan horizon` starts a master supervisor for the server. It resolves the environment from `--environment`, then a `horizon.env` config value, then `app.env`, and takes the first key under `environments` that matches it (keys may be wildcards such as `*`, so put that one last). Each supervisor in the chosen entry is merged over the `defaults` block with `array_replace_recursive`, so the environment only states what differs, usually `maxProcesses`. Every supervisor then runs its own pool of Redis workers for its `queue` list, with its own `balance` strategy, `tries`, `timeout` and `memory`. If no key matches, Horizon keeps running but provisions no workers, and a supervisor whose `maxProcesses` is 0 is not started at all.
code
php · 27 lines<?php
// config/horizon.php (excerpt)
return [
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'auto',
'maxProcesses' => 1,
'tries' => 1,
'timeout' => 60,
],
],
'environments' => [
'production' => [
'supervisor-1' => ['maxProcesses' => 10],
],
'local' => [
'supervisor-1' => ['maxProcesses' => 3],
],
'*' => [ // keep the wildcard last: the first matching key wins
'supervisor-1' => ['maxProcesses' => 2],
],
],
];go deeper
Know that Horizon runs Redis queue workers from config/horizon.php and is started with php artisan horizon; name the environments and supervisors keys.
Explain how the environment is resolved, how defaults merge into each supervisor, and why a missing environment entry means no workers at all.
Show you can lay out supervisors per workload, use maxProcesses 0 and wildcard entries deliberately, and restart Horizon when its config changes.
Frame config/horizon.php as reviewed infrastructure: who may change worker budgets per environment, and how that change is rolled out safely.
## What Horizon is **Laravel Horizon** is a first-party package (`composer require laravel/horizon`, then `php artisan horizon:install`) that runs and monitors queue workers for **Redis** queue connections. Instead of starting and sizing a separate `queue:work` process for every queue, you describe the whole worker fleet in one version-controlled file, `config/horizon.php`, and start it with one command, `php artisan horizon`. Horizon requires the queue connection to be `redis`. The Laravel 13 skeleton ships `QUEUE_CONNECTION=database`, so switching the connection is the first step of any Horizon install. Because the worker layout lives in code, it is reviewed, versioned and deployed like everything else: a pull request can raise the process count for one queue in production without anyone logging into a server. ## Three layers of process 1. **Master supervisor** - the process that `php artisan horizon` becomes. Normally one per server; it is named after the host name. It has its own memory ceiling, the `memory_limit` option (64 MB in the published file), past which it is terminated so the process monitor can start a fresh one. 2. **Supervisors** - one per entry under the chosen environment. Each owns a group of queues plus its settings: `connection`, the `queue` list, the `balance` strategy, `minProcesses` and `maxProcesses`, `tries`, `timeout`, `memory`, `maxJobs`, `maxTime`, `nice`. 3. **Worker processes** - started by each supervisor to pull jobs from Redis. They run Horizon's hidden `horizon:work` command, a subclass of the framework's worker command, with flags built from the supervisor's options. A supervisor restarts workers that die, applies its balancing strategy, and reports its state to Redis so the dashboard can draw it. ## How the environment is chosen `php artisan horizon` resolves an environment name in this order: - the `--environment=` option passed to the command; - a `horizon.env` config value, when you have set one; - otherwise `app.env`, which normally comes from `APP_ENV`. It then walks the `environments` array **in the order it is written** and takes the first key that matches the name using wildcard matching (`Str::is`). The published file has `production` and `local`; you may add more, including a catch-all `*`. Because the first match wins, the `*` key belongs at the end of the array: written first, it would also match `production` and shadow it. If nothing matches, Horizon still reports that it started and keeps running, but it provisions **no supervisors**, so no job is processed. That is the classic "Horizon is green but the queue never drains" report from a freshly added `staging` server. ## How defaults merge The `defaults` block holds base settings keyed by supervisor name. Horizon merges each environment over it with `array_replace_recursive`, which means: - a key present in the environment entry **overrides** the default; - a key present only in `defaults` is **inherited**; - a supervisor that appears only under an environment still runs; it simply inherits nothing. This is why the published `production` entry lists only `maxProcesses`, `balanceMaxShift` and `balanceCooldown`. | Key | Published default | |---|---| | `connection` | `redis` | | `queue` | `['default']` | | `balance` | `auto` | | `autoScalingStrategy` | `time` | | `maxProcesses` | `1` (production entry 10, local entry 3) | | `memory` | `128` (MB per worker) | | `tries` | `1` | | `timeout` | `60` (seconds) | Some keys are normalised while the plan is parsed: `tries` becomes the supervisor's maximum attempts, `processes` is accepted as an alias for `maxProcesses`, and an array `queue` is joined into a comma-separated list. ## Guard rails worth knowing - A `minProcesses` below 1 throws an exception when the plan is parsed, so a typo fails loudly at start. - A `maxProcesses` of 0 means the supervisor is **not started at all**: a clean switch to disable one worker group in one environment. - Horizon uses a Redis connection named `horizon` internally for its metadata; that name is reserved, so do not define one yourself. - The plan is read when the master starts, so an edited `config/horizon.php` needs Horizon restarted (`php artisan horizon:terminate` plus the process monitor that brings it back). - Keep an entry for **every** environment you run Horizon in; the docs warn about this explicitly because the failure is silent. ## Checking what actually started When the layout does not behave as the file suggests, inspect the running fleet rather than rereading the config: - `php artisan horizon:status` reports whether the master is running, paused or inactive; - `php artisan horizon:list` lists the deployed machines, i.e. the master supervisors; - `php artisan horizon:supervisors` lists each supervisor with its status, per-queue worker counts and balance strategy; - the dashboard shows the same supervisors and how many processes each queue currently has. An environment mismatch shows up here immediately: a running master while `horizon:supervisors` answers that no supervisors are running.
- A new staging server runs Horizon, the dashboard shows it active, yet jobs never leave the queue. What do you check first?Whether `config/horizon.php` has an `environments` key matching `APP_ENV=staging`. With no match, Horizon starts but provisions no supervisors, so nothing consumes the queue. Add a `staging` entry, add a `*` entry at the end of the array, or start it with `php artisan horizon --environment=production` if staging should mirror production.
- Why would you define a second supervisor in the same environment rather than add another queue to the first?Settings such as `balance`, `minProcesses`, `maxProcesses`, `timeout`, `memory` and `tries` are per supervisor. A second supervisor gives a group of queues its own process budget and limits, for example a long-running export queue with a longer `timeout` and a small `maxProcesses`, without changing how the other queues are worked.
- You changed a supervisor's maxProcesses in config/horizon.php and deployed. Why has nothing changed?The provisioning plan is read when the master supervisor starts, and the worker settings are fixed when each supervisor starts. The running Horizon keeps the old plan until it is terminated with `php artisan horizon:terminate` and started again by the process monitor.
saying these in an interview costs you the question
- Horizon can run on the database queue driver the skeleton ships with
- Each environment block must repeat every supervisor option from defaults
- Horizon starts the supervisors of every environment listed in the file
- A * environment written first still yields to a later production key
- Setting maxProcesses to 0 removes the cap on worker processes
- Editing config/horizon.php takes effect without restarting Horizon