Troubleshooting · Laravel
Laravel scheduler not running, or looking for an alternative?
Why Laravel's scheduler silently stops — the missing cron entry, wrong user, env, and overlapping tasks — and how to fix it, or replace it.
Laravel’s scheduler is driven by a single system cron entry. If that entry is wrong — or the environment under it is — every scheduled task silently stops. Here’s the checklist.
1. The one cron entry must exist
Laravel needs exactly one crontab line calling schedule:run every minute. Confirm
it’s there for the correct user (usually your deploy user, not root):
crontab -l
It should read:
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
If it’s missing, add it with crontab -e. A common deploy mistake is adding it as
root while the app runs as www-data (or vice versa).
2. Use the right PHP binary and absolute path
Under cron, php may not resolve, or may be the wrong version. Use the full path
and the project’s path:
* * * * * cd /var/www/app && /usr/bin/php8.3 artisan schedule:run >> /dev/null 2>&1
3. The environment differs from your shell
Cron doesn’t load your shell profile, so anything you set there is missing.
Laravel reads .env, which is fine — but if your tasks shell out to other
binaries, give them an explicit PATH. Also make sure APP_ENV and queue/cache
config match production.
4. You silenced the output and now can’t debug
>> /dev/null 2>&1 hides everything, including errors. Temporarily log it:
* * * * * cd /var/www/app && php artisan schedule:run >> storage/logs/schedule.log 2>&1
Then run php artisan schedule:run by hand and read the output — most failures
(permissions, missing env, DB connection) show up immediately.
5. Tasks overlap or hang
A long task that runs every minute can pile up. Use Laravel’s guards so a slow run doesn’t block the next:
$schedule->command('reports:build')
->hourly()
->withoutOverlapping()
->onOneServer();
The deeper problem: the scheduler can stop and stay quiet
If schedule:run stops firing — the server rebooted, the crontab was wiped on
deploy, PHP was upgraded — Laravel has no way to tell you. Your queued reports and
emails just stop.
Ping a heartbeat from a scheduled task so you know the scheduler itself is alive:
$schedule->call(function () {
Http::timeout(10)->get('https://ping.steadycron.com/<your-ping-token>');
})->everyFifteenMinutes();
If that ping goes missing, SteadyCron alerts you — the scheduler is down, before your users notice.
Looking for a Laravel Scheduler alternative entirely?
If you’re tired of fixing this same class of problem — a wiped crontab, a
server migration that drops the one line everything depends on, a schedule:run
that’s technically firing but silently throwing — you don’t have to keep the
single point of failure at all.
Most $schedule->command(...) and $schedule->call(...) entries can become a
route instead:
// routes/api.php
Route::post('/cron/build-reports', function () {
Artisan::call('reports:build');
return response()->json(['ok' => true]);
})->middleware('auth.cron-token');
Then SteadyCron calls that endpoint on schedule — with its own retry/backoff,
timeout, and execution log — instead of depending on a system cron entry
existing, on the right PHP binary, or on schedule:run getting invoked at all.
The schedule now lives in SteadyCron, survives server migrations untouched, and
every run shows up with its status code and response, not just whatever made
it into storage/logs.
This is the same idea covered in more depth on the Docker cron alternative page — moving the trigger outside the process that’s supposed to just serve requests.