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.