PHP CLI
PHP CLI is the command-line version of PHP. It is used for scripts, Composer, framework console commands, migrations, test runners, cron jobs, queue workers, imports, exports, and maintenance tools.
CLI code has different inputs and outputs from web PHP. Instead of $_GET, forms, cookies, and HTTP responses, CLI scripts deal with arguments, options, standard input, standard output, standard error, and exit codes.
Running PHP From The Terminal
Common CLI commands:
php -v
php -m
php --ini
php -l script.php
php script.php
php -r "echo PHP_VERSION . PHP_EOL;"
php -l is a syntax check. php -r runs a small piece of PHP without creating a file. php script.php runs a script file.
Inside CLI PHP, PHP_SAPI is normally cli.
<?php
declare(strict_types=1);
echo 'SAPI: ' . PHP_SAPI . PHP_EOL;
// Prints:
// SAPI: cli
Positional Arguments With $argv
CLI arguments are available in $argv. The first item is the script name.
<?php
declare(strict_types=1);
$name = $argv[1] ?? null;
if ($name === null || trim($name) === '') {
fwrite(STDERR, "Usage: php greet.php <name>\n");
exit(1);
}
echo 'Hello ' . trim($name) . PHP_EOL;
// Example:
// php greet.php Ada
//
// Prints:
// Hello Ada
This is the simplest way to accept input when a script has one or two required values.
Named Options With getopt()
Named options are clearer when a command has optional settings.
<?php
declare(strict_types=1);
$options = getopt('', ['file:', 'dry-run']);
$file = $options['file'] ?? null;
$dryRun = array_key_exists('dry-run', $options);
if (!is_string($file) || $file === '') {
fwrite(STDERR, "Usage: php import.php --file=orders.csv [--dry-run]\n");
exit(1);
}
echo 'File: ' . $file . PHP_EOL;
echo 'Mode: ' . ($dryRun ? 'dry run' : 'write changes') . PHP_EOL;
// Example:
// php import.php --file=orders.csv --dry-run
//
// Prints:
// File: orders.csv
// Mode: dry run
The colon in file: means the option requires a value. dry-run has no colon because it is a boolean flag.
STDOUT, STDERR, And Exit Codes
Write normal output to STDOUT. Write errors, usage messages, and diagnostics to STDERR.
Exit codes tell the shell whether the command succeeded:
0means success- non-zero means failure
<?php
declare(strict_types=1);
$path = $argv[1] ?? null;
if (!is_string($path) || !is_file($path)) {
fwrite(STDERR, "File does not exist.\n");
exit(1);
}
echo filesize($path) . PHP_EOL;
exit(0);
Exit codes matter in CI, cron, deploy scripts, and shell pipelines. A command that prints an error but exits with 0 can make automation think everything succeeded.
Reading From STDIN
STDIN lets a command receive piped input.
<?php
declare(strict_types=1);
$input = stream_get_contents(STDIN);
$lines = array_filter(explode("\n", trim($input)));
echo 'Lines: ' . count($lines) . PHP_EOL;
// Example:
// printf "one\ntwo\n" | php count-lines.php
//
// Prints:
// Lines: 2
This is useful for small tools that work with output from other commands.
Environment Variables In CLI
CLI scripts often depend on environment variables. Cron and process managers may provide a different environment from your interactive terminal.
<?php
declare(strict_types=1);
$appEnvironment = getenv('APP_ENV') ?: 'production';
echo 'Environment: ' . $appEnvironment . PHP_EOL;
// Example:
// APP_ENV=local php env.php
//
// Prints:
// Environment: local
If a command works manually but fails in cron, check PATH, working directory, PHP binary path, and environment variables.
Shebang Scripts
On Unix-like systems, a PHP script can be directly executable with a shebang line.
#!/usr/bin/env php
<?php
declare(strict_types=1);
echo 'Hello from an executable PHP script' . PHP_EOL;
Then:
chmod +x hello
./hello
This is common for project tools in bin/.
Use $argc And $argv As Untrusted Input
$argc contains the argument count and $argv contains argument strings when the variables are available. $argv[0] identifies the invoked script or command name; later elements come from the shell after its own quoting and expansion.
Do not assume a numeric-looking argument is a valid integer, a path exists, or a filename is inside an allowed directory. Validate before performing work.
<?php
declare(strict_types=1);
$rawQuantity = $argv[1] ?? null;
$quantity = filter_var($rawQuantity, FILTER_VALIDATE_INT);
if ($quantity === false || $quantity < 1) {
fwrite(STDERR, "Quantity must be a positive integer.\n");
exit(2);
}
echo "Quantity: ", $quantity, PHP_EOL;
Use distinct non-zero statuses only when callers benefit from distinguishing usage, validation, and operational failures. Document them; unexplained status numbers are difficult to maintain.
Design An Option Contract
An option can require a value, accept an optional value, or act as a flag. With getopt(), a colon after a long option name requires a value and two colons make it optional. Optional values can be ambiguous, so prefer required values or boolean flags when possible.
Support --help without requiring the command's normal inputs. Usage text should show command shape, required values, optional flags, defaults, and at least one example.
Treat unknown options deliberately. For larger command surfaces, use a maintained console component that provides strict parsing, generated help, validation, and tests rather than building a fragile parser around getopt().
The shell convention -- marks the end of options in many command-line tools, allowing later values beginning with - to be treated as arguments. Verify how the chosen parser handles this boundary.
Remember Shell Quoting Happens First
A user can pass one argument containing spaces by quoting it:
php greet.php "Ada Lovelace"
PHP receives Ada Lovelace as one $argv element. Without quotes, the shell sends two elements. Windows PowerShell, Command Prompt, and POSIX shells have different quoting rules, especially for php -r commands.
Keep reusable code in files rather than publishing complex one-liners whose quoting works in only one shell. Never construct a shell command by concatenating untrusted CLI input.
Inspect CLI Configuration And Overrides
CLI PHP can display help and configuration information:
php --help
php --ini
php -i
Apply an INI directive to one invocation with -d:
php -d memory_limit=256M report.php
This is useful for a controlled job with a documented resource need. It does not edit php.ini or configure another SAPI. Do not use an unlimited memory or execution setting as a substitute for investigating an unbounded import.
The CLI SAPI commonly has different defaults from web SAPIs, including execution-time behaviour. Long-running commands still need explicit timeouts for network operations, bounded batches, memory monitoring, cancellation, and resumability.
Distinguish Interactive Input From A Pipeline
A command reading STDIN can receive a pipe, redirected file, or keyboard input. Decide whether waiting for interactive input is acceptable.
For automation, avoid prompts by default. A cron job cannot answer "Continue?" and can hang until killed. Provide explicit options such as --force or --no-interaction only when their safety semantics are clear.
When available, stream_isatty(STDIN) can help identify a terminal stream. Do not make correctness depend entirely on it; environments and platform support vary. A command should document whether it expects piped data or interaction.
Never read secrets from ordinary command arguments when avoidable. Arguments can appear in shell history or process listings. Use a protected file descriptor, secret store, environment mechanism with understood exposure, or an interactive no-echo prompt supplied by a suitable library.
Produce Stable Machine-Readable Output
Human-readable tables are useful in terminals but awkward for automation. A mature command can offer a format such as JSON while keeping diagnostics on stderr.
<?php
declare(strict_types=1);
$result = [
'processed' => 12,
'failed' => 0,
];
echo json_encode($result, JSON_THROW_ON_ERROR), PHP_EOL;
Do not mix progress messages into stdout when stdout is documented as JSON or CSV. Send progress to stderr or disable it in non-interactive mode. Keep field names and exit semantics stable because scripts may depend on them.
Resolve Paths Predictably
A CLI script's current working directory comes from the caller. Relative inputs such as data/orders.csv are resolved from that directory, not automatically from the script's location.
Use getcwd() when the command contract is working-directory-relative. Use __DIR__ for files packaged beside the script. Resolve and validate user-supplied paths before reading or writing them.
Avoid changing the working directory globally unless the command owns that decision and documents it. Hidden chdir() calls can make error messages and relative output paths surprising.
Make Maintenance Commands Safe To Retry
Imports, migrations, and repair commands can stop halfway because of invalid data, network failure, deployment interruption, or a terminated process. Design for restart:
- support
--dry-runwhen a meaningful preview is possible; - process bounded batches;
- record stable checkpoints;
- use transactions for atomic units;
- make repeated operations idempotent where possible;
- report counts and failed identifiers;
- avoid duplicate email, payments, or external side effects;
- provide a clear resume or rollback procedure.
A dry run must not secretly perform writes. Test it against each side effect, including database changes, files, queues, email, and external APIs.
Handle Failures At The Command Boundary
Let application services throw meaningful exceptions, then convert them to concise CLI diagnostics and non-zero statuses at the command entry point. Do not print full stack traces to ordinary users by default, but preserve enough logged detail for diagnosis.
Use try/finally for resources that must be released, and rely on database transactions or atomic file replacement where partial output would be dangerous. Temporary files should have unique names and be cleaned without deleting unrelated files.
Signals and graceful shutdown matter for long-running workers. Framework console and process components can help, but the command still needs a defined policy for finishing the current unit, releasing locks, and reporting interruption.
Test CLI Behaviour At Two Levels
Keep business logic in functions or services that can be tested without spawning a process. Then add command-level tests for:
- parsing arguments and options;
- help and usage output;
- stdout and stderr separation;
- exit statuses;
- files or records changed;
- dry-run guarantees;
- invalid input and operational failures.
A command that only works when manually observed is difficult to trust in CI, cron, or deployment automation.
Common CLI Failure Patterns
| Symptom | Likely boundary |
|---|---|
| Argument with spaces splits unexpectedly | Shell quoting before PHP |
| Command works manually but hangs in cron | Interactive prompt or stdin wait |
| CI passes despite printed error | Command exits with status 0 |
| JSON consumer fails | Progress or warning text mixed into stdout |
| Relative file missing | Caller working directory differs |
| Dry run changes data | Side effects were not routed through the dry-run decision |
| Retry duplicates work | Command lacks idempotency or checkpoints |
| Different result under web PHP | CLI and web SAPIs use different configuration and inputs |
What Makes A Good CLI Script
A good CLI script:
- validates required arguments and options
- prints a useful usage message
- writes errors to
STDERR - exits non-zero on failure
- avoids hard-coded absolute paths where possible
- handles missing files and empty input deliberately
- is safe to run more than once when used for maintenance
For larger applications, framework console components usually provide better argument parsing, help text, commands, dependency injection, and test support. The raw PHP CLI basics still matter because those frameworks build on the same runtime concepts.
What You Should Be Able To Do
After this lesson, you should be able to run PHP from the terminal, read positional arguments, parse named options, read STDIN, write errors to STDERR, and return meaningful exit codes.
For junior PHP work, this matters because CLI tasks are part of real projects: imports, migrations, scheduled jobs, one-off fixes, test commands, and deployment checks all rely on command-line PHP.
Practice
Practice: Build A Positional CLI Command
Create a CLI command that validates a product SKU and quantity before printing an order summary.
Task
Create summarise-order.php with this command contract:
php summarise-order.php <sku> <quantity>
The script must:
- use strict types
- require exactly two positional arguments after the script name
- trim the SKU and accept only uppercase letters, digits, and hyphens
- accept the quantity only when it is a positive integer
- write a one-line usage message to
STDERRwhen the argument count is wrong - write a specific validation message to
STDERRwhen either value is invalid - print
SKU=<sku> quantity=<quantity>toSTDOUTon success - exit with status
0on success and status2for usage or validation errors
Test at least these cases:
php summarise-order.php BOOK-42 3
php summarise-order.php "BOOK-42" 0
php summarise-order.php "book 42" 3
php summarise-order.php BOOK-42
After the code, explain why the script validates the number of arguments before validating their values, and why its normal output remains a stable single line.
Show solution
The command checks its shape first, then validates each value separately. That produces a useful error instead of allowing missing arguments to flow into later code.
<?php
declare(strict_types=1);
if ($argc !== 3) {
fwrite(STDERR, "Usage: php summarise-order.php <sku> <quantity>\n");
exit(2);
}
$sku = trim($argv[1]);
$quantityInput = trim($argv[2]);
if ($sku === '' || preg_match('/^[A-Z0-9-]+$/D', $sku) !== 1) {
fwrite(STDERR, "SKU must contain only uppercase letters, digits, and hyphens.\n");
exit(2);
}
$quantity = filter_var(
$quantityInput,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]],
);
if ($quantity === false) {
fwrite(STDERR, "Quantity must be a positive integer.\n");
exit(2);
}
printf("SKU=%s quantity=%d\n", $sku, $quantity);
exit(0);
Example runs:
$ php summarise-order.php BOOK-42 3
SKU=BOOK-42 quantity=3
$ php summarise-order.php "BOOK-42" 0
Quantity must be a positive integer.
$ php summarise-order.php "book 42" 3
SKU must contain only uppercase letters, digits, and hyphens.
$ php summarise-order.php BOOK-42
Usage: php summarise-order.php <sku> <quantity>
Checking $argc first establishes that both expected positions exist. The later checks can therefore concentrate on whether each value is valid rather than also handling absence.
The success output is deliberately one stable line. Another command can capture it, compare it in a test, or parse its key=value fields without removing headings or explanatory text. Diagnostics go to STDERR, so they do not contaminate that normal output stream.
Task: Build A Named-Option Command
Create an import preview command whose options remain understandable without memorising argument positions.
Command Contract
Create preview-import.php with this interface:
php preview-import.php --file=<path> --format=<csv|json> [--dry-run]
php preview-import.php --help
The script must:
- use strict types and
getopt() - print a concise usage guide and exit with status
0when--helpis present - require non-empty
--fileand--formatvalues for a normal run - accept only
csvorjsonas the format, case-insensitively - reject an absent, non-file, or unreadable path
- detect the boolean
--dry-runflag witharray_key_exists() - print the resolved file path, normalised format, and mode as three stable
key=valuelines - exit with status
2for missing or invalid options - exit with status
1when the selected file cannot be used - exit with status
0after a successful preview
The command is only a preview. It must not alter the file or import any data.
Test the help path, both supported formats, a missing file, an invalid format, and a run with --dry-run. After the code, explain why --dry-run is a flag while --file and --format require values.
Show solution
This command treats help, invalid invocation, and an unusable file as different outcomes. It reports what it would do but deliberately performs no import.
<?php
declare(strict_types=1);
$options = getopt('', ['file:', 'format:', 'dry-run', 'help']);
$usage = <<<TEXT
Usage:
php preview-import.php --file=<path> --format=<csv|json> [--dry-run]
php preview-import.php --help
TEXT;
if ($options === false) {
fwrite(STDERR, "Could not parse command options.\n");
exit(2);
}
if (array_key_exists('help', $options)) {
echo $usage . PHP_EOL;
exit(0);
}
$fileOption = $options['file'] ?? null;
$formatOption = $options['format'] ?? null;
if (!is_string($fileOption) || trim($fileOption) === '') {
fwrite(STDERR, "The --file option requires a path.\n");
fwrite(STDERR, $usage . PHP_EOL);
exit(2);
}
if (!is_string($formatOption) || trim($formatOption) === '') {
fwrite(STDERR, "The --format option requires csv or json.\n");
fwrite(STDERR, $usage . PHP_EOL);
exit(2);
}
$format = strtolower(trim($formatOption));
if (!in_array($format, ['csv', 'json'], true)) {
fwrite(STDERR, "Unsupported format: {$format}\n");
exit(2);
}
$file = trim($fileOption);
$resolvedFile = realpath($file);
if ($resolvedFile === false || !is_file($resolvedFile) || !is_readable($resolvedFile)) {
fwrite(STDERR, "Import file is missing or unreadable: {$file}\n");
exit(1);
}
$dryRun = array_key_exists('dry-run', $options);
printf("file=%s\n", $resolvedFile);
printf("format=%s\n", $format);
printf("mode=%s\n", $dryRun ? 'dry-run' : 'write');
exit(0);
A quick test can use an empty temporary file because the command checks access but does not parse data:
$ touch orders.csv
$ php preview-import.php --file=orders.csv --format=CSV --dry-run
file=/absolute/path/to/orders.csv
format=csv
mode=dry-run
$ php preview-import.php --file=missing.csv --format=csv
Import file is missing or unreadable: missing.csv
$ php preview-import.php --help
Usage:
php preview-import.php --file=<path> --format=<csv|json> [--dry-run]
php preview-import.php --help
--dry-run represents an on-or-off choice, so its presence is enough to enable it. --file and --format are incomplete without associated values, which is why their getopt() definitions contain a colon and their returned values are validated as strings.
The command uses status 2 when the caller supplied an invalid command shape and status 1 when a valid-looking request could not be completed because of the file system. A shell script can react differently to those failures if necessary.
Task: Process STDIN With Reliable Exit Codes
Create a pipeline-friendly command that reads product SKUs from STDIN and writes a machine-readable summary.
Input Contract
Each non-empty input line contains one SKU. A valid SKU contains only uppercase letters, digits, and hyphens. Blank lines are ignored, and repeated valid SKUs are counted as received but appear only once in the final list.
Create summarise-skus.php so that it:
- uses strict types
- reads all input with
stream_get_contents(STDIN) - handles Unix and Windows line endings
- reports a failed read to
STDERRand exits with status1 - reports empty input to
STDERRand exits with status1 - validates every non-empty line and includes its physical line number in an error
- exits with status
2when a line contains an invalid SKU - produces no normal output when validation fails
- preserves the first-seen order while removing duplicate SKUs
- prints one compact JSON object followed by a newline on success
- exits with status
0on success
The JSON object must contain:
received: the number of non-empty valid lines, including duplicatesunique: the number of distinct SKUsskus: the distinct SKUs in first-seen order
Test the command with a pipe, redirected file input, duplicate values, blank lines, invalid data, and empty input. Also demonstrate how to inspect its exit status in your shell.
After the code, explain why diagnostics do not belong in the JSON output and why an invalid record causes the whole command to fail instead of being silently skipped.
Show solution
The script validates the complete input before writing its JSON result. This prevents callers from receiving a success-looking partial summary.
<?php
declare(strict_types=1);
$input = stream_get_contents(STDIN);
if ($input === false) {
fwrite(STDERR, "Could not read from STDIN.\n");
exit(1);
}
$lines = preg_split('/\R/', $input);
if ($lines === false) {
fwrite(STDERR, "Could not split the input into lines.\n");
exit(1);
}
$received = 0;
$uniqueSkus = [];
$seen = [];
foreach ($lines as $index => $line) {
$sku = trim($line);
if ($sku === '') {
continue;
}
$lineNumber = $index + 1;
if (preg_match('/^[A-Z0-9-]+$/D', $sku) !== 1) {
fwrite(STDERR, "Invalid SKU on line {$lineNumber}: {$sku}\n");
exit(2);
}
$received++;
if (!array_key_exists($sku, $seen)) {
$seen[$sku] = true;
$uniqueSkus[] = $sku;
}
}
if ($received === 0) {
fwrite(STDERR, "No SKU records were received.\n");
exit(1);
}
$result = [
'received' => $received,
'unique' => count($uniqueSkus),
'skus' => $uniqueSkus,
];
echo json_encode($result, JSON_THROW_ON_ERROR) . PHP_EOL;
exit(0);
The command works with both pipes and redirection:
$ printf "BOOK-42\nPEN-7\nBOOK-42\n\n" | php summarise-skus.php
{"received":3,"unique":2,"skus":["BOOK-42","PEN-7"]}
$ php summarise-skus.php < skus.txt
{"received":3,"unique":2,"skus":["BOOK-42","PEN-7"]}
$ printf "BOOK-42\nbad sku\n" | php summarise-skus.php
Invalid SKU on line 2: bad sku
$ printf "BOOK-42\nbad sku\n" | php summarise-skus.php
echo $?
2
The error text is shown by the terminal because it is written to STDERR; it is not part of the JSON stream. A caller can redirect STDOUT to a file or pass it to a JSON processor without first removing human-readable diagnostics.
Silently skipping malformed records would make the summary appear complete even though data had been discarded. Failing the command makes that data-quality problem visible and allows automation to stop or alert someone. Status 2 distinguishes invalid input from a read failure or an input stream containing no records.