One Binary, Two Jobs: PHP Without a Web Server
The php you installed for web work is also a complete scripting runtime. Point it at a file instead of a URL and you are in the CLI SAPI — the same engine, different host environment:
php bin/migrate.php # run a script, print to the terminal
php -f bin/migrate.php # identical; -f just states "file" explicitly
Inside the script, PHP_SAPI is 'cli' (a web request reports fpm-fcgi, apache2handler, or cli-server). That constant is how library code branches between behaviors — logging to stderr instead of a response body, disabling session auto-start, or refusing to run dangerous maintenance code in a web context.
The CLI host differs from a web SAPI in ways you rely on daily:
| Property | Web SAPI | CLI SAPI |
|---|---|---|
| Standard streams | response body only | STDIN, STDOUT, STDERR constants you can read and write |
max_execution_time |
30s typical | 0 — unlimited (long workers are normal) |
memory_limit |
128M typical | -1 by default since PHP 7.1 (a distro php.ini may cap it) |
| Input | request body with timeouts | the script's arguments; STDIN for piped data |
| Output | headers + body, once | streamed line by line as you echo |
That last row changes the interaction model. There is no request to "respond" to, so progress output is immediate: a 40-row migration can print each step as it happens, with fwrite(STDERR, ...) reserved for diagnostics so a caller can separate data from noise by redirecting one stream.
The jobs this SAPI exists for are the ones a web request is worst at:
php bin/console doctrine:migrations:migrate # schema migrations
php bin/queue-worker.php --once # background workers
php tools/build-assets.php # build/compile steps
php scripts/repair-emails.php --dry-run # one-off data repairs
None of these fit inside a 30-second HTTP request, and all of them must be runnable from cron, a CI pipeline, or an ops terminal with no browser anywhere in the picture.
For files meant to be executed directly, add a shebang and make them executable:
#!/usr/bin/env php
<?php
fwrite(STDOUT, "starting import\n");
chmod +x import.php
./import.php --file=users.csv # env resolves `php` from PATH
env php rather than a hard path means the script follows whatever version PATH selects — the same PATH discipline from Installing PHP and a Local Server.
Two CLI-only affordances complete the picture. Any ini setting can be overridden for a single run with -d, which is invaluable when a memory-hungry migration needs room without editing a shared server config:
php -d memory_limit=2G -d display_errors=1 bin/import.php --file=users.csv
And scripts can prompt when attached to a terminal:
if (function_exists('readline') && posix_isatty(STDIN)) {
$confirm = readline('This deletes 4,000 rows. Continue? [y/N] ');
}
posix_isatty(STDIN) guards the prompt so a cron invocation never blocks forever waiting for input that will never arrive — a small check that prevents a very large class of 3 a.m. pages.
Interviewers use this topic as a proxy for operational experience: "how would you run this nightly?" The correct reflex is a CLI script with arguments, exit codes, and a log line — not an HTTP endpoint protected by a secret.
$argv, $argc, and getopt(): Taking Arguments Seriously
PHP does not parse arguments for you. It assembles them into two variables the CLI injects into every script's global scope:
php import.php --file=users.csv --dry-run extra-arg
// $argc === 4
// $argv[0] => 'import.php' the script itself — an argument, not the program name
// $argv[1] => '--file=users.csv'
// $argv[2] => '--dry-run'
// $argv[3] => 'extra-arg'
Two details bite newcomers. $argv[0] is the script path, so real arguments start at index 1 — array_slice($argv, 1) is the idiom for "everything the user typed". And nothing is interpreted: --dry-run arrives as the literal string, quotes already consumed by the shell (--name="Ann Lee" arrives as one element, Ann Lee).
For conventional flags, getopt reads the options without touching $argv:
<?php
// usage: php import.php -v --file=users.csv --dry-run
$opts = getopt('v', ['file:', 'dry-run']);
var_dump($opts);
// array(3) {
// ["v"]=> bool(true) no colon = boolean flag
// ["file"]=> string(9) "users.csv" colon = requires a value
// ["dry-run"]=> bool(true)
// }
The short spec 'vq:' means -v is a flag and -q takes a value; the long spec mirrors it with colons. Values may come as --file=users.csv or --file users.csv.
Know getopt's limits before you build a real CLI on it: it silently ignores unknown options, it stops at the first positional argument (so import.php users.csv --dry-run never sees --dry-run), and it does not validate. When your tool needs subcommands or positionals, parse $argv by hand:
<?php
$args = array_slice($argv, 1);
$file = null; $dryRun = false;
while ($args !== []) {
$arg = array_shift($args);
switch (true) {
case $arg === '--dry-run':
$dryRun = true;
break;
case str_starts_with($arg, '--file='):
$file = substr($arg, 7);
break;
case $file === null: // positional fallback
$file = $arg;
break;
default:
fwrite(STDERR, "Unknown option: $arg\n");
exit(2); // misuse — see the next chapter
}
}
if ($file === null || !is_readable($file)) {
fwrite(STDERR, "Usage: php import.php [--dry-run] <file>\n");
exit(2);
}
str_starts_with is PHP 8.0 — on 7.4 you would use strpos($arg, '--file=') === 0, and the migration difference is exactly the kind of version-sensitive detail flagged in the install topic.
Arguments are also how data arrives from other programs. STDIN is a regular stream, so shell pipes compose PHP with anything:
cat access.log | php tools/parse-log.php --format=combined
php export.php > users.csv # STDOUT redirected to a file
php import.php < backup.sql # STDIN redirected from a file
Inside the script, stream_get_contents(STDIN) reads it all; while (($line = fgets(STDIN)) !== false) streams it line by line for large inputs. Composability, not just automation, is the argument for writing tools in the CLI SAPI.
There is a third input channel worth deciding about deliberately: the environment. $_SERVER['argv'] and $argv hold the same array, and getenv('APP_ENV') reads variables the shell exported — APP_ENV=staging php deploy.php. The convention from twelve-factor apps keeps configuration in the environment (hostnames, credentials, log level) and behavior in arguments (--dry-run, --file=x), because arguments are per-invocation and env vars are per-process. When both exist, let the flag win and document the precedence; ambiguity there is how staging deploys quietly hit production databases.
Exit Codes, stderr, and Scheduling Without Surprises
A script that runs and a script that succeeds are different claims. Shell scripts, cron, and CI distinguish them by one number: the exit status.
<?php
if (! $db->exec($sql)) {
fwrite(STDERR, "Migration failed: {$db->errorInfo()[2]}\n");
exit(1);
}
fwrite(STDOUT, "Migration applied\n");
exit(0); // omitting this also returns 0 — falling off the end is success
The shell convention: 0 means success, any non-zero means failure — the inverse of most languages' truthiness, and the single most common bug when PHP code is embedded in shell (if php script.php; then succeeds only on exit 0). Keep to small codes: 0 success, 1 general failure, 2 misuse of the tool (the grep tradition your ops team already expects). Values 129 and up mean the process was killed by signal N (128 + N): 137 is SIGKILL's signature — usually the OOM killer, sometimes kill -9 in a deploy script.
Two PHP specifics close the loop on failure handling. An uncaught exception exits with code 255 by default, so registering a top-level handler turns a stack trace into a controlled code:
set_exception_handler(function (Throwable $e): void {
fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . "\n");
exit(1);
});
And the hardest failures — E_ERROR such as memory exhaustion — never reach a catch block, so a shutdown function covers them:
register_shutdown_function(function (): void {
$e = error_get_last();
if ($e !== null && in_array($e['type'], [E_ERROR, E_PARSE], true)) {
fwrite(STDERR, "Fatal: {$e['message']} in {$e['file']}:{$e['line']}\n");
exit(1);
}
});
With codes and stderr in place, scheduling becomes routine. Cron needs four things to be reliable — an absolute path, a cd, a lock, and a log:
*/5 * * * * cd /srv/app && flock -n /tmp/app-maint.lock php bin/maintenance.php >> /var/log/maint.log 2>&1
Read it left to right: run every five minutes; change to the project directory (cron starts in $HOME, so relative paths and .env loading break otherwise); flock -n takes a lock and fails immediately if a previous run still holds it, which stops a slow job from piling up concurrent copies; >> appends stdout to a log and 2>&1 folds stderr into the same file, so failures are findable in one place.
The CI story is the same contract one level up: a pipeline step fails when its command exits non-zero, which is why a PHP tool that "handles errors" by printing a warning and exiting 0 is worse than useless — it converts broken migrations into green builds. Print the diagnosis to STDERR, return a code that means it, and every scheduler in existence does the right thing with your script.
For jobs that run for minutes rather than seconds, output discipline matters as much as exit codes. CLI output is unbuffered by default, so each echo is immediately visible — prefix every line with a timestamp (printf("[%s] imported %d rows\n", date('c'), $n)) because a log file assembled from cron tells you when things happened, and interleaved with other jobs' output if the lock is ever skipped. Long imports should print progress every few hundred rows rather than one line at the end; the operator staring at a silent terminal after ten minutes cannot tell a slow job from a dead one. And when you need durable records rather than console chatter, error_log($message, 3, '/var/log/app-import.log') appends to a file regardless of who invoked the script — cron, CI, or an interactive shell — giving every entry point the same audit trail.
Quiz
1. `php import.php --file=users.csv extra` — what are $argv[0], $argv[1], and $argv[2]?
2. A cron job runs every 5 minutes but the job takes 20 minutes, and runs start overlapping. What is the standard fix?
3. A CI step runs `php check.php`, which prints an error but exits normally. The pipeline stays green. Why, and what is the fix?
4. You need `--dry-run` to be honored when given after a positional argument: `import.php users.csv --dry-run`. Why does getopt() miss it and what do you do?
Flashcards
Question
How do you detect the CLI SAPI in code, and why would you?
Click to reveal answer
Answer
`PHP_SAPI === 'cli'` (or php_sapi_name()). Libraries branch on it to write diagnostics to STDERR instead of a response body, skip session auto-start, allow long-running work (CLI has max_execution_time=0 and memory_limit=-1 by default), or refuse dangerous maintenance operations in a web context.
Question
What do $argv and $argc contain, and what is the classic off-by-one?
Click to reveal answer
Answer
$argv is the argument vector where index 0 is the SCRIPT PATH and user arguments start at index 1; $argc is count($argv). The classic bug is treating $argv[0] as the first user argument — the idiom is array_slice($argv, 1).
Question
How does getopt() declare flags versus valued options, and what are its three main limitations?
Click to reveal answer
Answer
Short spec 'vq:' means -v is a flag and -q takes a value; long spec ['file:', 'dry-run'] mirrors it — a colon requires a value. Limitations: it silently ignores unknown options, it stops scanning at the first positional argument, and it does not validate — so complex CLIs parse $argv by hand.
Question
What exit statuses must a PHP CLI tool produce, and what do unusual codes mean?
Click to reveal answer
Answer
0 = success (falling off the end also yields 0), 1 = failure, 2 = misuse. Uncaught exceptions produce 255 by default — override with set_exception_handler. Statuses above 128 mean killed by signal N (128+N): 137 is SIGKILL, typically the OOM killer.
Question
What four ingredients make a cron entry reliable, and what does each prevent?
Click to reveal answer
Answer
An absolute php path and script path (cron's PATH is minimal), `cd /srv/app` before running (cron starts in $HOME, breaking .env and relative paths), `flock -n lockfile` (prevents overlapping runs of a slow job), and `>> log 2>&1` (appends stdout and merges stderr so failures are findable in one file).
Revision Notes
Key Takeaways
- 1. The CLI SAPI is the same engine without a request: STDIN/STDOUT/STDERR, unlimited execution time, memory_limit -1 by default
- 2. CLI exists for jobs that cannot fit an HTTP request: migrations, queue workers, build steps, one-off data repairs
- 3. $argv[0] is the script path and user args start at index 1; getopt handles simple flags but stops at the first positional
- 4. Failure is a number: exit(0) success, exit(1) failure, exit(2) misuse; uncaught exceptions return 255, signals 128+N
- 5. Reliable cron = absolute paths + cd + flock -n + appended log with 2>&1
Interview Tips
- • "How would you run this nightly?" — a CLI script with arguments, exit codes, and a cron entry using flock and a log; never a secret-protected HTTP endpoint
- • "Your CI is green although the script failed — what happened?" — the script printed an error but exited 0; explain the shell convention (0 = success) and the fix: STDERR + exit(1)
- • "getopt vs manual parsing?" — getopt for simple flag/value specs; manual array_slice($argv, 1) loop when you need subcommands, positionals, or strict validation, because getopt stops at the first positional and drops unknowns
- • "A cron job piles up duplicate runs — why and the fix?" — the interval fires again while the previous run holds; flock -n makes the second invocation exit immediately; also mention log rotation awareness for >> files
Cheat Sheet
Running Scripts with the PHP CLI
Invocation:
php script.php # CLI SAPI; PHP_SAPI === 'cli'
php -f script.php # same thing, explicit
./script.php # with #!/usr/bin/env php + chmod +x
php -r 'echo PHP_VERSION;' # one-liner, no file
CLI vs web SAPI:
| Web | CLI | |
|---|---|---|
| Streams | response body | STDIN / STDOUT / STDERR |
max_execution_time |
~30s | 0 (unlimited) |
memory_limit |
128M typical | -1 default since 7.1 |
| Input | request (with timeouts) | $argv + piped STDIN |
Arguments:
$opts = getopt('v', ['file:', 'dry-run']); // v/dry-run = flags, file: = requires value
$args = array_slice($argv, 1); // user args; $argv[0] is the script path
stream_get_contents(STDIN); // read piped input
getopt ignores unknown options and stops at the first positional — parse $argv by hand for subcommands.
Exit contract:
fwrite(STDERR, "diagnostic\n"); // diagnostics → stderr
exit(0); // success (falling off the end also returns 0)
exit(1); // failure exit(2): misuse
Uncaught exceptions exit 255; use set_exception_handler + register_shutdown_function (catches E_ERROR) to control it. Killed by signal N → status 128+N (137 = OOM/SIGKILL).
Reliable cron:
*/5 * * * * cd /srv/app && flock -n /tmp/app.lock php bin/maint.php >> /var/log/maint.log 2>&1
absolute binary + cd + flock (no overlap) + append-log with stderr merged.