Building Command-Line Applications with PHP

Write PHP scripts that run from the command line: read arguments via $argc/$argv, read interactive input from STDIN, write to STDOUT and STDERR appropriately, return meaningful exit codes, read environment variables, and validate CLI input.

PHP isn't just a web language -- the same php binary that FPM runs behind Nginx also runs standalone scripts from a terminal, and that CLI mode powers an enormous amount of real infrastructure: Composer itself is a PHP CLI application, so are most framework console tools (Artisan, Symfony Console), database migration runners, queue workers, and cron jobs. This lesson covers everything needed to build a real CLI tool: reading arguments and input, writing output to the right stream, returning exit codes a shell script can check, reading environment variables, and validating what a user typed before acting on it.

Running a Script and Reading Arguments: $argc, $argv

The simplest possible invocation is php file.php, which runs file.php from top to bottom exactly like any PHP script -- the difference from web execution is simply that there's no web server, no HTTP request, and no superglobals like $_GET or $_SERVER['REQUEST_METHOD'] populated (those simply don't exist in a CLI context). Anything a CLI script needs comes from arguments passed on the command line, from STDIN, or from environment variables -- not from HTTP.

Command-line arguments are exposed through two special variables that only exist when running under the CLI SAPI: $argv is an indexed array of every token on the command line, where $argv[0] is always the script's own filename and $argv[1], $argv[2], etc. are the actual arguments passed after it; $argc is simply count($argv) -- the total number of entries including the script name itself. Running php greet.php Alice 30 gives you $argv = ['greet.php', 'Alice', '30'] and $argc = 3.

For the classic example: echo "Arguments: " . implode(', ', $argv); run as php script.php foo bar prints Arguments: script.php, foo, bar -- note that implode() over the whole $argv array includes the script name itself as the first joined element, which is almost always not what you want when displaying just the user-supplied arguments; slicing it off with array_slice($argv, 1) is the fix, covered in the examples below.

• php file.php arg1 arg2 -- runs a script from the terminal, no web server involved. • $argv -- indexed array of all command-line tokens; $argv[0] is always the script's own filename. • $argc -- count of entries in $argv, including the script name. • No $_GET/$_POST/$_SERVER['REQUEST_METHOD'] in CLI mode -- there is no HTTP request to populate them from.

STDIN, STDOUT, STDERR

PHP CLI scripts get three predefined stream constants matching the three standard Unix streams every process has: STDIN for reading input, STDOUT for normal output, and STDERR for error/diagnostic output. echo and print by default write to STDOUT, so for plain scripts you rarely need to reference STDOUT explicitly -- but for anything meant to be piped or redirected, being deliberate about which stream a message goes to matters a lot.

The convention that matters most in real tooling: STDOUT is for the program's actual output/result -- the thing a user or another program piping your output would want to capture -- while STDERR is for diagnostics, warnings, progress messages, and errors, precisely because a shell pipeline like php report.php > output.csv redirects STDOUT to a file but leaves STDERR going to the terminal, so error messages stay visible to a human even while the real output is being captured cleanly. Writing an error message with plain echo instead of fwrite(STDERR, ...) is a common CLI bug: it silently corrupts redirected output by mixing error text into the file the user expected to contain only clean data.

Reading input interactively uses fgets(STDIN) (or the shorthand readline() when the readline extension is available, which additionally supports arrow-key history/editing). fgets(STDIN) blocks and waits for the user to type a line and press Enter, returning that line including its trailing newline (which you typically strip with trim() or rtrim()). This is how interactive prompts -- "Are you sure? (y/n)" -- are built in plain PHP CLI without any framework.

• STDOUT -- normal program output; what echo/print write to by default; safe to pipe/redirect. • STDERR -- errors, warnings, diagnostics; stays visible even when STDOUT is redirected to a file. • STDIN -- input stream; read with fgets(STDIN) for a line, or readline() for line-editing support. • fwrite(STDERR, $msg) -- the correct way to emit an error/diagnostic message from a CLI tool.

Exit Codes and Environment Variables

Every process, PHP CLI scripts included, returns an exit code to the shell when it finishes -- a small integer where 0 conventionally means success and any non-zero value signals some kind of failure (the specific non-zero value is up to the program; many tools use different codes for different failure categories). PHP exposes this through the exit() (or its alias die()) function, which accepts an integer exit code: exit(0) for success, exit(1) for a generic failure, and so on. If a script finishes normally without calling exit() explicitly, PHP returns exit code 0 automatically; if a script raises an uncaught fatal error, PHP returns a non-zero code (255) automatically.

Exit codes matter enormously in real automation: a shell script or CI pipeline chains commands with && ("only run the next command if the previous one exited 0") or checks $? (the shell variable holding the last command's exit code) explicitly, so a PHP CLI tool that always exits 0 regardless of whether it actually succeeded silently breaks every automation built on top of it -- a classic, very real production bug in home-grown deploy/migration scripts.

Environment variables -- values set in the shell or by the process that launched your script (a CI runner, a cron daemon, a Docker container's env block) -- are read with getenv('VAR_NAME') (returns the string value, or false if unset) or, in some setups, via $_ENV / $_SERVER depending on the variables_order php.ini directive. This is the standard way CLI tools receive configuration that shouldn't be hardcoded or passed as a plaintext argument (visible in process listings) -- API keys, database URLs, and feature flags are commonly passed this way rather than as CLI arguments.

• exit(0) -- success; exit(1) (or other non-zero) -- failure; the convention every shell/CI pipeline relies on. • An uncaught fatal error auto-exits with code 255; a script finishing normally auto-exits with code 0. • getenv('NAME') -- reads an environment variable; returns false if it isn't set. • Prefer environment variables over plaintext CLI arguments for secrets -- CLI args are visible to other users via ps.

CLI Configuration, Input Validation, and Testing

PHP can load a separate php.ini file specifically for CLI execution (commonly named php-cli.ini or found in a cli/ subdirectory of the PHP config, depending on OS/package), distinct from the php.ini the web SAPI (FPM/Apache) uses. This matters because sensible defaults differ by context: CLI scripts typically want display_errors = On and a high or unlimited memory_limit (long-running batch jobs may need far more memory than a typical web request), while a production web-facing php.ini wants display_errors = Off (never leak stack traces to visitors) and a bounded memory_limit per request. Running php --ini prints exactly which config files PHP CLI loaded, which is the first thing to check when CLI behavior doesn't match what you configured.

Because CLI input -- arguments, STDIN, environment variables -- all arrive as untyped, unvalidated strings exactly like HTTP input does, the same input-validation discipline from web development applies: never trust that $argv[1] is a valid integer just because the user was supposed to type one; check it (is_numeric(), ctype_digit(), or a regex) and fail with a clear STDERR message and a non-zero exit code rather than letting a malformed value silently propagate into, say, a database query or a file path. A brief note on signals: pressing Ctrl+C in an interactive script sends SIGINT, and by default PHP just terminates -- catching and handling that gracefully (via the pcntl extension) is the subject of the next lesson, Process Control and Signals.

CLI applications are tested the same way any PHP code is tested -- PHPUnit works fine against the pure logic functions inside a CLI tool -- but testing the actual command invocation (argument parsing, exit codes, STDOUT/STDERR content) typically uses either PHPUnit's process-isolation helpers or a dedicated library like Symfony's CommandTester, which lets a test invoke a console command in-process, feed it fake input, and assert on its captured output and returned exit code without actually spawning a subprocess for every test.

• php.ini vs php-cli.ini (or a cli/ config dir): CLI wants errors visible + high memory limits; web wants errors hidden + bounded memory. • php --ini shows exactly which config files were loaded for the current CLI invocation. • Validate all CLI input (args, STDIN, env vars) exactly as rigorously as HTTP input -- it's just as untrusted. • Test CLI logic with PHPUnit directly; test full command behavior (exit codes, output) with process-isolation helpers or tools like Symfony's CommandTester.

Anatomy of a CLI Script

The #!/usr/bin/env php shebang line lets the file be run directly as ./script.php (after chmod +x) on Unix-like systems, without typing php first. array_slice($argv, 1) is the standard way to get just the user-supplied arguments, dropping the script's own filename at index 0. Always give a CLI script an explicit, meaningful exit() call rather than relying on the implicit 0 -- it documents intent and matters the moment error-handling is added.

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

// $argv[0] is always the script name itself.
fwrite(STDOUT, "Script: {$argv[0]}" . PHP_EOL);
fwrite(STDOUT, "Argument count (excluding script name): " . ($argc - 1) . PHP_EOL);

// The real, user-supplied arguments (skip index 0):
$userArgs = array_slice($argv, 1);
fwrite(STDOUT, "Arguments: " . implode(', ', $userArgs) . PHP_EOL);

if (empty($userArgs)) {
    fwrite(STDERR, "Error: at least one argument is required." . PHP_EOL);
    exit(1); // non-zero exit code signals failure to the calling shell
}

exit(0); // explicit success

Code Examples

This is exactly the example this lesson builds on. Run as php echo_args.php first second, $argv is ['echo_args.php', 'first', 'second'], so implode(', ', $argv) joins all three elements -- including the script's own name -- into one comma-separated string. Expected result: Arguments: echo_args.php, first, second echo "Arguments: ". implode(', ', $argv);

Common mistake: Because $argv[0] is always the script name, implode()-ing the whole array (as shown) mixes the script name in with the real arguments -- almost never what you actually want to display to a user.

fgets(STDIN) blocks until the user types a line and presses Enter, returning it with a trailing newline that trim() strips. Because STDIN input is just as untrusted as any web form field, ctype_digit() validates it's a plain non-negative integer string before casting -- a malformed value gets a clear STDERR message and a non-zero exit code instead of silently becoming (int) 0. Expected result: Enter your age: 07 You are 7 years old. fgets(STDIN) returning false (rather than a string) specifically signals STDIN was closed/exhausted -- e.g. the script was run with input piped from an empty file -- and should be handled as its own error case, not just treated as an empty string.

if (!ctype_digit($input)) {... exit(1); } getenv('API_KEY') reads the environment variable set by whatever launched the process (the shell, a CI runner, a Docker container). Passing secrets this way instead of as a plain CLI argument avoids them showing up in a process listing (ps aux reveals CLI arguments to any user on the same machine, but not environment variables of a process you don't own). Using exit(2) instead of exit(1) here distinguishes a 'missing configuration' failure from a generic one, which calling automation can branch on if it needs to. Expected result: Using API key ending in...3xyz $apiKey = getenv('API_KEY');

<?php
// echo_args.php
echo "Arguments: " . implode(', ', $argv);
<?php
declare(strict_types=1);

fwrite(STDOUT, "Enter your age: ");
$line = fgets(STDIN);

if ($line === false) {
    fwrite(STDERR, "Error: no input received (STDIN closed)." . PHP_EOL);
    exit(1);
}

$input = trim($line);

if (!ctype_digit($input)) {
    fwrite(STDERR, "Error: '{$input}' is not a valid non-negative integer." . PHP_EOL);
    exit(1);
}

$age = (int) $input;
fwrite(STDOUT, "You are {$age} years old." . PHP_EOL);
exit(0);
<?php
declare(strict_types=1);

$apiKey = getenv('API_KEY');

if ($apiKey === false || $apiKey === '') {
    fwrite(STDERR, "Error: the API_KEY environment variable is not set." . PHP_EOL);
    exit(2); // a distinct code for "missing configuration"
}

fwrite(STDOUT, "Using API key ending in ..." . substr($apiKey, -4) . PHP_EOL);
exit(0);

// Run like: API_KEY=sk-abc123xyz php script.php
// Or, in a CI pipeline, API_KEY is typically set in the environment ahead of time.

CLI Script I/O Streams

Arguments and env vars come in; STDOUT/STDERR are separate outbound channels; the exit code is the final signal a shell/CI pipeline acts on.

Terminal / Shell / CI runner
        |          ^        ^
   argv[]/STDIN  STDOUT   STDERR
        |          |        |
        v          |        |
   +------------------------------+
   |     php script.php ...       |
   |  $argv / $argc / getenv()    |
   |  fgets(STDIN)                |
   |  fwrite(STDOUT, ...)  ------>|  normal output (pipeable)
   |  fwrite(STDERR, ...)  ------>|  errors/diagnostics (stays on terminal)
   |  exit(0) success / exit(N)   |
   +------------------------------+
        |
        v
   Shell reads $? (exit code) to decide next step

Important Rules

$argv[0] is always the script's own filename -- slice it off with array_slice($argv, 1) before treating the array as "user arguments".

Write actual program output to STDOUT and errors/diagnostics to STDERR, never mix the two.

Always return a meaningful exit code: 0 for success, non-zero for failure -- never let a real failure exit 0.

Treat every source of CLI input (arguments, STDIN, environment variables) as untrusted and validate it explicitly.

Prefer environment variables over plaintext CLI arguments for secrets, since CLI arguments are visible via process listings.

Why This Matters in Production

Composer -- the tool nearly every PHP developer runs dozens of times a day -- is itself a PHP CLI application, and its careful use of exit codes (0 for success, distinct non-zero codes for different failure modes) is exactly why CI pipelines can reliably gate a deploy on composer install succeeding. Database migration runners, deployment scripts, and queue-worker bootstrap scripts in nearly every serious PHP codebase follow the same discipline this lesson teaches: validated input, STDOUT reserved for real output, STDERR for diagnostics, and exit codes a calling script or CI system can actually branch on -- get any of those wrong and automation built on top of the tool silently does the wrong thing.

echo "Error: invalid input\n"; exit(0); // still exits 0 on failure fwrite(STDERR, "Error: invalid input\n"); exit(1); // error goes to STDERR, exit code signals failure

Writing an error to STDOUT and then exiting 0 breaks every automated caller: a shell script chaining with &&, or a CI pipeline checking $?, will believe the command succeeded and proceed as if nothing went wrong, and any redirected STDOUT capture will silently contain the error text mixed into the expected data.

$name = $argv[1]; // assumes an argument was passed, no check if ($argc < 2) { fwrite(STDERR, "Usage: php script.php <name>\n"); exit(1); } $name = $argv[1];

Accessing $argv[1] without first checking $argc raises an 'Undefined array key' warning (and produces null) when the script is run with no arguments -- validating $argc first and failing with a clear usage message is far friendlier and far more robust than letting the script continue with missing data.

[multiple-choice] Given php greet.php Alice, what does $argv contain? A. ['Alice'] B. ['greet.php', 'Alice'] C. ['php', 'greet.php', 'Alice'] D. An associative array with a 'script' and 'args' key

B. ['greet.php', 'Alice'] — $argv[0] is always the script's own filename, so $argv is ['greet.php', 'Alice'] -- $argc would be 2.

[multiple-choice] Why should error messages from a CLI tool be written to STDERR rather than STDOUT? A. STDERR is faster to write to than STDOUT B. STDOUT is often redirected/piped to capture a program's real output, so errors on STDOUT would corrupt that captured data; STDERR stays visible even when STDOUT is redirected C. PHP does not allow echo to be used for errors D. There is no functional difference between the two streams

B. STDOUT is often redirected/piped to capture a program's real output, so errors on STDOUT would corrupt that captured data; STDERR stays visible even when STDOUT is redirected — A common pattern like php report.php > output.csv redirects only STDOUT to the file; STDERR still reaches the terminal, keeping diagnostics visible to a human while leaving the captured file free of error noise.

[true-false] If a PHP CLI script finishes executing normally without calling exit() explicitly, it automatically returns exit code 0 to the shell. A. True B. False

A. True — True -- a script that completes normally (no uncaught fatal error) returns exit code 0 by default; an explicit exit() call is only needed to return a different code or to stop early.

[multiple-choice] What is the recommended way to pass a secret like an API key into a PHP CLI script, and why? A. As a plain command-line argument, because it's the simplest to read with $argv B. As an environment variable read with getenv(), because CLI arguments are visible to other users via process listings like ps, while environment variables of a process you don't own are not C. Hardcoded directly in the script's source file D. Through $_GET, since PHP always populates it regardless of SAPI

B. As an environment variable read with getenv(), because CLI arguments are visible to other users via process listings like ps, while environment variables of a process you don't own are not — Command-line arguments of a running process can be viewed by other users on the same system via tools like ps aux, which is why secrets are conventionally passed as environment variables (read with getenv()) instead.

Write a CLI script sum.php that accepts any number of numeric command-line arguments, validates each one is numeric (rejecting the whole run with a clear STDERR message and exit(1) if any argument isn't), and if valid, prints their sum to STDOUT and exits 0.

<?php\n$args = array_slice($argv, 1);\nif (empty($args)) { fwrite(STDERR, "Usage: php sum.php <n1> <n2> ...\\n"); exit(1); }\n$sum = 0;\nforeach ($args as $a) {\n if (!is_numeric($a)) { fwrite(STDERR, "Error: '$a' is not numeric.\\n"); exit(1); }\n $sum += (float) $a;\n}\necho "Sum: $sum" . PHP_EOL;\nexit(0);

Loop over array_slice($argv, 1), use is_numeric() for validation, and accumulate the sum with (float) casts.