Monitor the Laravel Scheduler with Cuckoo
Updated
The Laravel scheduler hides a lot behind a single crontab line. If that cron entry goes missing after a server
move, if schedule:run dies because of a broken deploy, or if a command exits non-zero at 03:00, nothing tells you.
The nightly invoice export just stops, and you find out when someone asks for it.
Laravel can already call a URL before and after a task. Point those calls at Cuckoo and every scheduled task gets a deadline. If a ping is late, or a task reports a failure, you get an alert.
How Cuckoo sees a Laravel task
Each task you care about gets its own check in Cuckoo, and each check has its own ping URL:
https://cuckoo.metastic.ch/ping/YOUR-UUID/startmeans the task started (optional, needed for max runtime)https://cuckoo.metastic.ch/ping/YOUR-UUIDmeans the task finished successfullyhttps://cuckoo.metastic.ch/ping/YOUR-UUID/failmeans the task failed
If neither a success nor a failure arrives within the schedule plus the grace time, the check goes down and you are alerted by email, Slack, Discord or webhook.
Step 1: Make sure the scheduler runs at all
Laravel needs exactly one cron entry that runs schedule:run every minute:
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
Everything below depends on this line, so you will also add a dedicated check for it in step 3.
Step 2: Add ping hooks to your tasks
Laravel ships four ping methods on scheduled events: pingBefore($url), thenPing($url), pingOnSuccess($url) and
pingOnFailure($url). There are also ...If($condition, $url) variants. The pings are plain GET requests sent with
Guzzle. guzzlehttp/guzzle is a dependency of laravel/framework in current versions, so you don't need to install
anything. If a ping fails, Laravel reports the exception and the task itself keeps running.
On Laravel 11 and newer, schedules live in routes/console.php:
<?php
use Illuminate\Support\Facades\Schedule;
Schedule::command('invoices:export')
->dailyAt('03:00')
->timezone('Europe/Zurich')
->withoutOverlapping()
->onOneServer()
->pingBefore('https://cuckoo.metastic.ch/ping/YOUR-UUID/start')
->pingOnSuccess('https://cuckoo.metastic.ch/ping/YOUR-UUID')
->pingOnFailure('https://cuckoo.metastic.ch/ping/YOUR-UUID/fail');
On Laravel 10 and older, the same chain goes in the schedule() method of app/Console/Kernel.php:
protected function schedule(Schedule $schedule): void
{
$schedule->command('invoices:export')
->dailyAt('03:00')
->pingBefore(config('services.heartbeat.invoices') . '/start')
->pingOnSuccess(config('services.heartbeat.invoices'))
->pingOnFailure(config('services.heartbeat.invoices') . '/fail');
}
Keep the ping URLs in config or .env (for example CUCKOO_INVOICES=https://cuckoo.metastic.ch/ping/YOUR-UUID),
so that staging doesn't ping your production checks. You can also wrap them in pingOnSuccessIf(app()->isProduction(), ...).
A task counts as failed when the Artisan or shell command exits with a non-zero code. In your command, return
Command::FAILURE (or throw) when the work did not succeed. Otherwise pingOnFailure never fires.
withoutOverlapping and onOneServer
withoutOverlapping()skips a run while the previous one still holds the lock. A skipped run sends no ping, so a task stuck for hours shows up as late in Cuckoo. That's what you want.onOneServer()runs the task only on the server that wins the cache lock (needs a sharedredis,database,memcachedordynamodbcache). Only that server sends pings, so one check per task is enough even with several app servers.
Step 3: Monitor schedule:run itself
If the cron entry disappears, no task runs and no task pings. Your per-task checks will catch that eventually, but a daily task only notices after a day. Add a cheap task that pings every five minutes:
Schedule::call(fn () => true)
->name('scheduler-heartbeat')
->everyFiveMinutes()
->thenPing('https://cuckoo.metastic.ch/ping/YOUR-UUID');
Create a separate check for it with a 5-minute period and a 5-minute grace time.
Schedule and grace advice
- For
dailyAt('03:00')->timezone('Europe/Zurich'), use the cron expression0 3 * * *with time zoneEurope/Zurichin Cuckoo, so the deadline follows daylight saving time the same way Laravel does. - Set the grace time to roughly the longest normal runtime plus a margin. 30 minutes is a good start for a report that usually takes 10.
- Set max runtime if a hung task is a real risk. With
pingBeforein place, Cuckoo flags a run that started but never finished. - Tasks with
runInBackground()still trigger the ping hooks when the background process finishes.
Troubleshooting
- No pings at all: run
php artisan schedule:listto confirm the task is registered, thenphp artisan schedule:testto run it once by hand. Check that the cron user can reach the project path. - Pings in staging: gate them with the
...Ifvariants or keep empty URLs out of config. - Failure ping never arrives: the command probably returns 0 on error. Return
Command::FAILURE. - Outbound HTTPS blocked: allow egress to
cuckoo.metastic.chon port 443. Laravel logs ping errors through your exception handler.
FAQ
Do the ping methods work with Schedule::call and Schedule::job?
Yes. The ping hooks are defined on every scheduled event. Note that Schedule::job() only dispatches the job to
the queue, so the ping means "dispatched", not "finished". For queued jobs, ping from the job's handle() method.
Will a slow or failing ping break my task?
No. Laravel catches HTTP client errors from pings and reports them. The default client uses a 10-second connect timeout and a 30-second total timeout, so a network problem can delay but not fail the task.
Should I use thenPing or pingOnSuccess?
Use pingOnSuccess together with pingOnFailure. thenPing fires after every run whatever the exit code, so
Cuckoo would mark a failed run as successful.
Related guides
- Monitor Django and Celery Beat Periodic TasksMonitor Celery beat periodic tasks and Django management commands with start, success and fail pings, and get alerted when beat or the worker stops.
- Monitor GoodJob Cron Jobs with CuckooMonitor GoodJob cron jobs in Rails: configure enable_cron and cron entries, then ping Cuckoo with an ActiveJob around_perform callback on each run.
- Monitor Rails Cron Jobs Built with WheneverAdd heartbeat monitoring to Rails cron jobs from the whenever gem: a custom job_type with curl, or Net::HTTP pings inside rake and runner tasks.
- Monitor sidekiq-cron Jobs with CuckooMonitor sidekiq-cron scheduled jobs: ping Cuckoo from perform, report failures with rescue, and decide how Sidekiq retries map to fail alerts.