Monitor PHP Cron Jobs with CronPeek API

Published March 29, 2026 · 12 min read · By CronPeek

PHP powers a staggering amount of the web's background work. WordPress alone runs millions of scheduled tasks every minute through wp-cron. Laravel applications lean on the Scheduler for queue maintenance, report generation, and cache cleanup. Custom PHP scripts in crontab handle everything from database backups to invoice processing. When any of these stop running, the failure is silent. No error page. No stack trace. Just a job that quietly stopped doing its thing.

Dead man's switch monitoring catches these invisible failures. Your PHP cron job pings an external endpoint after every successful run. If the ping stops arriving, you get an alert. This guide shows you how to wire that up with plain PHP curl, Guzzle HTTP, the Laravel Scheduler, WordPress wp-cron, and Symfony Console Commands using CronPeek.

Why PHP Cron Jobs Fail Silently

PHP's execution model makes cron failures especially hard to catch. Each invocation is a fresh process with no shared state. If the process dies, nothing is left running to report the failure. Common silent failure modes in PHP cron jobs:

None of these trigger a 500 error or an exception tracker alert. You need a monitor that detects the absence of a signal.

How CronPeek Works: The Dead Man's Switch Pattern

A dead man's switch flips the monitoring model. Instead of checking whether your server is up, the server proves it's working by sending a heartbeat to an external monitor. The monitor expects a ping every N minutes. If a ping is late, it fires an alert.

This is the only reliable way to monitor scheduled tasks because the failure mode is silence. You can't poll for something that didn't happen.

With CronPeek, the flow is:

  1. Create a monitor in CronPeek with an expected interval (e.g., every 60 minutes)
  2. Get your unique ping URL: https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/{monitor_id}
  3. Hit that URL at the end of each successful job run
  4. If the ping is late, CronPeek alerts you via email, Slack, or webhook

To report a failure explicitly, append /fail to the ping URL. This triggers an immediate alert rather than waiting for the heartbeat to expire.

PHP curl Example: The Simplest Ping

For standalone PHP scripts running in crontab, the built-in curl extension is all you need. No Composer packages required.

<?php
// cronpeek-ping.php — Reusable CronPeek helper

function cronpeekPing(string $monitorId, bool $failed = false): bool
{
    $baseUrl = 'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping';
    $url = $baseUrl . '/' . $monitorId;

    if ($failed) {
        $url .= '/fail';
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 5,
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_HTTPHEADER     => ['User-Agent: CronPeek-PHP/1.0'],
    ]);

    $response = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error    = curl_error($ch);
    curl_close($ch);

    if ($error) {
        error_log("CronPeek ping failed: $error");
        return false;
    }

    if ($httpCode !== 200) {
        error_log("CronPeek ping unexpected status: $httpCode");
        return false;
    }

    return true;
}

Use it at the end of any cron script:

<?php
require_once __DIR__ . '/cronpeek-ping.php';

try {
    // Your cron job logic
    $orders = processUnfulfilledOrders();
    echo "Processed {$orders} orders\n";

    // Report success to CronPeek
    cronpeekPing('mon_orders_001');

} catch (Throwable $e) {
    error_log("Order processing failed: " . $e->getMessage());

    // Report failure — triggers immediate alert
    cronpeekPing('mon_orders_001', failed: true);

    exit(1);
}

Key details: the 5-second timeout prevents a CronPeek outage from blocking your job. The Throwable catch covers both exceptions and fatal errors in PHP 7+. The failed: true parameter uses PHP 8 named arguments for clarity.

Guzzle HTTP Client Example

If your project already uses Guzzle (most Laravel and Symfony applications do), you can use it for cleaner HTTP handling with better error reporting and connection pooling.

<?php

use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;

class CronPeekClient
{
    private Client $client;
    private string $baseUrl = 'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping';

    public function __construct()
    {
        $this->client = new Client([
            'timeout'         => 5,
            'connect_timeout' => 3,
            'http_errors'     => true,
            'headers'         => [
                'User-Agent' => 'CronPeek-PHP-Guzzle/1.0',
            ],
        ]);
    }

    public function ping(string $monitorId): void
    {
        try {
            $this->client->get("{$this->baseUrl}/{$monitorId}");
        } catch (RequestException $e) {
            error_log("CronPeek ping failed: " . $e->getMessage());
        }
    }

    public function fail(string $monitorId): void
    {
        try {
            $this->client->get("{$this->baseUrl}/{$monitorId}/fail");
        } catch (RequestException $e) {
            error_log("CronPeek fail ping failed: " . $e->getMessage());
        }
    }
}

// Usage
$cronpeek = new CronPeekClient();

try {
    generateDailyReport();
    $cronpeek->ping('mon_reports_001');
} catch (Throwable $e) {
    $cronpeek->fail('mon_reports_001');
    throw $e;
}

Guzzle's built-in exception handling and connection pooling make it the better choice for applications that already have it as a dependency. The client object can be injected via your framework's service container for easier testing.

Laravel Scheduler Integration

Laravel's task scheduler runs all your scheduled commands through a single schedule:run Artisan command triggered by crontab once per minute. This means a single crontab entry manages dozens of scheduled tasks — and if any of them silently fail, you won't know unless you're monitoring each one individually.

Using onSuccess and onFailure Callbacks

Laravel 8+ provides onSuccess and onFailure callbacks directly on scheduled task definitions. This is the cleanest integration point.

<?php
// app/Console/Kernel.php

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\Facades\Http;

protected function schedule(Schedule $schedule): void
{
    $schedule->command('orders:process-unfulfilled')
        ->everyFiveMinutes()
        ->withoutOverlapping()
        ->onSuccess(function () {
            Http::timeout(5)->get(
                'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_laravel_orders'
            );
        })
        ->onFailure(function () {
            Http::timeout(5)->get(
                'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_laravel_orders/fail'
            );
        });

    $schedule->command('reports:generate-daily')
        ->dailyAt('02:00')
        ->onSuccess(function () {
            Http::timeout(5)->get(
                'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_laravel_reports'
            );
        })
        ->onFailure(function () {
            Http::timeout(5)->get(
                'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_laravel_reports/fail'
            );
        });
}

Using afterCallbacks for Older Laravel Versions

If you're on Laravel 7 or earlier, use the after callback instead:

$schedule->command('invoices:send-reminders')
    ->weeklyOn(1, '09:00')
    ->after(function () {
        @file_get_contents(
            'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_invoices_001',
            false,
            stream_context_create(['http' => ['timeout' => 5]])
        );
    });

Extracting a Reusable Trait

If you have many scheduled tasks, extract the ping logic into a trait to keep your Kernel clean:

<?php
// app/Concerns/MonitoredByCronPeek.php

namespace App\Concerns;

use Illuminate\Support\Facades\Http;

trait MonitoredByCronPeek
{
    protected function pingCronPeek(string $monitorId, bool $failed = false): void
    {
        $url = 'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/' . $monitorId;
        if ($failed) {
            $url .= '/fail';
        }

        try {
            Http::timeout(5)->get($url);
        } catch (\Throwable $e) {
            logger()->warning("CronPeek ping failed: {$e->getMessage()}");
        }
    }
}

Then use it in any Artisan command:

<?php

namespace App\Console\Commands;

use App\Concerns\MonitoredByCronPeek;
use Illuminate\Console\Command;

class ProcessUnfulfilledOrders extends Command
{
    use MonitoredByCronPeek;

    protected $signature = 'orders:process-unfulfilled';

    public function handle(): int
    {
        try {
            $count = $this->processOrders();
            $this->info("Processed {$count} orders.");

            $this->pingCronPeek('mon_laravel_orders');
            return Command::SUCCESS;

        } catch (\Throwable $e) {
            $this->error($e->getMessage());
            $this->pingCronPeek('mon_laravel_orders', failed: true);
            return Command::FAILURE;
        }
    }
}

WordPress wp-cron Integration

WordPress has a built-in pseudo-cron system that triggers on page visits. This is notoriously unreliable for low-traffic sites — no visitors means no cron. The recommended setup is to disable WordPress's internal trigger and use a real system crontab instead.

Step 1: Disable WordPress's Built-in wp-cron Trigger

// wp-config.php
define('DISABLE_WP_CRON', true);

Step 2: Add a System Crontab Entry

# Run WordPress cron every 5 minutes via real crontab
*/5 * * * * cd /var/www/html && php wp-cron.php >/dev/null 2>&1

Step 3: Add CronPeek Monitoring to Your Plugin or Theme

<?php
/**
 * Plugin Name: CronPeek Monitor
 * Description: Monitors critical wp-cron events with CronPeek dead man's switch
 */

// Monitor a specific wp-cron action
add_action('my_daily_report_hook', 'my_daily_report_task', 10);
function my_daily_report_task(): void
{
    try {
        // Your scheduled task logic
        $report = generate_daily_site_report();
        wp_mail(get_option('admin_email'), 'Daily Report', $report);

        // Ping CronPeek on success
        wp_remote_get(
            'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_wp_report',
            ['timeout' => 5, 'blocking' => true]
        );

    } catch (\Throwable $e) {
        error_log('Daily report failed: ' . $e->getMessage());

        // Report failure to CronPeek
        wp_remote_get(
            'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_wp_report/fail',
            ['timeout' => 5, 'blocking' => true]
        );
    }
}

// Schedule the event if it's not already scheduled
if (!wp_next_scheduled('my_daily_report_hook')) {
    wp_schedule_event(time(), 'daily', 'my_daily_report_hook');
}

Using wp_remote_get instead of raw curl respects WordPress's HTTP transport configuration, proxy settings, and SSL certificate handling. The blocking => true parameter ensures the ping completes before the function returns.

Monitoring WooCommerce Scheduled Actions

WooCommerce uses Action Scheduler for background tasks like processing orders and sending emails. You can hook into completed actions:

add_action('action_scheduler_after_execute', function ($action_id, $action) {
    $hook = $action->get_hook();

    // Map WooCommerce hooks to CronPeek monitors
    $monitors = [
        'woocommerce_cleanup_sessions'     => 'mon_woo_sessions',
        'woocommerce_tracker_send_event'   => 'mon_woo_tracker',
        'generate_category_lookup_table'   => 'mon_woo_categories',
    ];

    if (isset($monitors[$hook])) {
        wp_remote_get(
            'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/' . $monitors[$hook],
            ['timeout' => 5]
        );
    }
}, 10, 2);

Symfony Console Command Integration

Symfony applications typically run scheduled tasks as Console Commands triggered by crontab or the Symfony Scheduler component. Here's how to add CronPeek monitoring to both approaches.

Standalone Console Command with CronPeek

<?php
// src/Command/ProcessPaymentsCommand.php

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

#[AsCommand(name: 'app:process-payments', description: 'Process pending payments')]
class ProcessPaymentsCommand extends Command
{
    private const CRONPEEK_BASE = 'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping';
    private const MONITOR_ID = 'mon_sf_payments';

    public function __construct(
        private readonly HttpClientInterface $httpClient,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        try {
            $count = $this->processPayments();
            $output->writeln("<info>Processed {$count} payments.</info>");

            $this->pingCronPeek();
            return Command::SUCCESS;

        } catch (\Throwable $e) {
            $output->writeln("<error>{$e->getMessage()}</error>");

            $this->pingCronPeek(failed: true);
            return Command::FAILURE;
        }
    }

    private function pingCronPeek(bool $failed = false): void
    {
        $url = self::CRONPEEK_BASE . '/' . self::MONITOR_ID;
        if ($failed) {
            $url .= '/fail';
        }

        try {
            $this->httpClient->request('GET', $url, ['timeout' => 5]);
        } catch (\Throwable $e) {
            // Log but don't throw — monitoring should never break the job
            error_log("CronPeek ping failed: {$e->getMessage()}");
        }
    }

    private function processPayments(): int
    {
        // Your business logic here
        return 42;
    }
}

Symfony Scheduler Component (6.3+)

If you're using Symfony's Scheduler component, you can wire CronPeek into your message handlers:

<?php
// src/Scheduler/DefaultScheduleProvider.php

use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;

#[AsSchedule('default')]
class DefaultScheduleProvider implements ScheduleProviderInterface
{
    public function getSchedule(): Schedule
    {
        return (new Schedule())
            ->add(RecurringMessage::every('5 minutes', new ProcessPaymentsMessage()))
            ->add(RecurringMessage::every('1 hour', new CleanupExpiredTokensMessage()));
    }
}

Then in your message handler, ping CronPeek after processing:

<?php

use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\HttpClientInterface;

#[AsMessageHandler]
class ProcessPaymentsHandler
{
    public function __construct(
        private readonly HttpClientInterface $httpClient,
    ) {}

    public function __invoke(ProcessPaymentsMessage $message): void
    {
        try {
            $this->processPayments();
            $this->httpClient->request('GET',
                'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_sf_payments',
                ['timeout' => 5]
            );
        } catch (\Throwable $e) {
            $this->httpClient->request('GET',
                'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_sf_payments/fail',
                ['timeout' => 5]
            );
            throw $e;
        }
    }
}

Error Handling and Timeout Best Practices

The monitoring layer should never interfere with the job it's monitoring. Here are the rules that prevent CronPeek from becoming a liability:

<?php
$jobCompleted = false;

register_shutdown_function(function () use (&$jobCompleted) {
    if (!$jobCompleted) {
        // Script died before completing — report failure
        @file_get_contents(
            'https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_batch_001/fail',
            false,
            stream_context_create(['http' => ['timeout' => 5]])
        );
    }
});

// Your job logic
processBatchRecords();
$jobCompleted = true;

// Report success
cronpeekPing('mon_batch_001');

The register_shutdown_function pattern is the only way to catch fatal errors and OOM kills in PHP. The @ suppresses warnings on the fallback file_get_contents call since the error handler may be in an unstable state during shutdown.

Quick Reference: Crontab Entry with CronPeek

For the simplest possible integration, you can chain a curl ping directly in your crontab entry without modifying the PHP script at all:

# Process orders every 5 minutes, ping CronPeek on success or failure
*/5 * * * * php /var/www/app/cron/process-orders.php \
  && curl -sf https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_orders_001 \
  || curl -sf https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_orders_001/fail

# Laravel Scheduler — single crontab entry for all tasks
* * * * * cd /var/www/laravel && php artisan schedule:run >> /dev/null 2>&1 \
  && curl -sf https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_laravel_scheduler

# WordPress wp-cron via real crontab
*/5 * * * * cd /var/www/wordpress && php wp-cron.php \
  && curl -sf https://us-central1-todd-agent-prod.cloudfunctions.net/cronpeekApi/ping/mon_wp_cron

This approach works for any PHP script without touching application code. The -sf curl flags suppress output and fail silently, keeping your cron logs clean.

Monitor your PHP cron jobs in 60 seconds

Free tier includes 5 monitors. No credit card required. Set up a dead man's switch for your Laravel, WordPress, or Symfony cron jobs today.

Monitor 5 Cron Jobs Free

FAQ

How do I monitor a PHP cron job for silent failures?

After your PHP cron script completes, send an HTTP GET request to your CronPeek ping URL using curl, file_get_contents, or Guzzle. If CronPeek stops receiving pings within the expected interval, it triggers an alert via email, Slack, or webhook. This catches silent failures like fatal errors, memory exhaustion, and scripts that exit early without producing any output.

How do I monitor Laravel Scheduler tasks with CronPeek?

Use Laravel's onSuccess and onFailure callbacks on your scheduled task definition in Kernel.php. After each successful run, ping your CronPeek monitor URL. On failure, ping the /fail endpoint for an immediate alert. You can also create a reusable trait and use it directly in your Artisan commands for more granular control.

Can I monitor WordPress wp-cron events with CronPeek?

Yes. Hook into your wp-cron action and add a CronPeek ping using wp_remote_get at the end of your callback function. For reliability, disable WordPress's built-in wp-cron.php trigger by setting DISABLE_WP_CRON to true in wp-config.php, then trigger wp-cron via a real system crontab entry. This ensures your tasks run on time and are monitored by CronPeek.

What is a dead man's switch for PHP cron jobs?

A dead man's switch is a monitoring pattern where your PHP scheduled task sends a heartbeat ping to an external service like CronPeek after each successful run. If the ping stops arriving within the configured grace period, the service assumes the job has failed and sends an alert. Unlike uptime monitoring, it detects when something stops happening — the exact failure mode of cron jobs and scheduled tasks.

How much does PHP cron job monitoring cost with CronPeek?

CronPeek's free tier includes 5 monitors with no credit card required. The Starter plan at $9/month covers 50 monitors, and Pro at $29/month gives unlimited monitors. Compared to Cronitor at roughly $2 per monitor per month, CronPeek is over 10x cheaper for teams with 50+ scheduled tasks.

Related Posts

The Peek Suite

CronPeek is part of a family of developer monitoring tools: