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
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');
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
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
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
(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
$ 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
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
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
(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.
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
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
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
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
(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
(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:runevery 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 bare
phpin 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
withoutOverlappinglock skips it silently. -
Expecting
schedule: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
- Laravel queue jobs not processing
- Laravel queues: Redis vs database driver
- Laravel .env configuration: env() vs config()
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)