GUIDE · PHP · FREE

Monitor PHP cron jobs: Laravel scheduler callbacks or plain try/catch

In Laravel, attach onSuccess and onFailure callbacks to any scheduled command — one HTTP call each. Without a framework, a try/catch plus file_get_contents is all you need: ping on success, POST the exception to /fail on failure. Absence detection covers PHP fatal errors and OOM kills that bypass every catch block. Free, no monitor cap.

The snippets

Create a monitor in the console, set the schedule plus a grace window, copy the ping URL, and add it to your script or scheduler config.

Plain PHP — file_get_contents, zero dependencies

file_get_contents with a stream context sends both the GET success ping and the POST failure body. Re-throwing keeps the exit code non-zero so your cron entry still sees the failure:

<?php declare(strict_types=1); define('PING', getenv('LASTPING_URL') ?: 'https://ping.lastping.dev/<your-monitor-id>'); function runJob(): void { // your job } try { runJob(); file_get_contents(PING); // success ping } catch (\Throwable $e) { $ctx = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: text/plain\r\n", 'content' => substr($e->getMessage(), 0, 10_000), ], ]); @file_get_contents(PING . '/fail', false, $ctx); throw $e; // re-throw so cron sees exit code != 0 }

Laravel scheduler — onSuccess / onFailure

In app/Console/Kernel.php, chain onSuccess and onFailure onto any scheduled command. The onFailure closure receives a Throwable argument in Laravel 10+:

// app/Console/Kernel.php use Illuminate\Support\Facades\Http; protected function schedule(Schedule $schedule): void { $ping = config('services.lastping.url', 'https://ping.lastping.dev/<your-monitor-id>'); $schedule->command('app:nightly-etl') ->daily() ->onSuccess(function () use ($ping) { Http::get($ping); }) ->onFailure(function (\Throwable $e) use ($ping) { Http::post($ping . '/fail', [ 'body' => substr($e->getMessage(), 0, 10_000), ]); }); }

Laravel artisan command — inside the handle method

For commands that are invoked outside the scheduler (e.g. from Jenkins or a deployment hook), add the try/catch directly inside the handle() method. Return a non-zero exit code on failure so the caller can detect it:

use Illuminate\Console\Command; use Illuminate\Support\Facades\Http; class NightlyEtl extends Command { protected $signature = 'app:nightly-etl'; public function handle(): int { $ping = config('services.lastping.url'); try { // your job Http::get($ping); return self::SUCCESS; } catch (\Throwable $e) { $this->error($e->getMessage()); Http::post($ping . '/fail', ['body' => substr($e->getMessage(), 0, 10_000)]); return self::FAILURE; } } }

register_shutdown_function for fatal errors

PHP fatal errors and parse errors terminate the process before any catch block runs. A shutdown function can catch the last error, but absence-detection is still the safer net — if PHP can't even bootstrap, no shutdown function fires. The missing ping opens the incident regardless. See also the LastPing MCP server for AI-agent provisioning and the Node.js guide for a comparison pattern.

register_shutdown_function(function () { $err = error_get_last(); if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR], true)) { $ping = getenv('LASTPING_URL'); $ctx = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => "Content-Type: text/plain\r\n", 'content' => substr("{$err['message']} in {$err['file']}:{$err['line']}", 0, 10_000), ], ]); @file_get_contents($ping . '/fail', false, $ctx); } });

What this catches

The catch reports what it can; the silence reports the rest.

  • A thrown exception or error — the /fail ping opens an incident immediately with the exception message stored on the ping event.
  • A PHP fatal error — the shutdown function catches the last error and POSTs it to /fail before the process exits.
  • OOM kill or SIGKILL — no ping fires; the missing ping opens the incident after the grace window.
  • The script never ran — missing cron entry, dead server, broken include path. Absence → incident.
  • Recovery — the next successful run closes the incident and sends a recovery notice automatically.

Questions people ask

Front-loaded answers — the most important fact first.

  • Do I need Composer or a package?

    No. file_get_contents() with a stream context handles both GET and POST without any dependencies. Guzzle or Laravel's Http facade are convenient but optional.

  • How does Laravel's onFailure know the exception?

    In Laravel 10+ the onFailure closure signature accepts a \Throwable argument. Laravel injects it automatically if you declare it as a parameter. In earlier versions use withoutOverlapping() combined with a custom command that returns a non-zero exit code.

  • What catches a PHP fatal error or parse error?

    Register a shutdown function with register_shutdown_function and check error_get_last(). For the most severe cases — OOM kills, SIGKILL — absence-detection is the real safety net: no ping reaches the server, and that silence opens the incident.

  • Is this free?

    Yes. LastPing is free and fully hosted with no monitor cap and no paid tier. See how it compares to Healthchecks.io and Cronitor.

FREE · FULLY HOSTED · NO PAID TIER

Fatal errors don't reach your catch block — absence does.

One callback now beats a quiet ETL gap later. First alert wired up in about a minute — and LastPing monitors itself the same way.