Cuckoo

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/start means the task started (optional, needed for max runtime)
  • https://cuckoo.metastic.ch/ping/YOUR-UUID means the task finished successfully
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/fail means 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 shared redis, database, memcached or dynamodb cache). 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 expression 0 3 * * * with time zone Europe/Zurich in 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 pingBefore in 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:list to confirm the task is registered, then php artisan schedule:test to run it once by hand. Check that the cron user can reach the project path.
  • Pings in staging: gate them with the ...If variants 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.ch on 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

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.