← Blog

Your Laravel scheduler isn't running. Here's the ten-minute checklist

You wrote the task, deployed it, and nothing happened. No error, no log line, no email. schedule:run is supposed to fire every minute and it apparently does — or does it?

This is a debugging problem with an unusual shape: the evidence you want is the evidence that was never produced. Below is the order I actually work through, cheapest checks first. Nine times out of ten it ends before item five.

1. Does Laravel know about the task at all?

Start inside the application, before touching the server:

php artisan schedule:list

This prints every registered task and the next time it is due. If your task isn’t in that list, stop — the problem is in your code, not in cron. Usual causes: the definition sits in a file that never loads, or you added it to routes/console.php on one branch and deployed another, or a merge dropped it. In Laravel 11 and later the schedule lives in routes/console.php (or in withSchedule() in bootstrap/app.php); code left behind in an old app/Console/Kernel.php after an upgrade is registered by nobody.

If it is in the list, run the scheduler by hand, as the user your application runs as:

cd /path/to/app && php artisan schedule:run

You’ll see which tasks it considered and which it ran. A task that runs correctly here but never runs on its own tells you the problem is in the cron layer — items 2 and 3. A task that Laravel skips here is being filtered — item 5.

2. The crontab belongs to the wrong user

The single most common cause. crontab -l shows the crontab of whoever you are right now. You SSH in as root or as your own account, see an empty list, and conclude nothing is scheduled — while the entry lives under deploy or www-data. Or the reverse: you added the entry as yourself, and the application user has none.

sudo crontab -l -u deploy

Check the user your PHP-FPM pool and deployments run as. And if the entry is genuinely there, confirm cron itself is alive:

systemctl status cron

On managed platforms you don’t own the crontab at all. Forge keeps scheduled jobs in its own panel; Laravel Cloud manages the scheduler for you. In both cases a job can be disabled in the interface while your code looks perfect.

3. Cron’s environment is not your shell

Cron runs with a minimal PATH and no shell profile. Two failures follow from that.

php may not resolve at all, or may resolve to a different PHP than the one you use interactively — an old 8.1 on a box that now runs 8.4, with a version constraint your app no longer satisfies. Use an absolute path:

* * * * * cd /path-to-your-project && /usr/bin/php8.4 artisan schedule:run >> /dev/null 2>&1

And the entry ends with >> /dev/null 2>&1, which throws away exactly the message that would explain everything. Temporarily point it at a file instead:

* * * * * cd /path-to-your-project && /usr/bin/php8.4 artisan schedule:run >> /tmp/schedule.log 2>&1

Give it two minutes, then read /tmp/schedule.log. Permission errors on storage/, a database the CLI user can’t reach, a missing .env — they all surface here and nowhere else.

4. The scheduler is paused, or the app is down

Two switches that survive deploys and are easy to forget.

Recent Laravel versions ship schedule:pause, meant for incidents. It does what it says, indefinitely, until someone runs schedule:continue. If a task must run regardless, mark it evenWhenPaused().

Maintenance mode does the same thing implicitly: while php artisan down is in effect, scheduled tasks do not run. That’s deliberate — Laravel would rather not have your jobs interfere with a migration — but a forgotten maintenance flag on one server in a pool looks exactly like a broken scheduler. evenInMaintenanceMode() exempts a task.

5. A filter is skipping the task, silently

Filters are the quietest failure in the whole list, because a skipped run looks identical to a run that never happened.

->environments(['production']) compares against APP_ENV. If the server sets APP_ENV=prod, or live, or the value drifted during a migration between hosts, the task is skipped forever and nothing says so.

->when() and ->skip() take closures that may quietly depend on the database, on a feature flag, on a cached value. ->between('9:00', '17:00') combined with a timezone you didn’t expect can exclude the entire window you were testing in.

You don’t have to guess. Laravel dispatches Illuminate\Console\Events\ScheduledTaskSkipped, and a five-line listener that logs the task name turns an invisible skip into a log entry:

Event::listen(function (ScheduledTaskSkipped $event) {
    Log::info('scheduled task skipped', ['task' => $event->task->getSummaryForDisplay()]);
});

ScheduledTaskFailed and ScheduledTaskFinished exist too, and are worth wiring up once, permanently.

6. A stuck withoutOverlapping lock

withoutOverlapping() takes a cache lock and releases it when the task finishes. If the process is killed — OOM killer, a deploy mid-run, a hard restart — the lock is never released, and by default it expires after 24 hours. For a task that runs every five minutes, that’s 288 skipped runs, silently.

php artisan schedule:clear-cache

That clears the scheduler’s locks. If your tasks are short, cap the lock so this can’t cost you a day: withoutOverlapping(10) expires it after ten minutes.

There’s a related trap with onOneServer(): it needs a cache driver with atomic locks — database, redis, memcached or dynamodb — shared by all servers. Point two servers at their own local file cache and each one happily takes “the” lock, so the job runs everywhere instead of once.

7. Timezones and the hour that happens twice

Laravel runs the schedule in the app’s timezone unless you set schedule_timezone, or ->timezone() per task. Change the server’s timezone without changing either and a task moves by hours without a single code change.

Daylight saving is worse: on the switch, a task scheduled inside the affected hour runs twice or not at all. Laravel’s own documentation recommends avoiding timezone scheduling where you can, which is good advice — schedule in UTC and convert at the edges.

8. Sub-minute tasks and deploys

If any task uses everySecond() or everyThirtySeconds(), schedule:run doesn’t exit immediately — it keeps running until the end of the minute. That means an instance started before your deploy continues executing the old code, which can look like your fix simply didn’t take. Add php artisan schedule:interrupt to the end of the deploy script.

When the checklist finds nothing

Sometimes everything above is clean because the task did run — it just did nothing useful, or it died halfway. Note what all eight items have in common: each one is discovered after a human notices something is missing. That’s the actual problem. The scheduler has no way to tell you it stopped, because “stopped” produces no event.

The fix is a monitor outside the process that expects a signal on a schedule and complains when it doesn’t arrive — a dead man’s switch. Laravel gives you the hooks for it (pingBefore, thenPing, pingOnSuccess, pingOnFailure), and any external service can receive them. We wrote about wiring that up, including the copy-paste problem it creates, in monitoring Laravel scheduled jobs; the short version with setup steps lives on the Laravel scheduler monitoring page.

Whichever service you pick — Healthchecks.io, Cronitor, Dead Man’s Snitch or CronAlive — the property you want is the same: something that notices the absence of a ping while you’re asleep, so that this checklist gets used the morning the job breaks and not the morning you finally need its output.

Found a cause that isn’t on this list? Write to support@cronalive.com — I’d like to make it longer.


CronAlive monitors cron jobs, scheduled tasks and HTTP endpoints: if a job stops pinging on schedule, you get an alert. Start with the guide or see the plans.