Laravel Schedule Not Running: Causes and Fixes
Contents
If your Laravel schedule is not running, first confirm that something runs php artisan schedule:run every minute, as the account that owns the application and with the PHP binary it needs; then run php artisan schedule:list to see whether Laravel has loaded the task and when it is next due. Cron, or schedule:work in a container, starts schedule:run each minute, and schedule:run decides which tasks are due. When that part works and a task still doesn't run, it is usually filtered out, skipped by a leftover withoutOverlapping() lock, due in a different time zone than you expect, or stuck behind an earlier task.
This guide covers Laravel 11, 12, and 13, checked against the framework source at v11.57.0, v12.70.1, and v13.38.0 (the latest release of each on October 25, 2026) and the 12.x scheduling documentation. The causes are in the order worth checking.
Start With These Commands#
Run them on the production server. www-data and /var/www/app are examples.
# Is there a scheduler entry, and for which user?
sudo crontab -u www-data -l
sudo grep -rs "schedule:run" /etc/crontab /etc/cron.d/
cd /var/www/app
# Which tasks has Laravel loaded, and when is each one next due?
sudo -u www-data php artisan schedule:list
# PHP version, environment, maintenance mode, time zone, cached config, cache store
sudo -u www-data php artisan about --only=environment,cache,drivers
# Run the scheduler as cron would: same user, nearly empty environment.
# Any task that is due runs for real.
sudo -u www-data env -i PATH=/usr/bin:/bin HOME=/var/www \
sh -c 'cd /var/www/app && php artisan schedule:run'
# Run one task now, whether or not it is due
sudo -u www-data php artisan schedule:test --name="invoices:send"| What you see | Go to |
|---|---|
No crontab entry and nothing in /etc/cron.d/ |
Cause 1 |
php: not found, Could not open input file: artisan, or Composer detected issues in your platform |
Cause 2 |
No scheduled tasks have been defined., or the task missing from the list |
Cause 3 |
| A next due time you didn't expect | Cause 4 |
No scheduled commands are ready to run. in a minute when the task is due |
Causes 5 and 6 |
Has Mutex › next to the task in schedule:list |
Cause 6 |
schedule:run doesn't return |
Cause 8 |
schedule:run prints "No scheduled commands are ready to run." when no due task passed its checks that minute (ScheduleRunCommand.php). schedule:test calls the task directly (ScheduleTestCommand.php), skipping the time, environment, maintenance-mode, when(), and skip() checks, though a held overlap lock still stops it. If the task works there, the problem is in how or when the scheduler reaches it.
1. Nothing Runs schedule:run Every Minute#
Laravel needs one cron entry (docs):
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1It lives on the server, not in your repository, so a new server or a rebuilt image starts without it. Cron job not running covers the general checks: which crontab holds the entry, the user field in /etc/cron.d/ files, syntax, cron's log, and SELinux.
Install the entry for the account that owns the application (sudo crontab -u www-data -e), with absolute paths, and add it to your provisioning code:
* * * * * cd /var/www/app && /usr/bin/php8.3 artisan schedule:run --whisper >> /var/www/app/storage/logs/scheduler.log 2>&1A log file instead of /dev/null keeps the next error. --whisper, in Laravel 12.20 and later, drops the "No scheduled commands are ready to run." line so the file grows only when something runs; leave it out on Laravel 11.
Containers: Run schedule:work#
If a container's main process is PHP-FPM or a web server, nothing starts the scheduler, and a copied crontab does nothing without a cron daemon. A common setup is a second container from the same image running php artisan schedule:work. Laravel documents it for local use, but it's a foreground loop that starts schedule:run at the top of each minute and prints its output (ScheduleWorkCommand.php), which then reaches docker logs:
services:
scheduler:
image: registry.example.com/billing-app:2026.10.24
command: ['php', 'artisan', 'schedule:work']
restart: unless-stoppedRun one scheduler container per environment; every replica runs every task (cause 7). If you install cron in the image instead, cron in Docker explains why the daemon doesn't pass the container's environment to jobs. The official php images build PHP with the default /usr/local prefix (Dockerfile), which isn't on Debian cron's PATH of /usr/bin:/bin, so write /usr/local/bin/php in the entry.
2. Cron Runs It, but Laravel Never Boots#
Cron starts the command as the crontab's owner, in that user's home directory, with a short PATH and none of your shell's setup (cron environment variables).
The wrong PHP binary. Cron's php is the first one on its PATH, which may not be the version PHP-FPM serves your site with. Laravel 11 and 12 require PHP 8.2 or later, and Laravel 13 requires 8.3 (12.x, 13.x); an older CLI stops at autoload with "Composer detected issues in your platform: Your Composer dependencies require a PHP version..." (Composer source). Every Schedule::command() task inherits the binary running schedule:run (php_binary()). Compare php -v in your shell with env -i PATH=/usr/bin:/bin php -v, check php --ini (the CLI can load a different php.ini from PHP-FPM), and use an absolute path such as /usr/bin/php8.3.
The wrong directory. php artisan opens artisan in the current directory, so without cd /var/www/app && the command fails with "Could not open input file: artisan". php /var/www/app/artisan schedule:run also works; Laravel runs command tasks from the application's base path either way (Event.php).
The wrong user. Laravel writes to storage and bootstrap/cache (deployment docs). A scheduler user who can't write there can't log or use the file cache, and a scheduler running as root creates files, such as a new daily log, that the web server's user can't write to afterward. Run it as the account that owns those directories.
3. The Schedule Is Defined Where Laravel Doesn't Load It#
Since Laravel 11, new applications define tasks in routes/console.php with the Schedule facade, or in a withSchedule() callback in bootstrap/app.php (11.x docs). An application upgraded from Laravel 10 can keep the schedule() method in app/Console/Kernel.php, which Laravel 11 still supports (upgrade guide). Mixing the two goes wrong:
- In an app on the new structure, a
schedule()method added toapp/Console/Kernel.phpis never called, becausebootstrap/app.phpbinds the framework's own console kernel (ApplicationBuilder.php). routes/console.phploads only whenbootstrap/app.phppasses it aswithRouting(commands: __DIR__.'/../routes/console.php'). If that argument is missing or the path doesn't exist, Laravel skips the file without an error (ApplicationBuilder.php).- A scheduled closure needs
->name()beforewithoutOverlapping()oronOneServer(). Without it, Laravel throws aLogicExceptionwhile loading the file (CallbackEvent.php), soschedule:runfails and no task runs.
4. The Task Is Due at a Different Time Than You Think#
Laravel matches a task's time in its ->timezone() if it has one, otherwise in schedule_timezone from config/app.php, otherwise in the application's timezone (Kernel.php, Event.php). The server's zone doesn't matter, because Laravel sets PHP's default zone from app.timezone as it boots (LoadConfiguration.php), unlike plain cron, which uses the daemon's zone (cron timezone). So dailyAt('02:00') with 'timezone' => 'America/Chicago' runs at 02:00 Chicago time wherever the server is.
schedule:list shows next due times in app.timezone, and schedule:list --timezone=UTC in UTC. Laravel's docs warn that a task in a zone with daylight saving time may run twice or not at all when the clocks change (docs). Keep app.timezone at UTC, as new applications ship, and give only tasks that must follow local time a ->timezone(), at an hour when the clocks don't change.
5. A Constraint Filters the Task Out#
A due task still has to pass its constraints (Event.php):
| Constraint | Skips the task when |
|---|---|
->environments('production') |
The environment, from APP_ENV, isn't listed |
->when(), ->between() |
A callback returns false, or the time is outside the window |
->skip(), ->unlessBetween() |
A callback returns true, or the time is inside the window |
Maintenance mode (php artisan down) |
Always, unless the task has ->evenInMaintenanceMode() |
php artisan schedule:pause (Laravel 13) |
Always, unless the task has ->evenWhenPaused() |
Laravel logs none of these. when(), skip(), and overlap-lock skips dispatch a ScheduledTaskSkipped event you can listen for; environment and maintenance-mode checks drop the task with no event. about --only=environment shows the environment and maintenance mode. A deploy that runs php artisan down and fails before php artisan up stops the whole schedule. On Laravel 13, php artisan schedule:continue ends a pause (13.x docs).
Cached configuration freezes all of this. After php artisan config:cache, Laravel stops reading .env, and env() returns only variables from the real environment (configuration docs). A changed APP_ENV or time zone takes effect only after the next config:cache, and env() in a when() callback or a command returns null. about --only=cache shows whether configuration is cached; read settings through config() and re-cache on every deploy.
6. A Stale withoutOverlapping Lock Skips Every Run#
withoutOverlapping() holds a lock in the cache store while the task runs and skips the task while that lock exists (ManagesAttributes.php, CacheEventMutex.php). schedule:run releases it when the task finishes or throws (Event.php). If schedule:run itself dies first, the lock stays until it expires, after 24 hours by default (docs). A reboot, an out-of-memory kill, kill -9, or a container stopped mid-task can do it; when a container's main process exits, the kernel kills every process left in it (pid_namespaces(7)).
schedule:list marks a task whose lock exists with Has Mutex › (ScheduleListCommand.php). If no copy of the task is running, the lock is stale:
# Is a copy still running, and for how long?
ps -eo pid,etime,args | grep "[a]rtisan"
# Release the overlap locks of every task in the schedule
php artisan schedule:clear-cacheschedule:clear-cache also releases a lock held by a run that is still working (ScheduleClearCacheCommand.php), so check first. Then set an expiry in minutes a little above the task's longest normal run, so a crash blocks it for that long at most:
Schedule::command('invoices:send')
->dailyAt('02:00')
->withoutOverlapping(120);To stop a hung Schedule::command() task, kill the task's own process, not schedule:run: schedule:run then records the failure and releases the lock. Killing schedule:run leaves it, except that Laravel 13.2 and later, with the pcntl extension, release a foreground task's lock on SIGTERM, SIGINT, or SIGQUIT (Event.php in 13.38.0). A runInBackground() task's lock is released by a separate php artisan schedule:finish after the command exits (CommandBuilder.php). For locks outside Laravel, see prevent overlapping cron jobs.
7. Several Servers: It Runs Everywhere, or Somewhere Else#
Every server running schedule:run runs every due task. With onOneServer(), only the first server to take an atomic lock runs it, and the rest print "Skipping [...] because the command already ran on another server." (Laravel 12 and 13). It needs the database, memcached, dynamodb, or redis cache driver, with every server using the same cache server (docs); with the file store, each server checks only its own lock. Closures need ->name(), and Schedule::job() entries for the same class with different arguments need different names. A task missing on the server you're watching may have run, or failed, on another one.
8. An Earlier Task Hangs, or the Run Stops Early#
Tasks due in the same minute run one after another in the order you define them, unless they use runInBackground() (docs). Laravel sets no time limit on a scheduled command (Event.php), so one that hangs holds up every task after it in that run; cron's next schedule:run still handles the next minute. A task that throws or exits non-zero is caught and reported, and the next task runs (ScheduleRunCommand.php).
Two things end the whole run, because schedule:run evaluates them outside that per-task error handling (ScheduleRunCommand.php): a when() or skip() callback that throws, and an overlap or onOneServer() check that can't reach the cache store, for example when the database behind the database store is down. Every later task that minute is skipped.
Run schedule:run by hand and watch where it stops. Give the command's network and database calls their own timeouts, so a hang becomes a failure; move slow commands to ->runInBackground() (only command and exec tasks) or a queue; and define the most important tasks first.
A command task's output, error message included, goes to /dev/null by default (Event.php); ->appendOutputTo(storage_path('logs/invoices-send.log')) keeps it. Laravel 12.18 and later also log a non-zero exit through your exception handler as "Scheduled command [...] failed with exit code [...]".
schedule:interrupt doesn't stop a hung task. It ends only the loop schedule:run keeps going through the minute for tasks scheduled more often than once a minute, so a deploy doesn't keep running old code (docs, ScheduleRunCommand.php).
Get Alerted the Next Time a Task Stops#
Most of these causes are silent. A monitor outside the server that expects each task on its schedule turns that silence into an alert. Monitor each task that matters, not only the schedule:run entry, because schedule:run can start every minute while a lock or filter keeps one task from ever firing.
With CronRadar, that means HTTP check-ins. There is no CronRadar SDK for PHP or Laravel you can install today (checked September 20, 2026; the Laravel integration guide has the current notice), and nothing discovers scheduled tasks automatically, so each task reports with its own check-in. Keep the key in your deployment's secrets as CRONRADAR_API_KEY, with a separate CronRadar application and key per environment, and read it through configuration so it survives config:cache:
// config/services.php
'cronradar' => [
'key' => env('CRONRADAR_API_KEY'),
],A before() hook runs inside the task's error handling: if it throws, Laravel releases the lock, reports the task as failed, and never starts it (Event.php). So the helper catches everything and gives each request five seconds:
<?php
namespace App\Support;
use Illuminate\Console\Scheduling\Event;
use Illuminate\Support\Facades\Http;
use Throwable;
final class CronRadar
{
/** Report a task's start, success, and failure. $schedule is five-field cron in UTC. */
public static function monitor(Event $event, string $key, string $schedule, ?int $graceSeconds = null): Event
{
$query = array_filter(['schedule' => $schedule, 'gracePeriod' => $graceSeconds]);
return $event
->before(fn () => self::checkIn($key, 'start', $query))
->onSuccess(fn () => self::checkIn($key, 'complete', $query))
->onFailure(fn () => self::checkIn($key, 'fail', $query + [
'message' => "Scheduled task {$key} exited with a failure",
]));
}
public static function checkIn(string $key, string $event = '', array $query = []): void
{
$apiKey = config('services.cronradar.key');
if (! $apiKey) {
return;
}
$url = 'https://cron.life/api/v1/ping/'.rawurlencode($key).($event === '' ? '' : "/{$event}");
try {
Http::timeout(5)
->connectTimeout(5)
->withBasicAuth($apiKey, '')
->withQueryParameters($query)
->send('POST', $url);
} catch (Throwable $e) {
// Monitoring problems must never stop or fail the task.
error_log("CronRadar check-in failed for {$key}: {$e->getMessage()}");
}
}
}Wrap each task in routes/console.php, with a heartbeat for the scheduler itself defined first so a slow task can't delay it:
<?php
use App\Support\CronRadar;
use Illuminate\Support\Facades\Schedule;
Schedule::call(fn () => CronRadar::checkIn('laravel-scheduler', '', ['schedule' => '*/5 * * * *']))
->everyFiveMinutes()
->name('cronradar-scheduler-heartbeat');
CronRadar::monitor(
Schedule::command('invoices:send')->dailyAt('02:00')->withoutOverlapping(120),
'invoices-send',
'0 2 * * *',
graceSeconds: 900,
);The heartbeat catches causes 1 to 3 and maintenance mode. The per-task monitor catches the rest: a run skipped by a lock or filter sends no start, so CronRadar sees a missed run. Its overdue check runs once a minute; a monitor shows warning once the expected time passes and turns critical, with an incident, only when it is past its grace period and at least two minutes late. A fail check-in opens an incident immediately. A start with no finish goes critical as stuck once the task's next scheduled time plus grace passes; there is no per-task runtime limit, so the timeouts in cause 8 still matter.
CronRadar reads schedules as five-field cron in UTC, so dailyAt('02:00') is 0 2 * * * only while the task's zone is UTC. For a task with ->timezone('America/New_York'), use the earlier of its two UTC times and a grace period of at least an hour plus its usual run: 06:00 New York time is 0 10 * * * with graceSeconds: 4500 for a run under 15 minutes, and during standard time the monitor shows warning for that hour each day. If when() or between() skips runs on purpose, send the schedule the task really runs on, or let it run and exit early. The schedule and grace period apply only when a check-in creates the monitor; change an existing one in the dashboard. Alerts go to email, Slack, a webhook, Discord, or PagerDuty.
Laravel's pingBefore(), thenPing(), pingOnSuccess(), and pingOnFailure() can call the same URLs and report a failed request instead of failing the task, but they wait up to 30 seconds unless you bind your own Guzzle client, and the API key would sit in the URL (Event.php). How to monitor Laravel scheduled tasks covers those hooks and other options.
Frequently Asked Questions#
Why does schedule:run say "No scheduled commands are ready to run"?#
No due task passed its checks that minute. Either nothing is due, which schedule:list shows, or the task was dropped by an environment restriction, maintenance mode, a when(), skip(), or between() constraint, or a withoutOverlapping() lock. If schedule:list prints "No scheduled tasks have been defined.", Laravel hasn't loaded your schedule at all.
How do I clear a stuck withoutOverlapping lock?#
Check that no copy of the task is still running, then run php artisan schedule:clear-cache, which releases the lock of every task in the schedule. Pass an expiry in minutes, such as withoutOverlapping(120), so a future crash doesn't block the task for the default 24 hours.
Do I need cron for Laravel scheduling in Docker?#
You need something that starts schedule:run every minute, and a container has no cron daemon unless you add one. The common choice is a separate container from the same image running php artisan schedule:work. Run one per environment, or every task runs once per replica.
Why does my scheduled task run on every server?#
Every server that runs the scheduler runs every due task. Add ->onOneServer(), and use a database, memcached, dynamodb, or redis cache store that all servers share. Scheduled closures need a ->name() before onOneServer().
Where do I define the schedule in Laravel 11?#
In routes/console.php with the Schedule facade, or in a withSchedule() callback in bootstrap/app.php. routes/console.php loads only if bootstrap/app.php passes it to withRouting(commands: ...). Apps upgraded from Laravel 10 can keep their app/Console/Kernel.php schedule.
Put this guide to work
Connect your Laravel job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The Laravel SDK is temporarily unavailable on Packagist. Use manual HTTP reporting below. This setup does not discover scheduled tasks automatically.