Writing PHP Scripts Safe to Run Unattended From Cron

Write a PHP script that is safe to schedule with cron: acquire a file-based lock to prevent overlapping runs, reason correctly about which timezone cron and the script itself are operating in, and log failures somewhere a human will actually see them.

A script that works perfectly when run by hand can still be dangerous once it's scheduled: cron will happily start a new run of a job every five minutes regardless of whether the previous run is still going, cron's environment is far sparser than an interactive shell's (a near-empty PATH, no timezone set the way you'd expect, no .bashrc sourced), and -- the part that bites hardest -- there is no terminal for anyone to watch, so a script that fails silently can stay broken for weeks before anyone notices. This lesson covers the three things that turn an ordinary script into one that's actually safe to hand to cron: overlap prevention via locking, explicit timezone handling, and failure logging that surfaces problems instead of hiding them.

Scheduling With Cron: The Sparse Environment

A crontab entry (crontab -e, or a file under /etc/cron.d/) has five time fields (minute, hour, day-of-month, month, day-of-week) followed by the command to run, e.g. */5 * * * * /usr/bin/php /var/www/app/cron/sync.php >> /var/log/sync.log 2>&1 -- running sync.php every five minutes and appending both STDOUT and STDERR (2>&1 redirects STDERR into wherever STDOUT is going) to a log file. Because cron itself doesn't email failures anywhere by default on most modern systems unless MAILTO is configured and mail delivery actually works, that redirected log file is very often the *only* record of what happened.

Cron runs jobs with a minimal environment: no PATH beyond a bare default (commonly just /usr/bin:/bin), none of the environment variables your interactive shell exports, and no working directory guarantee -- which is why scripts scheduled from cron should always use absolute paths (for the PHP binary, for any files the script reads or writes, for any other executables it shells out to) rather than relying on PATH lookups or a relative path that assumed a particular current directory.

The practical fix for most of cron's environment gaps is to be explicit inside the script or the crontab line itself: set any needed environment variables directly in the crontab (PATH=/usr/local/bin:/usr/bin:/bin as a line at the top of the crontab, or VAR=value before the command), or have the PHP script itself load a .env file / call putenv() for anything it depends on, rather than assuming the ambient environment will look like an interactive shell's.

• Crontab format: minute hour day-of-month month day-of-week command. • >> logfile 2>&1 -- append STDOUT and redirect STDERR into the same log file; often the only record kept. • Cron's PATH and environment are minimal -- always use absolute paths inside a cron-scheduled script. • Set needed environment variables explicitly (in the crontab or the script) rather than assuming they're inherited.

Overlap Prevention: Locking Against a Slow Previous Run

The single most common cron bug: a job scheduled every 5 minutes takes 8 minutes to run under real load (a slow API, a bigger-than-usual batch of rows), so cron starts a second instance while the first is still running, then a third before either finishes -- and now multiple instances are racing over the same data, multiplying load exactly when the system is already struggling, sometimes cascading into an outage. A script scheduled by cron must assume it can be started again before it finishes, and must actively refuse to run a second copy if one is already in progress.

The simplest reliable mechanism in plain PHP is a file lock: open (or create) a fixed lock file and call flock() with LOCK_EX | LOCK_NB (non-blocking exclusive lock) at the very start of the script. If flock() returns true, this process holds the lock and may proceed; if it returns false, another instance already holds it, so this run should log that fact and exit immediately (with a non-zero code, or a distinct 'skipped' code so it's clearly not treated as a hard failure) rather than waiting around. The lock is released automatically when the file handle is closed or the process ends, which is why keeping the handle open in a variable for the script's entire lifetime -- rather than closing it early -- matters.

For a single-server setup, a file lock in a fixed, known location (e.g. /var/run/myapp/sync.lock or a path under the app's own directory) is sufficient and requires no extra infrastructure. Once a job runs across multiple servers (so two different machines might both try to run it at the scheduled time), a file lock on one machine doesn't protect against the other machine also running -- that requires a shared lock instead, such as a Redis key set with SET key value NX EX <ttl> (succeeds only if the key doesn't already exist, with an expiry as a safety net against a crashed process leaving a lock stuck forever) or an equivalent database-row-based lock.

• flock($handle, LOCK_EX | LOCK_NB) -- try to acquire an exclusive lock without blocking; returns false if already held. • On lock failure: log it and exit immediately -- never wait, and never treat it as a hard error either. • Keep the file handle open for the script's whole run; closing it (or the script ending) releases the lock. • Multi-server jobs need a shared lock (Redis SET NX EX, or a DB row), not a local file lock.

Timezone: Cron's Clock vs. PHP's Clock

Cron schedules run according to the system clock and the system's configured timezone (or a TZ= line set inside the crontab itself on systems that support it), which is very often UTC on a server regardless of where the team or the business operating hours are -- a schedule written as 0 9 * * * assuming "9 AM local time" can actually fire at 9 AM UTC, which might be 1 AM or 5 PM local time depending on the server's real configuration. Never assume; check explicitly with timedatectl (systemd) or cat /etc/timezone, and check what a crontab's own TZ line (if any) says, before reasoning about when a job "actually" runs.

Independently of what timezone cron itself uses to *decide when to fire*, the PHP script's own idea of the current time -- date(), new DateTime(), time()-based logic -- depends on PHP's configured default timezone (the date.timezone php.ini setting, or an explicit date_default_timezone_set() call at the top of the script), which can differ from the system/cron timezone entirely if it isn't set. A script that logs timestamps or makes date-based decisions ("only run the monthly report on the 1st") should call date_default_timezone_set() explicitly at the very top, rather than relying on whatever php.ini happened to default to -- this is the single most reliable fix for cron jobs whose logs mysteriously show times that don't match the wall clock.

The safest general convention for anything scheduled and logged is: run cron itself and store/compare all internal timestamps in UTC, then convert to a human's local timezone only at the point of display (in a report, an email, a dashboard) using DateTime's setTimezone() -- this sidesteps daylight-saving-time transitions entirely for the scheduling and logging logic, and confines timezone-conversion complexity to the one place it's unavoidable: showing a time to a person.

• Cron fires based on the system clock's timezone (often UTC on servers) -- verify it, never assume. • PHP's own date()/DateTime calls follow date.timezone / date_default_timezone_set(), independently of cron's timezone. • Call date_default_timezone_set() explicitly at the top of every scheduled script -- don't rely on php.ini defaults. • Store and log in UTC; convert to a local timezone only when displaying a time to a human.

Failure Logging: Making Silence Impossible

A cron job that fails silently is worse than one that fails loudly, because nobody finds out until the downstream damage (a report nobody received, a stale cache, an unsent batch of emails) is discovered independently, often much later. The baseline fix is simple and often skipped: wrap the script's real logic in a try/catch at the top level, log any caught Throwable with its message and stack trace to a log file (and to STDERR, since cron's 2>&1 >> logfile redirect will have already captured that too), and exit with a non-zero code on failure -- an uncaught exception in PHP CLI does print to STDERR and exit non-zero automatically, but a deliberate catch lets you log a cleaner, more specific message and make sure cleanup (like releasing the lock) still happens.

Logging to a file that nobody reads is only marginally better than not logging at all -- pairing a log file with an active alert closes that gap: many teams have the script itself notify an external channel (a monitoring endpoint hit on success called a 'dead man's switch' or 'heartbeat' -- a service like this alerts precisely when the expected ping *doesn't* arrive on schedule, catching both crashes and a cron entry that got silently deleted) or push a message to Slack/email/PagerDuture only on failure, since success on every run doesn't need active attention but failure does.

A structured log line (a fixed format like [timestamp] [level] [job_name] message, or genuinely structured JSON for anything feeding a log aggregator) is worth the small extra effort over a bare echo, because it's what makes grep ERROR /var/log/myjob.log or a log-shipping pipeline actually useful later -- an unattended job's log is often read only during an incident, under time pressure, and unstructured noise costs real minutes exactly when they matter most.

• Wrap the script's logic in try/catch; log the Throwable's message (and ideally trace) before exiting non-zero. • A log file nobody actively watches doesn't catch a failure -- pair it with an active alert (heartbeat/dead-man's-switch, or a failure-only notification). • A dead man's switch alerts when an expected success ping *doesn't* arrive -- it also catches a cron entry that silently stopped firing at all. • Use a consistent log line format (timestamp, level, job name, message) so failures are easy to grep or ship to log aggregation.

flock() Locking and an Explicit Timezone

Using LOCK_NB means flock() returns immediately instead of waiting for the other instance to finish -- exactly what a cron job wants. exit(0) on 'already running' is deliberate: it's an expected condition under load, not an error condition that should page anyone.

// Non-blocking exclusive lock at the very top of a cron script:
$lockPath = '/var/run/myapp/sync.lock';
$lockHandle = fopen($lockPath, 'c'); // 'c': create if missing, don't truncate
if ($lockHandle === false || !flock($lockHandle, LOCK_EX | LOCK_NB)) {
    fwrite(STDERR, "Another instance is already running -- exiting.\n");
    exit(0); // not a failure, just a skipped overlapping run
}

// Explicit timezone -- don't rely on php.ini defaults for a scheduled script:
date_default_timezone_set('UTC');
$startedAt = new DateTimeImmutable('now'); // now unambiguously UTC

// ... do the actual work ...

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

Code Examples

This is the full pattern for any script handed to cron: set the timezone explicitly before doing anything date-related, acquire a non-blocking lock and exit cleanly (code 0, not an error) if another run already holds it, run the real work inside a try/catch so any Throwable is caught and logged with a non-zero exit rather than failing silently, and release the lock in a finally block so it's freed whether the run succeeded, failed, or threw. Expected result: Normal run (sync.log): [2026-09-20 03:00:00] [INFO] [sync-orders] Run started. [2026-09-20 03:00:00] [INFO] [sync-orders] Run completed successfully. Overlapping run started while the lock is held: [2026-09-20 03:05:00] [WARNING] [sync-orders] Skipped: previous run still holds the lock. A failed run: [2026-09-20 03:10:00] [ERROR] [sync-orders] Run failed: Upstream orders API returned a 500. Crontab entry for this script: */5 * * * * /usr/bin/php /var/www/app/cron/sync_orders.php >> /var/log/sync_orders_cron.log 2>&1 -- note the script keeps its own structured sync.log via log_line() *in addition to* whatever raw STDOUT/STDERR cron itself redirects, so a stray fatal error PHP prints before log_line() even runs is still captured somewhere.

if ($lockHandle === false || !flock($lockHandle, LOCK_EX | LOCK_NB)) { log_line('WARNING', 'Skipped...'); exit(0); }

#!/usr/bin/env php
<?php
declare(strict_types=1);

const LOCK_PATH = __DIR__ . '/sync.lock';
const LOG_PATH  = __DIR__ . '/sync.log';
const JOB_NAME  = 'sync-orders';

function log_line(string $level, string $message): void {
    $line = sprintf('[%s] [%s] [%s] %s', gmdate('Y-m-d H:i:s'), $level, JOB_NAME, $message);
    file_put_contents(LOG_PATH, $line . "\n", FILE_APPEND | LOCK_EX);
    if ($level === 'ERROR') {
        fwrite(STDERR, $line . "\n");
    }
}

// 1) Explicit timezone -- never rely on the ambient/php.ini default for a scheduled job.
date_default_timezone_set('UTC');

// 2) Acquire an exclusive, non-blocking lock so a slow run can't overlap the next one.
$lockHandle = fopen(LOCK_PATH, 'c');
if ($lockHandle === false || !flock($lockHandle, LOCK_EX | LOCK_NB)) {
    log_line('WARNING', 'Skipped: previous run still holds the lock.');
    exit(0); // expected under load -- not a failure
}

$exitCode = 0;
try {
    log_line('INFO', 'Run started.');

    // 3) The actual scheduled work, isolated so any failure is caught below.
    syncOrders();

    log_line('INFO', 'Run completed successfully.');
} catch (Throwable $e) {
    log_line('ERROR', 'Run failed: ' . $e->getMess
…

Two Overlapping Cron Firings, With and Without a Lock

Without a lock, a slow run causes every subsequent scheduled firing to pile on top of it; with a non-blocking lock, each overlapping firing detects the conflict and exits immediately instead of racing.

ohne lock (no protection):
  00:00  run #1 starts (expected to take 3 min) ---------------------\
  00:05  run #2 starts (cron fires again on schedule) ----\           \
  00:10  run #3 starts -----\                              \           \
                     run #1, #2, #3 all touching the same rows at once!

with flock(LOCK_EX | LOCK_NB):
  00:00  run #1 starts, acquires lock ------------------------------->  done, releases lock
  00:05  run #2 starts, flock() fails (lock held) -> log + exit(0) immediately
  00:10  run #3 starts, lock is free now (run #1 finished) -> proceeds normally

Important Rules

Every cron-scheduled script acquires a non-blocking lock (flock(), or a shared lock for multi-server jobs) before doing real work.

Failing to acquire the lock is a skip (exit 0, logged), not a hard failure -- it's expected behavior under load.

Call date_default_timezone_set() explicitly; never assume php.ini's default matches what you intend.

Store and log timestamps in UTC (gmdate()/DateTimeImmutable with UTC); convert to local time only for human display.

Wrap the real work in try/catch, log every Throwable with enough detail to diagnose it later, and exit non-zero on failure.

Release the lock in a finally block so it's always freed, success or failure.

Use absolute paths everywhere in a cron-scheduled script -- cron's PATH and working directory can't be assumed.

Dead Man's Switches in Production

Production teams commonly pair every important cron job with an external heartbeat check (services like Healthchecks.io, Cronitor, or an internal equivalent built on a simple 'last successful ping' timestamp in a database): the script sends a ping (a plain HTTP GET to a unique URL) only on successful completion, and the monitoring service alerts the team specifically when the *expected* ping fails to arrive within the job's normal window. This catches failure modes a log file alone misses entirely -- a cron entry accidentally deleted during a deploy, a server that stopped running cron altogether, or a script that hangs forever without ever reaching its own error-logging code -- because in every one of those cases, nothing gets logged locally at all; the absence of the expected ping is the only signal.

// crontab: */5 * * * * /usr/bin/php /var/www/app/cron/sync.php >> /var/log/sync.log 2>&1 // sync.php has no locking at all -- just runs syncOrders() directly.

// sync.php acquires flock(LOCK_EX | LOCK_NB) first and exits immediately // (logging a WARNING) if another instance already holds it.

The moment syncOrders() legitimately takes longer than 5 minutes under real load -- a slow API, a bigger batch -- cron starts a second, then a third instance on top of the first, all racing over the same data and multiplying load exactly when the system is already struggling. A non-blocking lock turns that pile-up into a clean, logged skip instead of a cascading failure.

// script logs: file_put_contents('job.log', date('Y-m-d H:i:s'). " done\n", FILE_APPEND); // -- relies on php.ini's default timezone, never set explicitly date_default_timezone_set('UTC'); file_put_contents('job.log', gmdate('Y-m-d H:i:s'). " done\n", FILE_APPEND);

If php.ini's date.timezone differs from what the team assumes (or is unset, triggering a warning and falling back to a default), every logged timestamp is silently wrong relative to the real wall-clock time the job ran -- which makes it far harder to correlate a logged failure with, say, an incident reported by another system at a specific real-world time.

A job is scheduled every 5 minutes via cron. One run unexpectedly takes 12 minutes because an upstream API is slow. Without any locking, what happens? A. Cron automatically detects the previous run is still active and skips the next scheduled firing B. Cron starts new instances at each scheduled time regardless of whether the previous run finished, so up to 2-3 overlapping instances can end up running at once C. The PHP script automatically queues the new invocation until the previous one finishes D. cron refuses to run a script that previously took longer than its own interval

B. Cron starts new instances at each scheduled time regardless of whether the previous run finished, so up to 2-3 overlapping instances can end up running at once — Cron has no built-in awareness of whether a previously scheduled invocation of a job is still running -- it simply fires again at each scheduled time. Preventing overlap is the script's own responsibility, typically via a non-blocking file (or shared) lock acquired at the start of the run.

Why should a cron-scheduled PHP script call date_default_timezone_set() explicitly rather than relying on php.ini's default? A. date_default_timezone_set() is required syntax and PHP will refuse to run without it B. It has no real effect; timezone only matters for web requests, not CLI scripts C. Cron fires based on the system clock's timezone (often UTC on servers), which can differ from php.ini's date.timezone default -- leaving it unset risks the script's own logged/computed times not matching what was intended or expected D. It makes the script run faster

C. Cron fires based on the system clock's timezone (often UTC on servers), which can differ from php.ini's date.timezone default -- leaving it unset risks the script's own logged/computed times not matching what was intended or expected — Cron's firing schedule and PHP's own date()/DateTime calculations are governed by two independent settings (the system/cron timezone, and PHP's date.timezone or an explicit date_default_timezone_set() call). Leaving the second unset and assuming it matches the first is a common source of scheduled jobs whose logs show times that don't match reality.

Modify the sync_orders.php example so that instead of only logging locally, it also sends a heartbeat -- for this exercise, simply append a line to a separate heartbeats.log file with the current UTC timestamp -- but only when the run completes successfully (not on a skip, and not on a failure).

// inside the try block, right after log_line('INFO', 'Run completed successfully.'); file_put_contents( __DIR__ . '/heartbeats.log', gmdate('Y-m-d H:i:s') . " " . JOB_NAME . " OK\n", FILE_APPEND | LOCK_EX );

Add the heartbeat write as the last line inside the try block, right after 'Run completed successfully' is logged, so it only executes on the success path and is naturally skipped if syncOrders() throws.