The Laravel scheduler is one of the nicest parts of the framework. You describe your tasks in PHP, add one cron entry to the server, and Laravel takes care of the rest. It is also one of the easiest places for things to go quietly wrong. A scheduled task that stops running does not throw an exception in your face. It just does not happen, until a client asks why the invoices were not sent.
This article explains how the scheduler works, the most common ways scheduled tasks fail silently, and how to monitor them properly.
How the Laravel scheduler works
You define your schedule in routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('invoices:send')->dailyAt('06:00');
Schedule::command('backup:run')->daily()->withoutOverlapping();
Schedule::job(new PruneOldReports)->weekly();
Then the server runs the scheduler every minute with a single cron entry:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Every minute, schedule:run checks which tasks are due and runs them. Locally you can use php artisan schedule:work, which runs the scheduler in the foreground. Two commands help you check your setup:
php artisan schedule:listshows every task and when it runs next.php artisan schedule:testlets you pick a task and run it right away.
Eight ways scheduled tasks fail silently
1. The cron entry is missing
The most common one. A server is rebuilt or the app moves to a new server, and nobody adds the cron entry again. Nothing errors, because nothing runs.
2. Cron runs as the wrong user or with the wrong PHP
Cron runs with a minimal environment. If the crontab belongs to a different user, or php points to an older PHP version than your web server uses, tasks fail with errors that end up in /dev/null.
3. A stuck overlap lock
withoutOverlapping() prevents a task from starting while the previous run is still going. It does that with a lock in the cache. If a run is killed halfway, for example during a deploy or a server restart, the lock can stay behind. By default it expires after 24 hours, so a daily task can skip a whole day. Pass a shorter expiry when a task normally finishes quickly:
Schedule::command('reports:generate')->hourly()->withoutOverlapping(10);
4. onOneServer() with the wrong cache driver
onOneServer() makes sure a task runs on only one of several servers. It needs a cache that all servers share, such as Redis, Memcached, DynamoDB or the database driver. With the file cache, each server has its own lock and the task runs everywhere.
5. Maintenance mode
Scheduled tasks do not run while the app is in maintenance mode. If a deploy fails and leaves the app in maintenance mode overnight, every task is skipped. Use evenInMaintenanceMode() for tasks that must always run.
6. Time zones
Tasks run in the time zone of your app unless you set one. dailyAt('06:00') on a server in UTC is 7 or 8 in the morning in Amsterdam, depending on daylight saving time. Set the time zone per task with ->timezone('Europe/Amsterdam'), or for all tasks in your app config.
7. The task runs, but its work does not
Schedule::job() only dispatches a job to the queue. If the queue worker is down, the scheduler reports success while nothing happens. The same is true for commands that catch every exception and return a success exit code.
8. The task fails, and nobody looks
Even when a task fails loudly, the output often goes nowhere. Without alerting, a failing backup can fail every night for months.
Built-in hooks
The scheduler has hooks that help:
Schedule::command('backup:run')
->daily()
->onFailure(fn () => Log::critical('Backup failed'))
->emailOutputOnFailure('ops@example.com');
There are also ping hooks, such as pingOnSuccess() and pingOnFailure(), that call a url after a run. They are the basis of heartbeat monitoring.
These hooks only fire when the task actually runs. They cannot tell you about the most common failures on the list above: a missing cron entry, a task skipped because of a stuck lock, or a scheduler that stopped altogether.
Monitor the absence of a run
To catch silent failures you have to reverse the logic. Instead of waiting for an error, you expect a signal from every task on its schedule, and you raise an alert when that signal does not arrive in time. This is called heartbeat or cron monitoring.
A good setup:
- Knows the schedule. It should know that
invoices:sendruns at 06:00 every day, so it knows when a run is missing. - Has a grace period per task. A backup that sometimes takes 20 minutes should not wake anyone up at minute 5.
- Reports failures with their cause. The exception a task threw is more useful than "task failed".
- Covers every app. If you maintain many client apps, you want one overview, not a separate check per server.
nopanic does this for Laravel apps. The nopanic/laravel package reads your schedule and reports every run, so new tasks are monitored without configuration, and a task that fails, runs late or never runs raises an alert. Tasks outside Laravel report in with a simple ping url.
A short checklist
- The cron entry exists on every server that should run the scheduler, as the right user with the right PHP binary.
php artisan schedule:listshows what you expect.- Tasks with
withoutOverlapping()have a lock expiry that fits how long they run. - Apps on several servers use
onOneServer()with a shared cache. - Critical tasks use
evenInMaintenanceMode(). - Time zones are set explicitly.
- Queue workers are monitored too.
- Every task is monitored for missing runs, not only for failures.
Not sure what a cron expression does? Paste it into our free cron expression explainer to see each field in plain words, the next runs and the matching Laravel method.