DEV Community

Cover image for Laravel Scheduler Not Running: Cron and schedule:run Fixes
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Scheduler Not Running: Cron and schedule:run Fixes

You defined a scheduled task, deployed, and nothing happens. No error, no log line, no email. The scheduler is one of the most frustrating parts of Laravel to debug because it has two separate layers that can fail silently: cron has to start Laravel every minute, and Laravel then decides which tasks are due. Fixing it is mostly a matter of finding out which layer is broken, and that takes three commands.

Everything below was run on Laravel 13.40.0 and PHP 8.3 with a real cron daemon on Debian, not just simulated.

How the scheduler works

Laravel doesn't run its own background process. You add a single cron entry that calls php artisan schedule:run every minute, and Laravel checks which of your tasks are due at that moment. Tasks are defined in routes/console.php (Laravel's docs also allow withSchedule in bootstrap/app.php). The entry from the official docs:

* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode

The test app for this article has two every-minute tasks and one daily task:

// routes/console.php
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    file_put_contents(storage_path('logs/heartbeat.log'), now()->format('H:i:s') . " heartbeat\n", FILE_APPEND);
})->everyMinute()->name('heartbeat');

Schedule::call(function () {
    sleep(25);
})->everyMinute()->name('slow-report')->withoutOverlapping();

Schedule::call(fn () => null)->dailyAt('09:00')->timezone('Asia/Colombo')->name('morning-digest');
Enter fullscreen mode Exit fullscreen mode

Step 1: Does Laravel see your tasks?

$ php artisan schedule:list

  *  * * * *  heartbeat ........................ Next Due: 46 seconds from now
  *  * * * *  slow-report ...................... Next Due: 46 seconds from now
  30 3 * * *  morning-digest ..................... Next Due: 12 hours from now
Enter fullscreen mode Exit fullscreen mode

If your task is missing here, the problem is in the code: the schedule is defined in the wrong file, the file has an error, or the config cache is stale (php artisan config:clear). Cron is not involved yet. Note that schedule:list only tells you what is next due; it doesn't tell you what already ran.

Step 2: Does it run when you start it by hand?

$ php artisan schedule:run

  2026-11-10 14:41:07 Running [heartbeat] ........................ 0.21ms DONE
  2026-11-10 14:41:07 Running [slow-report] ......................... 25s DONE
Enter fullscreen mode Exit fullscreen mode

This is the key split. If it works by hand but not on its own, the problem is cron (or how cron starts PHP). If it doesn't work by hand either, the problem is on the Laravel side, and the causes further down apply.

Step 3: Is cron actually firing it?

First check that the entry exists for the right user with crontab -l. Then make cron prove it's running. The cleanest test is to have one task that leaves a trace, like the heartbeat above. With a correct entry installed, the log fills up on its own:

$ crontab -l
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1

$ cat storage/logs/heartbeat.log
14:43:01 heartbeat
Enter fullscreen mode Exit fullscreen mode

(In my run the path was a scratch directory; I show /var/www/app here.) If the entry exists and the heartbeat never appears, work through the cron-side causes next.

Cron-side causes

A wrong path, hidden by > /dev/null

The official one-liner throws away all output, so a wrong cd path fails without a trace. Switching the redirect to a log file looks like the fix, but there is a trap. I installed two lines with a bad path:

* * * * * cd /wrong/path && php artisan schedule:run >> /tmp/sched-cron-bad.log 2>&1
* * * * * (cd /wrong/path && php artisan schedule:run) >> /tmp/sched-cron.log 2>&1
Enter fullscreen mode Exit fullscreen mode
$ cat /tmp/sched-cron-bad.log
cat: /tmp/sched-cron-bad.log: No such file or directory
$ cat /tmp/sched-cron.log
/bin/sh: 1: cd: can't cd to /wrong/path
Enter fullscreen mode Exit fullscreen mode

In the first line the redirect belongs only to the php command, which never ran, so the cd error went to cron's own error channel (cron normally tries to mail it to the user, which is rarely read) and the log file was never created. Wrapping both commands in parentheses sends everything to the log. This is my recommended form:

* * * * * (cd /var/www/app && /usr/bin/php artisan schedule:run) >> /var/log/laravel-scheduler.log 2>&1
Enter fullscreen mode Exit fullscreen mode

Note that schedule:run prints a line every minute, even when nothing is due, so that log grows; rotate it, or point it at a path you clean up.

Cron's minimal PATH

Cron does not load your shell profile. I had cron write out its own environment next to my interactive one:

$ cat /tmp/cronpath.txt
PATH=/usr/bin:/bin
php: /usr/bin/php
composer:

$ echo $PATH      # my interactive shell
/home/claude/.npm-global/bin:...:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Enter fullscreen mode Exit fullscreen mode

(The interactive PATH is shortened.) Cron's PATH was just /usr/bin:/bin, so php was found in /usr/bin but composer, installed in /usr/local/bin, was not. If PHP lives somewhere unusual on your server (a versioned directory, a control-panel install, a Homebrew path), a bare php in the crontab will fail with "not found". Use the absolute path from command -v php. This was tested with Debian's cron; other cron implementations and distributions may set a different default PATH, so print it on your server the same way.

Wrong user

Each user has a separate crontab. If the entry is in root's crontab but your files belong to the web user, log and cache files created by the scheduler can end up owned by root and later break web requests. I did not test this, but the safe habit is to install the entry for the same user that runs your application (crontab -u www-data -e on many Debian-based systems).

Laravel-side causes

The app is in maintenance mode

The docs say scheduled tasks don't run while the application is in maintenance mode. What the command prints is the confusing part:

$ php artisan down
$ php artisan schedule:run

   INFO  No scheduled commands are ready to run.
Enter fullscreen mode Exit fullscreen mode

That message makes it look like nothing is due, when in fact everything is being skipped. If your deploy script runs php artisan down and a step fails before php artisan up, your scheduler stays dead until someone notices. Tasks that must run during maintenance can opt in with evenInMaintenanceMode(), per the docs.

A stuck withoutOverlapping lock

withoutOverlapping() stops a task from starting while the previous run is still going. That's exactly what happened when I started a second schedule:run while slow-report was running: it ran heartbeat and skipped the slow task, without printing anything about the skip.

$ php artisan schedule:run      # while slow-report is still running

  2026-11-10 14:41:35 Running [heartbeat] ........................ 0.19ms DONE
Enter fullscreen mode Exit fullscreen mode

The lock is held in your cache. If the process is killed mid-task (a deploy restart, an out-of-memory kill, a kill -9), the lock is not released. I killed a run halfway through the slow task and ran the scheduler again:

$ php artisan schedule:run      # after the crash

  2026-11-10 14:42:03 Running [heartbeat] ........................ 0.26ms DONE
Enter fullscreen mode Exit fullscreen mode

With the database cache driver I looked at the lock row directly, and it was still there after the crash:

laravel-cache-framework/schedule-0b137ec5...1314 expires in 24 hours
Enter fullscreen mode Exit fullscreen mode

The slow task is skipped, silently, until the lock expires. The docs give the default expiry as 24 hours, and the row above agrees, so a crashed run can silence a task for a day. You can pass a shorter lifetime in minutes, withoutOverlapping(10), and you can clear stuck locks:

$ php artisan schedule:clear-cache
$ php artisan schedule:run

  2026-11-10 14:42:03 Running [heartbeat] ........................ 0.21ms DONE
  2026-11-10 14:42:03 Running [slow-report] ......................... 25s DONE
Enter fullscreen mode Exit fullscreen mode

(The command also printed an INFO Deleting mutex for [] line, which I trimmed. The task ran again afterwards, which is what matters.) Choose the expiry to be a little longer than the longest legitimate run of the task, not 24 hours.

Timezone surprises

Tasks run on the server's schedule clock, which comes from your app configuration, unless you set a timezone on the task. In the test app, dailyAt('09:00')->timezone('Asia/Colombo') showed up in schedule:list as 30 3 * * *, which is 03:30 UTC, the same instant as 09:00 in Colombo (UTC+5:30). If a daily task fires at an hour you don't expect, check schedule:list against the timezone you meant. The docs also warn that around daylight saving changes a task may run twice or not at all, and recommend avoiding timezone scheduling where you can.

onOneServer and the cache driver

If you run the scheduler on several servers and use onOneServer(), the docs require the default cache driver to be database, memcached, dynamodb or redis, with every server talking to the same central cache. With the file or array driver each server has its own locks, which defeats the purpose. I did not test a multi-server setup.

Local development: skip cron

On your own machine you don't need a cron entry. schedule:work runs in the foreground and invokes the scheduler every minute until you stop it:

$ php artisan schedule:work

   INFO  Running scheduled tasks.

  2026-11-10 14:48:00 Running [heartbeat] ........................ 0.25ms DONE
Enter fullscreen mode Exit fullscreen mode

(I stopped it after 80 seconds and removed the slow-report lines. Each minute it adds another batch of lines at the top of the minute.) It's a convenient test, but it doesn't replace cron on a server, since it stops when the terminal or process does.

Know when it stops working

The default setup tells you nothing when the scheduler dies. The docs describe appendOutputTo() for command output, onFailure() callbacks for tasks that exit non-zero, and ping methods for monitoring services. The simplest useful addition is a dead-man's switch: a task that pings a monitoring URL every minute (or a heartbeat file like the one above) and an alert when it goes quiet. Without something like that, a stuck lock or a failed deploy can silence your scheduled tasks for days.

Common mistakes

  • Adding one cron entry per task. You need a single entry that runs schedule:run every minute; Laravel does the rest.
  • Using> /dev/null 2>&1while debugging. It hides the exact error you need. Log to a file until it works.
  • Relying on a barephpin the crontab. Cron's PATH is minimal; use the full path.
  • Leaving the app in maintenance mode after a failed deploy. The scheduler stops without a clear message.
  • Assuming a task that does not run has failed. A held withoutOverlapping lock skips it silently.
  • Expectingschedule:listto show run history. It only shows what is due next.

Alternatives

On Linux, a systemd timer can replace cron for the one-minute trigger; the Laravel side (schedule:run) stays the same. I haven't tested that setup here. If the scheduler is just dispatching queue jobs, also check the worker side: see Laravel queue jobs not processing.

Related Dev Talk articles

Frequently asked questions

Do I need a cron entry for every scheduled task?

No. One entry running schedule:run every minute is enough. Laravel decides which tasks are due.

Can the scheduler run more often than once a minute?

Cron itself runs at most once per minute, as the docs note. Laravel supports sub-minute tasks (for example everyTenSeconds()), in which case schedule:run keeps running until the end of the current minute. The docs recommend dispatching queued jobs or background commands for that work.

Do I need to restart anything after changing the schedule?

Cron starts a fresh PHP process each minute, so new code is picked up on the next run. For long-running sub-minute runs, the docs suggest calling schedule:interrupt in your deployment script. I did not test that command.

How do I check that the scheduler is really running?

Add a tiny task that writes to a file or pings a monitoring URL every minute, as in the heartbeat example.

Sources


Originally published on DEV Talk.

Top comments (0)