Back to Blog
ยทCron Crew Team

PHP Cron Job Monitoring: A Complete Guide

PHP remains widely used for web development and scheduled tasks are core to most apps. This guide covers practical monitoring approaches with code examples.

PHP Cron Job Monitoring: A Complete Guide

PHP Cron Job Monitoring: A Complete Guide

PHP remains one of the most widely used languages for web development, and scheduled tasks are a core part of most PHP applications. Whether you are running vanilla PHP scripts, Symfony console commands, or custom scheduling implementations, knowing that your jobs actually run is critical. This guide covers practical approaches to monitoring PHP cron jobs with code examples you can adapt to your projects. For foundational concepts, see our complete guide to cron job monitoring.

PHP Scheduling Patterns

PHP applications typically handle scheduled tasks in one of several ways:

System cron with PHP scripts: The most common pattern. You write a PHP script and schedule it with system cron:

0 * * * * /usr/bin/php /var/www/app/scripts/hourly-task.php

Symfony Console commands: Symfony applications use the Console component to create commands that can be scheduled:

0 6 * * * /usr/bin/php /var/www/app/bin/console app:daily-report

Symfony Scheduler component: For Symfony 6.3+, the native Scheduler component provides in-application task management with cron expressions, periodic triggers, and event-based monitoring hooks (PreRunEvent, PostRunEvent, FailureEvent).

Custom scheduling implementations: Some applications implement their own task scheduling, running a single entry point that dispatches to different jobs based on configuration.

Regardless of the pattern, the monitoring approach remains similar: signal when the job starts, signal when it completes (or fails), and let an external service track execution.

PHP cron job architecture showing system cron triggering scripts with monitoring integration

Basic PHP Script Monitoring

The simplest approach uses file_get_contents to ping your monitoring endpoint. This works in any PHP environment without additional dependencies.

<?php

$monitorUrl = getenv('MONITOR_URL') ?: 'https://ping.example.com/daily-task';

// Signal start
file_get_contents("{$monitorUrl}/start");

try {
    // Your job logic
    processData();
    cleanupOldRecords();

    // Signal success
    file_get_contents($monitorUrl);
} catch (Exception $e) {
    // Signal failure
    file_get_contents("{$monitorUrl}/fail");

    // Log the error
    error_log("Job failed: " . $e->getMessage());

    // Re-throw to ensure non-zero exit code
    throw $e;
}

This pattern provides three pieces of information to your monitoring service:

  1. The job started (via /start endpoint)
  2. The job completed successfully (via base URL)
  3. The job failed (via /fail endpoint)

Your monitoring service uses this information to detect missed runs, failures, and measure job duration.

PHP cron monitoring lifecycle showing start signal, execution, and success or failure endpoints

Using cURL for Reliability

The file_get_contents function works but has limitations. It lacks timeout control and can hang if the monitoring service is slow to respond. The cURL extension provides better control:

<?php

function pingMonitor(string $url): void
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS => 3,
    ]);

    $result = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

    if (curl_errno($ch) || $httpCode >= 400) {
        error_log("Monitor ping failed: " . curl_error($ch));
    }

    curl_close($ch);
}

$monitorUrl = getenv('MONITOR_URL');

pingMonitor("{$monitorUrl}/start");

try {
    runMyJob();
    pingMonitor($monitorUrl);
} catch (Exception $e) {
    pingMonitor("{$monitorUrl}/fail");
    throw $e;
}

The timeout settings ensure that monitoring issues do not cause your job to hang. If the monitoring service is unavailable, the ping fails quickly and your job continues.

Guzzle HTTP Client Approach

For applications already using Guzzle, leverage the HTTP client you have:

<?php

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;

$client = new Client([
    'timeout' => 10,
    'connect_timeout' => 5,
]);

$monitorUrl = getenv('MONITOR_URL');

try {
    $client->get("{$monitorUrl}/start");
} catch (GuzzleException $e) {
    error_log("Monitor start ping failed: " . $e->getMessage());
}

try {
    processData();

    try {
        $client->get($monitorUrl);
    } catch (GuzzleException $e) {
        error_log("Monitor success ping failed: " . $e->getMessage());
    }
} catch (Exception $e) {
    try {
        $client->get("{$monitorUrl}/fail");
    } catch (GuzzleException $e) {
        error_log("Monitor fail ping failed: " . $e->getMessage());
    }
    throw $e;
}

Notice the nested try-catch blocks for monitoring calls. This ensures that monitoring failures do not interfere with your job execution or error reporting.

Symfony Console Command Monitoring

Symfony Console commands benefit from structured error handling and dependency injection. Here is a monitored command implementation:

<?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;
use Psr\Log\LoggerInterface;

#[AsCommand(name: 'app:daily-report')]
class DailyReportCommand extends Command
{
    public function __construct(
        private HttpClientInterface $httpClient,
        private LoggerInterface $logger,
        private string $monitorUrl,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $this->pingMonitor("{$this->monitorUrl}/start");

        try {
            $this->generateReport($output);
            $this->pingMonitor($this->monitorUrl);
            return Command::SUCCESS;
        } catch (\Exception $e) {
            $this->pingMonitor("{$this->monitorUrl}/fail");
            $this->logger->error('Daily report failed', ['exception' => $e]);
            throw $e;
        }
    }

    private function pingMonitor(string $url): void
    {
        try {
            $this->httpClient->request('GET', $url, ['timeout' => 10]);
        } catch (\Exception $e) {
            $this->logger->warning('Monitor ping failed', [
                'url' => $url,
                'error' => $e->getMessage(),
            ]);
        }
    }

    private function generateReport(OutputInterface $output): void
    {
        // Report generation logic
        $output->writeln('Generating report...');
    }
}

Configure the monitor URL in your services.yaml:

services:
    App\Command\DailyReportCommand:
        arguments:
            $monitorUrl: '%env(DAILY_REPORT_MONITOR_URL)%'

Reusable Monitoring Trait

If you have multiple scripts or commands that need monitoring, create a reusable trait:

<?php

trait Monitorable
{
    protected function withMonitoring(string $url, callable $job): mixed
    {
        $this->pingMonitor("{$url}/start");

        try {
            $result = $job();
            $this->pingMonitor($url);
            return $result;
        } catch (\Exception $e) {
            $this->pingMonitor("{$url}/fail");
            throw $e;
        }
    }

    protected function pingMonitor(string $url): void
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_CONNECTTIMEOUT => 5,
        ]);
        curl_exec($ch);

        if (curl_errno($ch)) {
            error_log("Monitor ping failed for {$url}: " . curl_error($ch));
        }

        curl_close($ch);
    }
}

Use the trait in your job classes:

<?php

class DataImportJob
{
    use Monitorable;

    public function run(): void
    {
        $this->withMonitoring(getenv('IMPORT_MONITOR_URL'), function () {
            $this->importData();
            $this->validateResults();
        });
    }

    private function importData(): void
    {
        // Import logic
    }

    private function validateResults(): void
    {
        // Validation logic
    }
}

Sending Execution Metadata

Basic success/failure pings tell you whether a job ran, but many monitoring services accept additional metadata. Sending memory usage, execution duration, and exit codes helps identify performance degradation before jobs start failing.

<?php

function pingWithMetadata(string $url, array $metadata = []): void
{
    $ch = curl_init($url);

    $postData = http_build_query($metadata);

    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $postData,
    ]);

    curl_exec($ch);
    curl_close($ch);
}

$startTime = microtime(true);
$monitorUrl = getenv('MONITOR_URL');

pingWithMetadata("{$monitorUrl}/start");

try {
    processData();

    $duration = round(microtime(true) - $startTime, 2);
    $memoryPeak = memory_get_peak_usage(true);

    pingWithMetadata($monitorUrl, [
        'duration' => $duration,
        'memory' => $memoryPeak,
        'exit_code' => 0,
    ]);
} catch (Exception $e) {
    $duration = round(microtime(true) - $startTime, 2);

    pingWithMetadata("{$monitorUrl}/fail", [
        'duration' => $duration,
        'error' => substr($e->getMessage(), 0, 255),
        'exit_code' => 1,
    ]);

    throw $e;
}

PHP script sending execution metadata including memory, duration, and exit code to monitoring service

Track these metrics to catch problems early:

  • memory_get_peak_usage(true): Returns bytes allocated by the system. Watch for steady increases indicating memory leaks.
  • Execution duration: Growing duration often signals data growth or degrading query performance.
  • Exit codes: PHP uses 0 for success, 1-254 for errors. Exit code 255 is reserved by PHP itself.

Exit Code Best Practices

PHP CLI scripts should use explicit exit codes to communicate success or failure to the calling process (cron, supervisord, etc.).

<?php

// At the end of successful execution
exit(0);

// For handled errors
exit(1);

// For specific error categories
const EXIT_SUCCESS = 0;
const EXIT_GENERAL_ERROR = 1;
const EXIT_CONFIG_ERROR = 2;
const EXIT_DATABASE_ERROR = 3;

try {
    if (!file_exists('config.php')) {
        error_log('Configuration file missing');
        exit(EXIT_CONFIG_ERROR);
    }

    $pdo = connectToDatabase();
    processRecords($pdo);

    exit(EXIT_SUCCESS);
} catch (PDOException $e) {
    error_log("Database error: " . $e->getMessage());
    exit(EXIT_DATABASE_ERROR);
} catch (Exception $e) {
    error_log("General error: " . $e->getMessage());
    exit(EXIT_GENERAL_ERROR);
}

Exit code conventions:

  • 0: Success
  • 1: General error
  • 2: Misuse of command/configuration error
  • 126: Command not executable
  • 127: Command not found
  • 255: Reserved by PHP (do not use)

Network Resilience

Monitoring pings can fail due to network issues. Add retry logic to prevent false alerts from transient failures.

<?php

function pingWithRetry(string $url, int $maxRetries = 3, int $delayMs = 1000): bool
{
    $attempt = 0;

    while ($attempt < $maxRetries) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_CONNECTTIMEOUT => 5,
        ]);

        $result = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_errno($ch);
        curl_close($ch);

        if (!$error && $httpCode >= 200 && $httpCode < 300) {
            return true;
        }

        $attempt++;
        if ($attempt < $maxRetries) {
            usleep($delayMs * 1000);
        }
    }

    error_log("Monitor ping failed after {$maxRetries} attempts: {$url}");
    return false;
}

For shell-based monitoring, curl provides built-in retry support:

curl -fsS --retry 3 --retry-delay 5 --max-time 10 "https://ping.example.com/job-id"

Security Considerations

PHP cron scripts should only run from the command line, not from web requests.

<?php

// Reject web requests
if (php_sapi_name() !== 'cli') {
    http_response_code(403);
    exit('CLI access only');
}

// Alternative using PHP_SAPI constant
if (PHP_SAPI !== 'cli') {
    die('This script must be run from the command line');
}

Additional security practices:

Store scripts outside the web root: Place cron scripts in a directory not accessible via HTTP (e.g., /var/app/scripts/ instead of /var/www/html/scripts/).

Restrict file permissions: Cron scripts should be readable and executable only by the user running cron:

chmod 700 /var/app/scripts/daily-task.php
chown www-data:www-data /var/app/scripts/daily-task.php

Validate environment: Check that required environment variables and dependencies exist before executing:

<?php

$required = ['DATABASE_URL', 'MONITOR_URL', 'APP_ENV'];

foreach ($required as $var) {
    if (!getenv($var)) {
        error_log("Missing required environment variable: {$var}");
        exit(2);
    }
}

Best Practices

Handle network failures gracefully: Monitoring should enhance your jobs, not break them. Always catch exceptions from monitoring calls and log them locally.

Set appropriate timeouts: Use short timeouts (5-10 seconds) for monitoring HTTP calls. A slow monitoring service should not delay your job completion.

Use environment variables for URLs: Store monitoring URLs in environment variables or configuration files. This keeps sensitive URLs out of code and allows different URLs per environment.

$monitorUrl = getenv('CRON_MONITOR_URL');
if (!$monitorUrl) {
    error_log('CRON_MONITOR_URL not set, monitoring disabled');
    // Continue without monitoring
}

Log monitoring failures locally: If a monitoring ping fails, write to your application logs. This creates a backup record even if external monitoring is unavailable.

Consider async pings for non-critical monitoring: If you do not need to wait for the monitoring response, use non-blocking requests. However, ensure the failure ping has time to complete before your script exits.

Common PHP Cron Job Patterns

Different types of jobs benefit from monitoring in different ways:

Email queue processing: Monitor each queue processor run. Track duration to detect queue backup (longer runs mean more emails waiting).

Report generation: Monitor for completion and track duration. Growing duration often indicates data growth or query performance issues.

Data imports and exports: These jobs often interact with external systems and are prone to failures. Monitor every run.

Cache warming: Monitor to ensure your cache stays fresh. A failed cache warm job can cause performance issues across your application.

Database cleanup: Maintenance jobs that remove old records or optimize tables should be monitored to ensure they complete within maintenance windows.

Conclusion

Monitoring PHP cron jobs does not require complex infrastructure or expensive tools. With a few lines of code, you can gain visibility into whether your scheduled tasks run successfully.

Start with your most critical jobs: anything that impacts revenue, customer communication, or data integrity. Add the monitoring pattern, configure your endpoints, and set up alerts. As you build confidence in the approach, expand coverage to all scheduled tasks.

If you work with PHP frameworks, see our guides on Laravel task scheduling monitoring for Laravel's elegant scheduler integration or WordPress cron monitoring for WooCommerce stores. For help choosing a monitoring service, check out our cron monitoring pricing comparison.

Cron Crew makes PHP cron monitoring straightforward with simple HTTP endpoints and flexible alerting. Create monitors for your jobs and start receiving alerts when things go wrong. The peace of mind from knowing your scheduled tasks are running is worth the few minutes of setup time.