All posts

June 14, 2026 7 min read

How to monitor Laravel scheduled tasks in production

Laravel's task scheduler runs on a single crontab entry — if the scheduler stops, all your tasks stop silently. Here's how to add external monitoring to every Laravel scheduled command.


Laravel's task scheduler is elegant. You define all your recurring tasks in one place — app/Console/Kernel.php — and a single crontab entry fires the scheduler every minute. Laravel handles the rest.

The single-entry architecture is also the scheduler's biggest monitoring risk. Every scheduled task in your application depends on one system-level cron entry: * * * * * php artisan schedule:run. If that entry stops running — because of a deployment that corrupted the crontab, a server reboot that didn't restore it, or a misconfiguration — all of your scheduled tasks silently stop.

No individual task fails. They simply don't run. And nothing in your Laravel application will tell you.


The monitoring gap in Laravel's scheduler

php artisan schedule:run runs every minute and executes any tasks that are due. Laravel doesn't expose an external health signal for the scheduler process itself. The schedule:run command exits 0 whether it ran tasks or not — it exits 0 even if the scheduler process stopped running altogether, because nothing calls it to fail.

This means standard web application monitoring (uptime checks, error trackers, application logs) will not detect a stopped Laravel scheduler. The scheduler's absence produces no error to catch.

External monitoring solves this by watching for a positive signal — a ping that your tasks send on each execution. If the ping doesn't arrive when expected, you get alerted.


Option 1: thenPing() on the Schedule

Laravel's scheduler has a built-in thenPing() method that fires an HTTP GET request after a task completes successfully. You can use this to send success pings to Crontify without modifying your command code:

// app/Console/Kernel.php

protected function schedule(Schedule $schedule): void
{
    $schedule->command('sync:records')
        ->dailyAt('02:00')
        ->thenPing(
            sprintf(
                'https://api.crontify.com/api/v1/ping/%s/success',
                config('services.crontify.monitors.sync_records')
            )
        );
}

This is the fastest path to basic monitoring — no changes to your command code. However, thenPing() sends a GET request with no body, so you can't attach metadata or a failure log. For start pings and fail pings, you need to instrument the command directly.


Option 2: Instrument the command directly

For full monitoring — start, success, and fail pings with metadata — add monitoring calls inside the command's handle() method:

// app/Console/Commands/SyncRecords.php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;

class SyncRecords extends Command
{
    protected $signature = 'sync:records';
    protected $description = 'Sync records from upstream API';

    private string $monitorId;

    public function __construct()
    {
        parent::__construct();
        $this->monitorId = config('services.crontify.monitors.sync_records');
    }

    public function handle(): int
    {
        $this->ping('start');

        try {
            $result = app(SyncService::class)->run();

            $this->ping('success', [
                'meta' => [
                    'records_synced' => $result->count,
                    'duration_ms'    => $result->durationMs,
                    'errors'         => $result->errors,
                ],
            ]);

            return Command::SUCCESS;
        } catch (\Throwable $e) {
            $this->ping('fail', [
                'message' => $e->getMessage(),
                'log'     => collect($e->getTrace())
                    ->take(10)
                    ->map(fn($frame) => ($frame['file'] ?? '?').':'.($frame['line'] ?? '?'))
                    ->join("\n"),
            ]);

            return Command::FAILURE;
        }
    }

    private function ping(string $event, array $payload = []): void
    {
        rescue(fn() =>
            Http::timeout(5)
                ->withHeader('X-API-Key', config('services.crontify.api_key'))
                ->post(
                    "https://api.crontify.com/api/v1/ping/{$this->monitorId}/{$event}",
                    $payload
                )
        );
        // rescue() swallows exceptions — monitoring failures must not crash the command
    }
}

Add the configuration values to config/services.php:

'crontify' => [
    'api_key' => env('CRONTIFY_API_KEY'),
    'monitors' => [
        'sync_records' => env('CRONTIFY_MONITOR_SYNC_RECORDS'),
        // add more monitors here
    ],
],

Monitoring the scheduler itself

Individual command pings tell you when specific tasks fail. But they can't tell you when the scheduler itself stops — because if schedule:run never fires, no commands run and no pings are sent.

To detect a stopped scheduler, create a dedicated monitor in Crontify and ping it from a simple Laravel command that runs every minute:

// app/Console/Commands/SchedulerHeartbeat.php

class SchedulerHeartbeat extends Command
{
    protected $signature = 'scheduler:heartbeat';
    protected $description = 'Sends a heartbeat ping to confirm the scheduler is running';

    public function handle(): int
    {
        rescue(fn() =>
            Http::timeout(5)
                ->withHeader('X-API-Key', config('services.crontify.api_key'))
                ->post(
                    'https://api.crontify.com/api/v1/ping/'.
                    config('services.crontify.monitors.scheduler_heartbeat').
                    '/success'
                )
        );

        return Command::SUCCESS;
    }
}

Schedule it every minute:

$schedule->command('scheduler:heartbeat')->everyMinute();

Configure the corresponding monitor in Crontify with a 2-minute grace period. If the scheduler stops, no heartbeat arrives, and you get an alert within 2 minutes.


Silent failure detection for data tasks

Laravel scheduled tasks often process data — syncing records, sending emails, generating reports. A task that runs without error but processes nothing is a silent failure.

The metadata you attach to the success ping enables alert rules in Crontify's dashboard. For a sync task, set a rule: records_synced eq 0 → fire alert. The task is still logged as a success, but you get an immediate notification that something upstream is broken.

This is the monitoring layer that Laravel's built-in scheduler, application logs, and error trackers all miss.


Frequently asked questions

Does thenPing() work for hung job detection?

No. thenPing() only fires on successful completion. To detect hung jobs, you need a start ping (via pingBefore()) and a success ping (via thenPing()). Configure the monitor in Crontify with a maximum duration threshold. If the success ping doesn't arrive within that threshold after the start ping, an alert fires.

What if my Laravel task throws and I'm not catching it?

Laravel's scheduler catches exceptions and logs them, but the task's exit status may not be reflected in thenPing() depending on your version. Instrumenting the command directly as shown above gives you explicit control over the fail ping.

Can I monitor tasks that don't use Artisan commands?

Yes. $schedule->call() and $schedule->exec() support the same pingBefore() and thenPing() methods. For shell commands, use curl directly in the exec string or wrap the command in a shell script that sends pings.


Crontify is free for up to 5 monitors — no credit card required.


Start monitoring your scheduled jobs

Free plan includes 5 monitors. No credit card required. Up and running in under 5 minutes.

Get started free →