Pixel Pete, the CanaryHub canaryCanaryHub

Laravel Scheduler: How to Set It Up and Actually Monitor It

Laravel Scheduler: How to Set It Up and Actually Monitor It

Quick answer

Laravel's task scheduler needs exactly one cron entry, `* * * * * php artisan schedule:run`, which evaluates every schedule defined in routes/console.php each minute. Laravel ships pingOnSuccess() and pingOnFailure() hooks for notifying an external URL about a task's outcome, but none of that fires if the cron entry itself goes missing or the server is down, so the scheduler process needs its own heartbeat.

Laravel's task scheduler only needs one line in your server's crontab: * * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1. Every minute, schedule:run reads the schedule you defined in routes/console.php and runs whatever is due. Laravel also ships hooks, pingOnSuccess() and pingOnFailure(), that can notify an external URL about how a task went, but they share a blind spot: none of it runs if cron stops calling schedule:run at all.

How does Laravel's task scheduler work?

Instead of one crontab line per job, you register every scheduled task in code and let Laravel decide what's due. The usual place is routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('invoices:generate')->dailyAt('02:00');
Schedule::call(fn () => DB::table('sessions')->where('last_activity', '<', now()->subDays(30))->delete())
    ->daily();

php artisan schedule:list prints every registered task and the next time it's due, which is the fastest way to sanity-check a schedule after a deploy.

What's the one cron entry you actually need?

Just this, on the server, not your local machine:

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

schedule:run itself doesn't run anything directly. It checks every task registered in routes/console.php against the current time and only executes the ones that are due that minute. That's the whole point of the design: your schedule lives in version control instead of scattered across whatever crontab lines someone added by hand over the years. It also means this one line is a single point of failure. If it's missing, mistyped, or the process that owns it dies, every scheduled task in your application silently stops, and nothing in Laravel will tell you.

How do you schedule a command, job, or closure?

Three ways, depending on what you're scheduling:

use App\Jobs\SendWeeklyDigest;
use Illuminate\Support\Facades\Schedule;

// An Artisan command
Schedule::command('emails:send', ['--force'])->daily();

// A queued job, dispatched onto a specific queue and connection
Schedule::job(new SendWeeklyDigest, 'emails', 'redis')->weeklyOn(1, '08:00');

// A shell command
Schedule::exec('node /home/forge/report.js')->hourly();

Frequency methods cover most schedules you'd actually need: everyFiveMinutes(), hourly(), dailyAt('13:00'), weeklyOn(1, '08:00'), and a raw cron('*/15 * * * *') escape hatch for anything odder.

How do you stop scheduled tasks from overlapping or running on every server?

Two methods, and they solve different problems. withoutOverlapping() stops a second run of the same task from starting while the first is still going, using a cache lock that expires after 24 hours by default:

Schedule::command('report:build')->everyFiveMinutes()->withoutOverlapping();

onOneServer() solves the opposite problem: if you're running the same application on multiple servers (common on Fly.io or Heroku with more than one dyno), every server evaluates the same schedule, so without this a weekly report job would run once per server. It requires a shared cache driver (database, redis, memcached, or dynamodb) so the servers can race for one lock:

Schedule::command('report:generate')->weekly()->onOneServer();

How do you monitor Laravel scheduled tasks?

Laravel gives you hooks for both outcomes. onSuccess() and onFailure() run a closure in your app; pingOnSuccess() and pingOnFailure() skip the closure and just hit a URL:

Schedule::command('invoices:generate')
    ->dailyAt('02:00')
    ->onOneServer()
    ->emailOutputOnFailure('ops@yourapp.com')
    ->pingOnFailure('https://hooks.example.com/alerts/invoices-failed');

That's genuinely useful for per-task alerting: you find out the moment a specific job returns a non-zero exit code. But notice what has to happen first for any of it to fire: cron has to call schedule:run, PHP has to boot, and the scheduler has to reach the point of evaluating that task. A dead server, a deleted crontab line, or a fatal error earlier in the request never gets that far, so pingOnFailure never gets called either. It monitors the task. It can't monitor whether the scheduler ran at all.

What happens when the cron entry itself stops running?

Nothing, and nothing tells you. This is the gap every scheduled-task hook shares: they all fire from inside a schedule:run invocation that already happened. If that invocation never happens, there's no exit code to check and no failure to ping about. The fix is a heartbeat that doesn't depend on any task succeeding or failing, just on the scheduler being alive at all:

use Illuminate\Support\Facades\Schedule;

// Fires every minute as long as cron is actually calling schedule:run.
// Deliberately has no real work to do, so it can't fail for any reason
// other than the scheduler not running.
Schedule::call(fn () => true)
    ->everyMinute()
    ->pingOnSuccess('https://ingest.canaryhub.ai/ping/YOUR_TOKEN');

Point a monitor at that URL and set it to expect a ping roughly every minute or two, with a short grace period for the occasional slow deploy. As long as pings keep arriving, the scheduler is alive, independent of whether any individual job succeeded, failed, or was due that minute. The moment they stop, something below your application code broke, and that's exactly the kind of failure pingOnFailure was never in a position to catch.

Frequently asked questions

What's the difference between onFailure and pingOnFailure?
onFailure runs a closure inside your app, so you can log the error, fire an event, or send a custom notification. pingOnFailure sends an HTTP request to a URL you give it, no app code required. Both only run if schedule:run actually executed and the task returned a non-zero exit code; neither fires if cron never called schedule:run in the first place.
Does Laravel retry a failed scheduled task?
No. The scheduler runs a task once per invocation and moves on. If you need a retry, build it into the command itself or queue the work as a job, since queued jobs support Laravel's normal retry and backoff options.
Can I schedule tasks more often than every minute?
Yes, with everySecond() through everyThirtySeconds(). Cron itself can't go below a minute, so Laravel's schedule:run command stays running for the rest of the minute to fire sub-minute tasks internally. Keep those tasks fast or dispatch them to a queue, since a slow sub-minute task delays the ones after it.
Do I need Laravel Forge to manage the cron entry?
No, Forge just adds the crontab line for you so you don't have to SSH in and edit it by hand. The entry itself, `* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1`, works the same whether a human, Forge, or a deploy script wrote it.
How do I know if the cron entry itself ever stops running?
Only an external check can tell you. Add a lightweight scheduled call that pings a monitoring URL every time schedule:run executes, then set that monitor to expect a ping on roughly that interval. If the ping goes quiet, something upstream of your application code died: the cron daemon, the box, or a bad deploy that deleted the crontab.