Scheduling And Background Work
Cron Fundamentals
Crontab Syntax
A crontab line has five time fields, then the command:
# minute hour day-of-month month day-of-week command
*/5 * * * * /usr/bin/php /var/www/app/bin/sync.php
Each field accepts a number, * (any value), a list (1,15), a range (1-5), or a step (*/5). The five fields in order are minute (0-59), hour (0-23), day of month (1-31), month (1-12), and day of week (0-6, where both 0 and 7 mean Sunday). */5 * * * * reads as "every 5 minutes, every hour, every day" — one minute is cron's finest granularity; there is no seconds field, so sub-minute scheduling needs a different mechanism (a long-running worker loop, or the request-triggered approach in the next lesson).
Cron's Environment Is Not Your Shell's Environment
This is the single most common source of "it works when I run it manually but fails under cron." An interactive shell loads your .bashrc/.profile, giving you a full PATH, your working directory, and whatever environment variables your login process set up. Cron runs commands with a minimal environment — often just SHELL, HOME, LOGNAME, and a bare-bones PATH like /usr/bin:/bin — and no relationship to your interactive shell's configuration at all.
The consequences are specific and predictable:
- A bare command name fails.
php script.phpin a crontab can fail with "command not found" even thoughphp script.phpworks perfectly when you type it, because cron's minimalPATHmay not include wherever your shell'sPATHfoundphp. Always use the absolute path to the interpreter:/usr/bin/php(find it once withwhich php). - Relative file paths break. Cron does not run commands from the directory you'd expect — there's no reliable "current directory" carried over from anywhere. A script that does
require 'config.php'assuming it's run from the project root will fail under cron even though it works when youcdthere and run it manually. Use absolute paths, or have the script resolve paths relative to__DIR__/dirname(__FILE__)rather than the process's working directory. - Expected environment variables are missing. If your application reads
getenv('APP_ENV')and that variable is normally set by your shell profile or a.env-loading mechanism tied to your login session, cron's process won't have it unless the crontab or the script explicitly sets it.
A defensive PHP scheduled command checks for what it needs and fails with a clear message instead of behaving unpredictably:
<?php
declare(strict_types=1);
function requireEnv(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
fwrite(STDERR, "Missing required environment variable: {$name}\n");
exit(1);
}
return $value;
}
// A real crontab entry would set this explicitly, e.g.:
// APP_ENV=production /usr/bin/php /var/www/app/bin/sync.php
putenv('APP_ENV=production');
$env = requireEnv('APP_ENV');
echo "Running sync in {$env}\n";
// Prints:
// Running sync in production
This fails fast and loud — exit(1) plus a message on STDERR — rather than continuing with an assumed default that might be wrong in a way nobody notices until data is corrupted.
Where Output Goes
By default, cron captures anything a command writes to stdout or stderr and, historically, emails it to the crontab owner via the local mail system (MAILTO in the crontab controls the recipient, or disables mail entirely if set to an empty string). On most modern servers, local mail delivery isn't configured, which means that output silently disappears — not an error, not a bounce, just gone. A script that only reports problems by printing to stdout, with no other logging, can fail for months with nobody noticing.
The fix is explicit: redirect output to a file the script controls, or have the script write structured logs on its own regardless of what cron does with stdout:
*/5 * * * * /usr/bin/php /var/www/app/bin/sync.php >> /var/log/app/sync.log 2>&1
>> file 2>&1 appends both stdout and stderr to a log file — visible, inspectable, rotatable with normal log tooling — instead of depending on a mail pipeline that may or may not exist. Better still, monitor that log (or have the script report success/failure to an external monitoring endpoint, covered in the distributed-scheduling lesson) rather than relying on someone manually tailing it.
Exit Codes Matter, Even Though Cron Mostly Ignores Them
Cron itself doesn't act on a command's exit code beyond logging that the job ran — it won't retry, alert, or roll anything back based on failure. That means exit codes only become useful once something else is watching them: a wrapper script, a monitoring check reading the log, or an external heartbeat. A scheduled command should still exit non-zero on failure and zero on success, consistently, so that whatever's watching (even if it's just a future engineer running echo $? while debugging) gets an honest signal.
Failure Modes
The most common failure is testing manually in a shell, then deploying a crontab entry that fails silently — because the manual test ran with your full shell environment and the crontab entry doesn't, and nothing surfaced the difference until the job had already been silently broken for a while.
The second is relying on cron's mail delivery for failure visibility on a server where local mail was never configured — a job can fail every single run for weeks with zero visibility, because the "notification" mechanism was never actually connected to anything.
The third is assuming cron guarantees only one instance runs at a time — it does not. If a job scheduled every minute takes 90 seconds, cron will happily start a second instance while the first is still running. That's the subject of the next lesson.
What To Check
Before moving on, make sure you can:
- read and write a five-field crontab schedule, including step and range syntax
- explain why a command that works in an interactive shell can fail under cron, and name the specific environment differences responsible
- write a PHP scheduled command that fails fast with a clear message when a required environment variable is missing, rather than silently using a wrong default
- redirect a crontab entry's output to a log file instead of depending on local mail delivery
- explain why cron's lack of overlap protection matters for jobs whose runtime can exceed their schedule interval
What You Should Be Able To Do
After this lesson, you should be able to write and troubleshoot a crontab entry for a PHP script: using absolute paths, validating the environment the script actually receives, redirecting output somewhere durable, and reasoning about exit codes and monitoring rather than assuming cron's default behavior is sufficient for production use.
Practice
Translate a schedule to crontab
Your team needs a PHP script /var/www/app/bin/reconcile.php to run every 15 minutes, but only send its output somewhere durable rather than relying on cron's mail delivery (which isn't configured on this server). The script depends on an APP_ENV environment variable that your interactive shell normally sets via .bashrc, but cron won't have it.
Write:
- The crontab line, with correct absolute paths and output redirection.
- A PHP snippet the script uses at its start to fail fast and clearly if
APP_ENVisn't set — rather than silently defaulting to something wrong. - One sentence explaining why the crontab line needs an absolute path to the
phpbinary specifically, not justphp.
Show solution
Solution
Crontab line:
*/15 * * * * APP_ENV=production /usr/bin/php /var/www/app/bin/reconcile.php >> /var/log/app/reconcile.log 2>&1
*/15 * * * * fires every 15 minutes. Setting APP_ENV=production directly in the crontab line is the simplest fix for the missing-environment-variable problem — cron passes variables set this way to the command's environment, so the script sees APP_ENV without needing to load a shell profile that cron never runs. >> /var/log/app/reconcile.log 2>&1 appends both stdout and stderr to a durable log file instead of depending on local mail delivery that isn't configured.
Fail-fast PHP snippet:
<?php
declare(strict_types=1);
function requireEnv(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
fwrite(STDERR, "Missing required environment variable: {$name}\n");
exit(1);
}
return $value;
}
putenv('APP_ENV=production'); // what the crontab line above sets in real use
$env = requireEnv('APP_ENV');
echo "reconcile.php starting in {$env}\n";
// Prints:
// reconcile.php starting in production
Placing this check at the very start of the script means a misconfigured crontab entry (someone edits it later and drops the APP_ENV=production prefix) produces an immediate, loud failure in the log — "Missing required environment variable: APP_ENV" — instead of the script silently running with whatever getenv('APP_ENV') happens to return (false), which could mean skipped validation or wrong behavior that's much harder to trace back to its cause.
Why the absolute PHP path matters: cron runs commands with a minimal PATH that may not include the directory where the interactive shell's PATH happens to find php — using /usr/bin/php (found once via which php) removes the dependency on cron's PATH containing the right directory at all, which is exactly the kind of environment-gap failure this lesson's article describes as the most common cause of "works manually, fails under cron."
Diagnose a silent cron failure
0 2 * * * php bin/import.php
Two weeks later, someone notices the imports haven't actually run since the crontab entry was added — no data, no errors anywhere anyone can find, nothing in their inbox.
Diagnose the failure and write a plan covering:
- the first three pieces of evidence to inspect (there is no log file to check yet — where do you even start?);
- the unsafe assumption baked into this crontab line;
- how you'd reproduce the failure to confirm your diagnosis before "fixing" anything;
- the durable fix — both the specific crontab line and the habit that prevents this class of bug in the future.
Show solution
Diagnosis
First three pieces of evidence:
- Check whether local mail delivery is even configured (
systemctl status postfix/sendmail, or just check/var/mail/<user>for accumulated undelivered cron output) — since this crontab entry has no output redirection, cron's default behavior is to try to email the output, and if mail isn't set up, that output is silently discarded. This is the first thing to check because it directly explains "nothing anywhere" — there was never a log file to check because nothing was ever told to write one. - Try running the exact crontab command in a clean, minimal environment rather than your normal shell —
env -i php bin/import.phpfrom a directory that isn't/var/www/app, simulating cron's stripped environment and unpredictable working directory. This is the fastest way to reproduce the actual failure instead of continuing to test in a shell that has every advantage cron doesn't. - Check the system cron log (
grep CRON /var/log/syslogor equivalent, depending on the distribution) — cron itself typically logs that it attempted to run the command, which tells you whether cron ever fired the job at all (ruling out "the crontab entry itself is malformed or never installed") versus the job firing and failing internally.
The unsafe assumption: the crontab line assumes cron will run php bin/import.php from /var/www/app, the same directory the developer manually cd'd into every time they tested. Cron has no concept of "the directory I usually work from" — it does not replicate the developer's manual cd step. bin/import.php is a relative path, and so is the bare php command name relying on cron's PATH containing whatever directory the developer's shell found php in. Either one — the relative script path resolving against the wrong directory, or php not being found at all under cron's minimal PATH — is enough to make the job fail immediately on every single run.
Reproducing safely: run env -i /bin/sh -c 'php bin/import.php' (or similar, depending on shell) from a directory other than /var/www/app, or better, from cron's actual working directory assumption — typically the crontab owner's home directory. This should reproduce the exact failure ("command not found" or "file not found") without needing to wait for the next scheduled run, and without touching the production crontab while you're still diagnosing.
Durable fix — the crontab line: use absolute paths for both the interpreter and the script, and redirect output somewhere durable:
0 2 * * * /usr/bin/php /var/www/app/bin/import.php >> /var/log/app/import.log 2>&1
Durable fix — the habit: never deploy a crontab entry that hasn't been tested with a command that approximates cron's actual environment (env -i ... from a neutral directory, not a quick manual run from the project directory), and never deploy one without explicit output redirection to a file the team actually monitors — treating "no errors in my inbox" as evidence of success is exactly the trap this crontab entry fell into, since nothing was ever wired up to send anything there in the first place.
Review a crontab entry
0 3 * * * cd /var/www/app && php bin/generate-report.php > /dev/null
Review this entry and identify every problem, covering:
- what happens to the script's output, and whether that's the right call for a job that could fail;
- whether
cd /var/www/app && php ...is a reasonable way to handle the working-directory problem, and what's still missing even if it is; - one thing about exit codes and monitoring this entry doesn't address at all;
- a corrected version of the line.
Show solution
Review
What happens to output: > /dev/null explicitly discards stdout. Worse than the "output silently vanishes because mail isn't configured" failure mode from this lesson's article — this is a deliberate choice to throw output away, including any error messages the script writes to stdout. Note also that only stdout is redirected to /dev/null; stderr is not redirected at all, so it falls back to cron's default handling (an attempted mail delivery, likely failing silently if mail isn't configured) — meaning errors go nowhere either way, just via two different paths to the same "nowhere." For a report generator that could fail (bad data, a database timeout, a template error), this guarantees failures are invisible. This should be >> /var/log/app/report.log 2>&1 at minimum, capturing both streams durably.
cd /var/www/app && php ... as a working-directory fix: this is actually a legitimate and common pattern — cd-ing explicitly before running the command does solve the relative-working-directory problem the article describes, since it doesn't rely on cron happening to start in the right place. What's still missing is the same problem on the interpreter itself: php is used bare, not as an absolute path, which still depends on cron's minimal PATH containing wherever php lives. The fix should be /usr/bin/php (or whatever which php returns on the target server), not just php, even with the cd in place.
What's unaddressed: nothing here creates any way to know whether the job actually succeeded or failed on any given night, beyond someone manually checking if a report showed up. There's no distinction preserved between "ran and succeeded," "ran and failed midway," and "never ran at all" — exactly the gap this lesson's article calls out when it says cron doesn't act on exit codes by itself and something else has to be watching. At minimum this needs output going somewhere inspectable (per the fix above); ideally, a monitoring signal (covered in the distributed-scheduling lesson) that alerts specifically when the job doesn't report success within its expected window, rather than requiring someone to notice a missing report.
Corrected version:
0 3 * * * cd /var/www/app && /usr/bin/php bin/generate-report.php >> /var/log/app/report.log 2>&1