# Laravel Schedule Not Running: Causes and Fixes

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/laravel-schedule-not-running

> Fix a Laravel schedule that won't run: the schedule:run cron entry, PHP binary, routes/console.php, time zones, filters, and stale withoutOverlapping locks.

**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.

_Figure: The numbers are this guide's causes, placed at the step each one breaks. Tasks due in the same minute run one after another unless they use runInBackground(), so a task that hangs holds up every task after it in that run._

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](https://laravel.com/framework/docs/12.x/scheduling). 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.

```bash
# 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php#L106-L150)). `schedule:test` calls the task directly ([ScheduleTestCommand.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ScheduleTestCommand.php#L82-L84)), 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](https://laravel.com/framework/docs/12.x/scheduling#running-the-scheduler)):

```text
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
```

It lives on the server, not in your repository, so a new server or a rebuilt image starts without it. [Cron job not running](https://cronradar.com/blog/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:

```text
* * * * * cd /var/www/app && /usr/bin/php8.3 artisan schedule:run --whisper >> /var/www/app/storage/logs/scheduler.log 2>&1
```

A 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ScheduleWorkCommand.php#L37-L80)), which then reaches `docker logs`:

```yaml
services:
  scheduler:
    image: registry.example.com/billing-app:2026.10.24
    command: ['php', 'artisan', 'schedule:work']
    restart: unless-stopped
```

Run one scheduler container per environment; every replica runs every task (cause 7). If you install cron in the image instead, [cron in Docker](https://cronradar.com/blog/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](https://github.com/docker-library/php/blob/5ec2a6a0dad498e89a91a39454dbb52e98ce2bdb/8.4/trixie/cli/Dockerfile#L141-L209)), 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](https://cronradar.com/blog/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](https://github.com/laravel/framework/blob/v12.70.1/composer.json), [13.x](https://github.com/laravel/framework/blob/v13.38.0/composer.json)); an older CLI stops at autoload with "Composer detected issues in your platform: Your Composer dependencies require a PHP version..." ([Composer source](https://github.com/composer/composer/blob/fac7d53f689dee6254f110f3b0104dfa39e607a9/src/Composer/Autoload/AutoloadGenerator.php#L937-L989)). Every `Schedule::command()` task inherits the binary running `schedule:run` ([php_binary()](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Support/functions.php#L38-L41)). 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L199-L210)).

**The wrong user.** Laravel writes to `storage` and `bootstrap/cache` ([deployment docs](https://laravel.com/framework/docs/12.x/deployment#directory-permissions)). 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](https://laravel.com/framework/docs/11.x/scheduling#defining-schedules)). An application upgraded from Laravel 10 can keep the `schedule()` method in `app/Console/Kernel.php`, which Laravel 11 still supports ([upgrade guide](https://laravel.com/framework/docs/11.x/upgrade#application-structure)). Mixing the two goes wrong:

- In an app on the new structure, a `schedule()` method added to `app/Console/Kernel.php` is never called, because `bootstrap/app.php` binds the framework's own console kernel ([ApplicationBuilder.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php#L58-L70)).
- `routes/console.php` loads only when `bootstrap/app.php` passes it as `withRouting(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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Foundation/Configuration/ApplicationBuilder.php#L176-L178)).
- A scheduled closure needs `->name()` before `withoutOverlapping()` or `onOneServer()`. Without it, Laravel throws a `LogicException` while loading the file ([CallbackEvent.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/CallbackEvent.php#L138-L166)), so `schedule:run` fails 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Foundation/Console/Kernel.php#L299-L304), [Event.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L297-L307)). The server's zone doesn't matter, because Laravel sets PHP's default zone from `app.timezone` as it boots ([LoadConfiguration.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Foundation/Bootstrap/LoadConfiguration.php#L65)), unlike plain cron, which uses the daemon's zone ([cron timezone](https://cronradar.com/blog/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](https://laravel.com/framework/docs/12.x/scheduling#timezones)). 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L272-L340)):

| 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](https://laravel.com/framework/docs/13.x/scheduling#pausing-scheduled-tasks)).

**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](https://laravel.com/framework/docs/12.x/configuration#configuration-caching)). 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ManagesAttributes.php#L145-L154), [CacheEventMutex.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/CacheEventMutex.php#L41-L90)). `schedule:run` releases it when the task finishes or throws ([Event.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L128-L230)). If `schedule:run` itself dies first, the lock stays until it expires, after 24 hours by default ([docs](https://laravel.com/framework/docs/12.x/scheduling#preventing-task-overlaps)). 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)](https://man7.org/linux/man-pages/man7/pid_namespaces.7.html)).

`schedule:list` marks a task whose lock exists with `Has Mutex ›` ([ScheduleListCommand.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ScheduleListCommand.php#L207)). If no copy of the task is running, the lock is stale:

```bash
# 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-cache
```

`schedule:clear-cache` also releases a lock held by a run that is still working ([ScheduleClearCacheCommand.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ScheduleClearCacheCommand.php#L31-L49)), 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:

```php
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](https://github.com/laravel/framework/blob/v13.38.0/src/Illuminate/Console/Scheduling/Event.php#L889-L906)). A `runInBackground()` task's lock is released by a separate `php artisan schedule:finish` after the command exits ([CommandBuilder.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/CommandBuilder.php#L46-L63)). For locks outside Laravel, see [prevent overlapping cron jobs](https://cronradar.com/blog/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](https://laravel.com/framework/docs/12.x/scheduling#running-tasks-on-one-server)); 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](https://laravel.com/framework/docs/12.x/scheduling#background-tasks)). Laravel sets no time limit on a scheduled command ([Event.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L199-L210)), 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php#L176-L220)).

Two things end the whole run, because `schedule:run` evaluates them outside that per-task error handling ([ScheduleRunCommand.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php#L118-L135)): 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L41)); `->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](https://laravel.com/framework/docs/12.x/scheduling#interrupting-sub-minute-tasks), [ScheduleRunCommand.php](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/ScheduleRunCommand.php#L231-L280)).

## 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](https://docs.cronradar.com/integrations/laravel) 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`:

```php
// 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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L180-L192)). So the helper catches everything and gives each request five seconds:

```php
<?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
<?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](https://github.com/laravel/framework/blob/v12.70.1/src/Illuminate/Console/Scheduling/Event.php#L578-L606)). [How to monitor Laravel scheduled tasks](https://cronradar.com/blog/laravel-cron-monitoring) 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.
