In Laravel's scheduler, how do environments(), when() and skip() decide whether a due task actually runs, and when is each one evaluated?
answer
- due first, then filters
- environments() is an exact APP_ENV match
- all when() must be true
- any true skip() cancels
- ScheduledTaskSkipped only for filters
basics
~20 senvironments() is checked with the cron expression when schedule:run picks due tasks and needs an exact APP_ENV match. For each due task, every when() callback must return true and every skip() callback false, evaluated at that moment through the container.
solid answer
~40 s`schedule:run` first selects **due** events: the app is not in maintenance mode (unless the task opted in), the cron expression matches the current minute in the task's timezone, and — if `environments()` was set — the current `APP_ENV` is in the list. That list is an exact `in_array` match, unlike `App::environment()`, which accepts wildcard patterns. Then, for each due event, it runs the filters: every `when()` callback must return truthy and every `skip()` callback falsy, and several `when()`s combine with AND. Callbacks run at that moment through the container, so they can type-hint services; a plain boolean is also accepted. A task rejected by a filter fires `ScheduledTaskSkipped`; one excluded by environment or expression simply is not due and fires nothing. `between()` and `unlessBetween()` are just time-window `when()`/`skip()` filters.
code
php · 11 lines<?php
use App\Support\BillingSettings;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schedule;
Schedule::command('subscriptions:renew')
->dailyAt('02:00')
->environments(['production'])
->when(fn (BillingSettings $billing) => $billing->renewalsEnabled())
->skip(fn () => Cache::get('billing:frozen', false));go deeper
Know that environments(['production']) limits a task to that APP_ENV and that when() and skip() take a condition.
Explain the two stages — due by expression, environment and maintenance mode, then when/skip filters — and that all when()s must pass.
Use filters for audit-visible skips via ScheduledTaskSkipped, guard money-moving tasks with environments(), and keep top-level definitions free of costly work.
Define how feature flags and kill switches reach scheduled work, so operations can stop a task without a deploy and every skip is traceable.
## Two stages: due, then allowed Every minute `schedule:run` decides the fate of each registered event in two separate stages. Knowing which check lives where explains most of the surprising behaviour. **Stage 1 — is the event due?** Evaluated for all events at the start of the run: 1. **Maintenance mode** — if the application is down, only events marked `evenInMaintenanceMode()` stay eligible. 2. **Cron expression** — the expression must match the current minute in the task's timezone. 3. **Environment** — if `environments()` was called, the current environment must be one of those listed. **Stage 2 — do the filters pass?** Evaluated just before each due event runs: 1. If the scheduler is paused (`schedule:pause`), only events marked `evenWhenPaused()` continue. 2. Every `when()` callback must return a truthy value. 3. No `skip()` callback may return a truthy value. 4. If a filter fails, `ScheduledTaskSkipped` is dispatched and the event is not run. ## environments() ```php Schedule::command('subscriptions:renew') ->dailyAt('02:00') ->environments(['production']); ``` `environments()` takes an array or a list of arguments (backed enums are accepted) and compares them with the application's environment — the `app.env` config value, set from `APP_ENV`. Two details matter: - The comparison is an **exact match**. `App::environment('prod*')` accepts wildcard patterns, but `->environments(['prod*'])` would only match an environment literally named `prod*`. - With no `environments()` call, the task runs in **every** environment, including `local` and `staging`. Forgetting it on a renewal or billing task is how staging charges real cards if it shares credentials. `php artisan schedule:list --environment=production` shows which tasks would run in a given environment. ## when() and skip() ```php Schedule::command('subscriptions:renew') ->dailyAt('02:00') ->when(fn (BillingSettings $billing) => $billing->renewalsEnabled()) ->skip(fn () => Cache::get('billing:frozen', false)); ``` - `when()` adds a condition that must hold; chaining several means **all** must hold. - `skip()` is the inverse; if **any** skip callback returns true, the task does not run. - Both accept a callable or a plain value. A callable is invoked through the container at the moment the due task is evaluated, so dependencies can be type-hinted, and a parameter typed as the scheduled event itself receives it. - Because callbacks run only for due events, an expensive check on a daily task costs one call a day, not one per minute. ## Sugar built on the same filters | Method | Implemented as | | --- | --- | | `between('8:00', '17:00')` | A `when()` time-window check in the task's timezone | | `unlessBetween('23:00', '4:00')` | A `skip()` time-window check; windows may cross midnight | These are filters, not cron fields, so they never change the expression `schedule:list` displays. ## Observability differences | Excluded by | Event fired | Visible where | | --- | --- | --- | | Cron expression | None | `schedule:list` next due time | | `environments()` | None | `schedule:list --environment` | | Maintenance mode | None | Only the app's maintenance status | | `when()` / `skip()` / pause | `ScheduledTaskSkipped` | Any listener you register | If you need an audit trail of why a renewal did not run, put the condition in `when()`/`skip()` and listen for `ScheduledTaskSkipped`, rather than returning early inside the command where the run looks successful. ## Common mistakes - Doing the check inside the task body: the run is recorded as successful even though it did nothing. - Putting expensive logic at the top level of `routes/console.php`: it runs on every `schedule:run` boot, for every minute, whether the task is due or not. - Assuming `environments()` accepts the same wildcard patterns as `App::environment()`. - Passing a boolean computed at the top of the file to `when()`: it is evaluated when the definitions load rather than when the task is due; that is fine for config values but hides intent, and a closure makes the run-time check obvious. ## A worked decision for renewals For the nightly renewal task the layering usually ends up as: the cron expression says *when* (`dailyAt('02:00')`), `environments(['production'])` says *where*, and a `when()` callback reading a kill switch says *whether*. Each layer can be changed independently — the switch without a deploy, the environment list in review, the time in one helper call — and each leaves a different trace when it stops a run.
- In Laravel, why might a when() condition on a scheduled task be better than an early return inside the command?A filter that fails stops the run before it starts and dispatches `ScheduledTaskSkipped`, which you can log or alert on. An early return inside the command still counts as a successful run with exit code zero, so success hooks fire and monitoring believes the renewals happened.
- In a Laravel schedule, does `->environments(['staging', 'production'])` accept wildcards the way App::environment() does?No. The scheduler checks the current environment with an exact `in_array` comparison against the list, while `App::environment()` matches patterns through `Str::is`, so `'prod*'` works there but not in `environments()`. List each environment name explicitly.
saying these in an interview costs you the question
- A scheduled task without environments() runs only in production.
- Chained when() callbacks are combined with OR, so one true is enough.
- when() callbacks run every minute even when the task's cron is not due.
- environments() accepts wildcard patterns such as 'prod*' like App::environment().
- skip() returning true delays the task to the next minute instead of cancelling it.