PHP Cron Jobs: Creating & Monitoring Scheduled Tasks

PHP cron jobs break in ways no other language does: PHP has two configs, one for the web and one for CLI. Cron runs the CLI version. Extensions, memory limits, timezone — all of it can differ between the two. Most tutorials don't even mention this.

Below: every practical way to schedule a PHP script on a timer — system crontab, PHP-native schedulers like Crunz, framework approaches in Laravel and Symfony, and container setups for Docker. Each section covers the PHP-specific problems that generic tutorials skip.

The crontab entry PHP developers get wrong

Start with the basics. A PHP script that sends a daily report at 7 AM:

0 7 * * * /usr/bin/php /var/www/app/scripts/daily-report.php >> /var/log/report.log 2>&1

Three details matter here. First, the full path to the PHP binary. Not php, not php8.2. Run which php in your terminal and use that exact path. Cron doesn't load your shell profile, so PATH is stripped to /usr/bin:/bin. If you installed PHP through a PPA, Homebrew, or compiled from source, the binary lives somewhere cron has never heard of.

Second, redirect stderr. The 2>&1 merges error output into your log file. Without it, PHP fatal errors vanish because cron tries to email them to the system user, and local mail delivery isn't configured on 90% of servers. Your script dies, nobody knows.

Third, absolute paths everywhere. require '../config.php' resolves relative to the working directory, and cron sets that to / or /root. Your script can't find its own config files.

CLI SAPI vs web SAPI: the config you're not reading

PHP loads a different php.ini for CLI and web. Find them:

php -i | grep "Loaded Configuration File"
# /etc/php/8.2/cli/php.ini

To find the web SAPI config, create a temporary phpinfo.php file in your document root and open it in a browser. Or specify the FPM config explicitly:

php -c /etc/php/8.2/fpm/php.ini -i | grep "Loaded Configuration File"
# /etc/php/8.2/fpm/php.ini

On most distributions, the CLI config has memory_limit = -1 (unlimited) and max_execution_time = 0 (no timeout). The web config limits both. That sounds like good news for cron scripts, but it cuts both ways: a bug that leaks memory in your cron job won't hit a wall. It'll consume all available RAM until the OOM killer steps in and takes the process down, possibly along with MySQL or nginx.

Set explicit limits in your cron scripts:

ini_set('memory_limit', '256M');
ini_set('max_execution_time', 300); // 5 minutes

Or pass them on the command line:

0 7 * * * /usr/bin/php -d memory_limit=256M -d max_execution_time=300 /var/www/app/scripts/daily-report.php >> /var/log/report.log 2>&1

Extensions differ too. A common one: pdo_mysql enabled for fpm but missing from cli. Check with php -m versus php-fpm -m. If your cron script needs an extension, make sure it's in the CLI config. On Debian/Ubuntu, extension configs live in /etc/php/8.2/cli/conf.d/ and /etc/php/8.2/fpm/conf.d/ separately.

Composer autoload and working directories

Every modern PHP project uses Composer. Your cron script needs the autoloader, and the autoloader needs to be found. Two approaches work reliably:

#!/usr/bin/env php
<?php
// Option 1: absolute path to autoloader
require '/var/www/app/vendor/autoload.php';

// Option 2: resolve from script location
require __DIR__ . '/../vendor/autoload.php';

Option 2 is better because it survives directory restructuring. __DIR__ always resolves to the script's actual location, regardless of cron's working directory.

For projects using .env files (via vlucas/phpdotenv), load them explicitly. Cron won't do it for you:

require __DIR__ . '/../vendor/autoload.php';

$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();

// Now $_ENV['DB_HOST'] works

Or set the cd in your crontab entry so relative paths resolve correctly:

0 7 * * * cd /var/www/app && /usr/bin/php scripts/daily-report.php >> /var/log/report.log 2>&1

Both patterns work. The cd approach is simpler to debug because crontab -l shows the full context in one line.

Error handling that doesn't swallow failures

PHP's default error behavior in CLI is to print to stderr and continue. For a web request, that's fine. For a PHP cron script, it means partial execution: half your data gets processed, the other half doesn't, and the exit code is still 0 (success). Cron thinks everything went fine.

Wrap your cron scripts with a proper error handler:

#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';

set_error_handler(function ($severity, $message, $file, $line) {
    throw new ErrorException($message, 0, $severity, $file, $line);
});

try {
    // Your actual work
    $report = generateDailyReport();
    sendToSlack($report);

    echo "Report sent: " . date('Y-m-d H:i:s') . "\n";
    exit(0);
} catch (Throwable $e) {
    fwrite(STDERR, "[CRON FAIL] " . $e->getMessage() . "\n");
    fwrite(STDERR, $e->getTraceAsString() . "\n");
    exit(1);
}

That exit(1) matters more than the rest of the script. It tells cron (and any monitoring system watching the exit code) that the job failed. Without it, a script that catches an exception, logs it, and falls through to the end returns exit code 0. Success, as far as cron knows.

For scripts that process batches (import 10,000 rows, send 500 emails), track partial failures:

$processed = 0;
$failed = 0;

foreach ($records as $record) {
    try {
        processRecord($record);
        $processed++;
    } catch (Throwable $e) {
        $failed++;
        error_log("[Record {$record->id}] {$e->getMessage()}");
    }
}

echo "Processed: {$processed}, Failed: {$failed}\n";
exit($failed > 0 ? 1 : 0);

Overlap prevention: don't let jobs pile up

A PHP script that takes 90 seconds to run on a one-minute schedule will stack. Two copies running against the same database table, then three, then four. I watched this take down a client's MySQL instance once: an import script that usually finished in 40 seconds hit a slow query, backed up, and spawned six overlapping processes that each tried to lock the same rows.

Linux flock is the simplest fix. It grabs an exclusive lock on a file before running your command. If the lock is already held, the second invocation exits immediately:

* * * * * /usr/bin/flock -n /tmp/import.lock /usr/bin/php /var/www/app/scripts/import.php >> /var/log/import.log 2>&1

The -n flag means "don't wait." Without it, flock queues the second invocation until the first finishes, which defeats the purpose: you'd still get a pile of waiting processes eating memory.

You can also handle it inside PHP with flock():

$lockFile = fopen('/tmp/import.lock', 'w');
if (!flock($lockFile, LOCK_EX | LOCK_NB)) {
    echo "Already running, skipping.\n";
    exit(0);
}

// Your work here
runImport();

flock($lockFile, LOCK_UN);
fclose($lockFile);

Go with the crontab approach. It catches the overlap before PHP even boots. On a busy server, skipping the PHP startup saves ~50-100ms of memory allocation per skipped run. For scripts that run every minute, those savings add up across hundreds of cron entries.

Timezones: the silent schedule drift

Cron uses the system timezone. PHP uses whatever date.timezone is set to in php.ini. Most applications use UTC internally while the server runs on America/New_York. A crontab entry that says 0 9 * * * fires at 9 AM server time, not 9 AM UTC.

Check what your server thinks:

timedatectl | grep "Time zone"
# Time zone: America/New_York (EDT, -0400)

php -r "echo date_default_timezone_get();"
# UTC

If those don't match, your cron schedule and your application's idea of "now" will disagree. The safest approach: set your server to UTC (timedatectl set-timezone UTC) and keep PHP in UTC too. Calculate display times in the application layer, not in cron.

For servers you can't reconfigure (shared hosting, managed platforms), do the timezone math yourself. A job that should run at 9 AM UTC on an EST server needs 0 4 * * * during winter (UTC-5) and 0 5 * * * during summer (UTC-4). Annoying, fragile, and exactly the kind of thing that breaks twice a year when DST switches. An external cron service that lets you specify the timezone directly avoids this entirely.

Crunz: a PHP-native task scheduler

Crunz (v4.x) is a standalone cron job scheduler written in PHP. You define tasks in PHP files with a fluent API, and Crunz replaces the need for multiple crontab entries. One crontab line runs Crunz every minute, and Crunz decides which tasks are due.

composer require lavary/crunz

Create a task file in a tasks/ directory:

// tasks/reports.php
use Crunz\Schedule;

$schedule = new Schedule();

$schedule->run('/usr/bin/php /var/www/app/scripts/daily-report.php')
    ->daily()
    ->at('07:00')
    ->description('Generate daily sales report')
    ->appendOutputTo('/var/log/report.log');

$schedule->run(function () {
    $pdo = new PDO('mysql:host=localhost;dbname=app', 'user', 'pass');
    $stmt = $pdo->prepare('DELETE FROM sessions WHERE expires_at < NOW()');
    $stmt->execute();
    echo "Deleted {$stmt->rowCount()} expired sessions\n";
})
->everyThirtyMinutes()
->description('Clean expired sessions');

return $schedule;

The single crontab entry:

* * * * * cd /var/www/app && vendor/bin/crunz schedule:run

Crunz adds overlap prevention (skip if previous run hasn't finished), before/after hooks, and output capture. It fills a gap that exists because PHP doesn't have a persistent runtime like Node or Python. Each invocation boots, runs due tasks, and exits. No daemon, no memory leaks from long-running processes.

Fair warning: the library's maintenance pace has slowed. The last major release was in 2023. For new projects, a framework scheduler (Laravel or Symfony) gives you the same capabilities with better long-term support.

Framework schedulers: Laravel and Symfony

If you're building with Laravel, the built-in task scheduler is the answer. One crontab entry runs schedule:run every minute, and you define schedules in app/Console/Kernel.php. We have a full guide on Laravel task scheduling covering setup, timezone handling, and running without crontab access — plus a separate guide on monitoring Laravel scheduled commands once they're running. If you're a PHP or Laravel developer, those two guides are more specific to your stack.

Symfony added a Scheduler component in version 6.3 (stable in 7.0). It takes a different approach than Laravel: schedules are defined as PHP attributes on message handlers.

use Symfony\Component\Scheduler\Attribute\AsPeriodicTask;

#[AsPeriodicTask('1 hour', schedule: 'default')]
class CleanupExpiredTokens
{
    public function __invoke(): void
    {
        // Delete tokens older than 24 hours
    }
}

For cron expressions:

use Symfony\Component\Scheduler\Attribute\AsCronTask;

#[AsCronTask('0 3 * * *', schedule: 'default')]
class NightlyDatabaseBackup
{
    public function __invoke(): void
    {
        // Backup logic
    }
}

Run it with: php bin/console messenger:consume scheduler_default

Symfony's scheduler uses the Messenger component under the hood, so you get retry policies, failure transport, and async processing if you need them. It requires Symfony Messenger to be configured, which is more setup than Crunz or Laravel's built-in scheduler but gives you the full message bus infrastructure.

For vanilla PHP projects without a framework, Crunz or plain crontab entries are the pragmatic choices. Don't pull in Symfony's scheduler component just for scheduling.

PHP cron jobs in Docker containers

Standard cron inside Docker has the same problems regardless of language: environment variables get stripped, output goes to syslog instead of stdout, and docker stop hangs because cron ignores SIGTERM. Our Python cron guide covers the Docker pitfalls in more detail.

PHP adds its own annoyance on top: the official Docker images don't include cron. You have to install it, and the package name varies by base image.

The cleaner approach is Supercronic, a Go binary that reads standard crontab syntax, runs in the foreground, preserves environment variables, and writes to stdout:

FROM php:8.3-cli

# Install supercronic
ARG SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.33/supercronic-linux-amd64
RUN curl -fsSL "$SUPERCRONIC_URL" -o /usr/local/bin/supercronic \
    && chmod +x /usr/local/bin/supercronic

# Install PHP extensions your scripts need
RUN docker-php-ext-install pdo_mysql

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
COPY . /app
WORKDIR /app
RUN composer install --no-dev --optimize-autoloader

COPY crontab /etc/crontab

CMD ["supercronic", "/etc/crontab"]

The crontab file:

0 7 * * * cd /app && php scripts/daily-report.php
*/30 * * * * cd /app && php scripts/cleanup-sessions.php

Use php:8.3-cli as your base image, not php:8.3-fpm. The FPM image ships with php-fpm as the entrypoint, which expects FastCGI connections. CLI is what cron scripts need. One less thing to debug at 2 AM.

Skipping the server: Cloud Cron for PHP endpoints

Not every PHP cron job needs a server with crontab access. If your scheduled task is behind an HTTP endpoint (a Laravel route, a Symfony controller, a plain PHP file on shared hosting), an external service can hit that URL on a schedule.

That's what Cloud Cron does: you give it a URL and a cron expression, and it makes the HTTP request on time. No server crontab, no SSH access needed. It works well for WordPress sites (wp-cron.php replacement), shared hosting where crontab access is restricted, and any PHP app that already exposes a webhook endpoint.

The catch is security. Protect your cron endpoint so random bots can't trigger it:

// cron-endpoint.php
$token = $_GET['token'] ?? '';
if (!hash_equals($_ENV['CRON_SECRET'], $token)) {
    http_response_code(403);
    exit;
}

// Run the scheduled work
$result = processQueue();
echo json_encode(['processed' => $result->count]);

Point Cloud Cron at https://yourapp.com/cron-endpoint.php?token=your-secret with a 5-minute schedule. The service both triggers the job and monitors the response. A 500 or timeout means something broke, and you get an alert before users notice.

Monitoring PHP cron jobs for silent failures

Here's the failure mode that drove me to add monitoring to every PHP cron job I manage: the script runs, hits a database connection timeout on the third query, catches the exception in a generic handler, logs it to a file nobody reads, and exits with code 0. Cron marks it as successful. The backup from Tuesday night is actually empty, but nobody checks until Friday.

A heartbeat monitor fixes this. After each successful run, your script pings a URL. If the ping doesn't arrive within the expected window, you get an alert. With PHP's built-in file_get_contents (no cURL extension needed):

$pingUrl = 'https://monitoring.example.com/ping/YOUR-CHECK-ID';

try {
    runNightlyBackup();

    // Ping only on success
    @file_get_contents($pingUrl, false, stream_context_create([
        'http' => ['timeout' => 10]
    ]));
} catch (Throwable $e) {
    @file_get_contents($pingUrl . '/fail', false, stream_context_create([
        'http' => ['timeout' => 10]
    ]));
    throw $e;
}

Or chain it in crontab with && so the ping fires only on success:

0 3 * * * cd /var/www/app && /usr/bin/php scripts/backup.php && curl -fsS --retry 3 https://monitoring.example.com/ping/YOUR-CHECK-ID > /dev/null

WatchCron's dead man's switch monitoring works this way. Set the expected schedule and grace period, point your script at the ping URL, and if the ping stops arriving, alerts fire to Slack, email, Telegram, or any other channel. Takes about two minutes to set up per job.

Picking the right PHP task scheduler

A standalone PHP cron script on a VPS: crontab. One line, zero dependencies beyond PHP itself. Use absolute paths, redirect stderr, set memory limits explicitly. Done.

Multiple scripts that need overlap prevention and output management: Crunz. One crontab entry replaces ten, and you get before/after hooks. Keep in mind the maintenance pace has slowed.

If you're on Laravel, the built-in scheduler is already there. Integrates with queues and notifications out of the box. Full guide here. Symfony (6.3+) takes more setup because it needs Messenger configured, but you get retry policies and failure transport for free.

Docker container: Supercronic with the php:8.3-cli base image. Environment variables, stdout logging, and graceful shutdown all handled. For shared hosting without crontab access, Cloud Cron hits a protected HTTP endpoint instead. No SSH needed.

Whatever you pick, add a heartbeat ping. PHP's split SAPI configuration means the script that ran perfectly in your browser just threw a PDOException in cron because cli/php.ini doesn't load pdo_mysql. A health check ping after each run is your only witness when that happens at 3 AM.

---

Frequently asked

No. php-fpm is a FastCGI process manager that listens for requests from nginx or Apache. It expects HTTP-style input and can't run standalone scripts. Always use the CLI binary (/usr/bin/php or whatever which php returns). They share extensions but read different php.ini files.

Add php -i | grep "Loaded Configuration File" to a temporary cron entry and check the output. On most systems, CLI uses /etc/php/8.2/cli/php.ini. You can also run php --ini to see all loaded configuration files, including any additional .ini files from conf.d/.

Four things, in order: (1) the PHP binary path, confirmed with which php, (2) missing environment variables that your shell loads but cron doesn't, (3) relative paths in require or include statements that depend on the working directory, (4) extensions loaded in your web php.ini but missing from CLI. Run php -m to check which extensions CLI has available.

Depends on the task. Database cleanup and cache warming work well at every 30 minutes or hourly. Report generation is usually daily. Real-time queue processing needs every minute with overlap prevention.

Yes. Add a heartbeat ping (using file_get_contents or curl) at the end of your script. WatchCron expects the ping on your defined schedule and alerts you through Slack, Telegram, email, or other channels if it doesn't arrive. The free plan includes 20 monitors.