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.