Command-Line PHP
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PHP 8.5, checked on 2026-10-05; the Symfony Console example was run with Symfony Console
8.1. The php binary is the CLI SAPI: the same engine as the web, wired to a terminal instead of a web server. It
runs cron jobs, queue workers, build tools (Composer, PHPStan, PHPUnit) and frameworks' consoles (Laravel’s Artisan,
Symfony’s bin/console). Running scripts, -r, the interactive shell and shebangs are introduced in
Getting Started.
How the CLI SAPI differs
| Behaviour | CLI |
|---|---|
|
always |
|
errors go to STDOUT (set |
|
|
|
|
|
|
headers, cookies, sessions |
|
working directory |
the directory the command was started in, not the script’s (use |
|
often a separate file ( |
|
applies as usual (raise it explicitly for big batch jobs) |
<?php
declare(strict_types=1);
var_dump(PHP_SAPI); // string(3) "cli"
var_dump(ini_get('max_execution_time')); // string(1) "0"
var_dump(ini_get('html_errors'), ini_get('implicit_flush')); // "0" "1"
var_dump(getcwd() !== false, __DIR__ !== ''); // the cwd and the script directory can differ
Source: Differences to other SAPIs
Arguments: $argv, $argc and getopt
$argv is an array whose first element is the script name, followed by the arguments as given; $argc is its
length. getopt($short, $long, $restIndex) parses options: a single letter for short options, : after it for a
required value, :: for an optional value; long options are an array of names with the same suffixes. Options without
a value are reported as false. The third parameter receives the index of the first non-option argument.
<?php
declare(strict_types=1);
// export-loans.php
$options = getopt('vf:', ['format:', 'dry-run', 'limit::'], $restIndex);
$files = array_slice($argv, $restIndex);
$verbose = array_key_exists('v', $options); // flags are present with value false
$format = $options['f'] ?? $options['format'] ?? 'table';
$limit = isset($options['limit']) ? (int) $options['limit'] : null;
printf("verbose=%s format=%s dry-run=%s limit=%s files=%s\n",
var_export($verbose, true), $format, var_export(isset($options['dry-run']), true), $limit ?? 'none', implode(',', $files));
$ php export-loans.php -v -f csv --dry-run --limit=50 loans-october.csv
verbose=true format=csv dry-run=true limit=50 files=loans-october.csv
getopt() is fine for small scripts; anything with subcommands, help text and validation is better served by a console
library (below). Arguments are strings: validate and convert them like any other input.
Standard streams and exit codes
STDIN, STDOUT and STDERR are predefined stream constants in the CLI (see
Files, Streams and I/O). Write results to STDOUT and
diagnostics to STDERR, so that output can be piped. stream_isatty(STDOUT) tells whether output goes to a terminal
(for colours) or to a pipe or file.
The exit code tells the calling shell, cron or supervisor whether the script succeeded: 0 means success, 1-254
failure. exit($code) with an integer sets it; an uncaught exception or fatal error ends with 255; reaching the end
of the script gives 0. exit('message') prints the message and exits with 0, which is rarely what an error path
wants. Since PHP 8.4 exit() is a real function, so it can be passed named arguments and used as a callable.
$ php -r 'exit(3);'; echo "code=$?"
code=3
$ php -r 'exit("failed\n");'; echo "code=$?"
failed
code=0
$ php -r 'throw new Exception("x");' 2>/dev/null; echo "code=$?"
code=255
<?php
declare(strict_types=1);
function main(array $argv): int
{
if (count($argv) < 2) {
fwrite(STDERR, "usage: {$argv[0]} <isbn>\n");
return 64; // EX_USAGE, following the BSD sysexits convention
}
fwrite(STDOUT, "Looking up {$argv[1]}\n");
return 0;
}
$code = main(['lookup.php']);
echo "would exit with {$code}\n"; // in a real script: exit(main($argv));
Source: Input/output streams and exit
Running other programs
PHP can run external programs in several ways:
| Function | Behaviour |
|---|---|
|
runs through the shell, collects output lines and the exit code |
|
runs through the shell, returns the whole output (backticks deprecated in 8.5) |
|
print the output directly |
|
full control over stdin/stdout/stderr pipes, environment and working directory; an array command (7.4) bypasses the shell |
Anything that goes through a shell is vulnerable to command injection if part of the command comes from user input.
Prefer proc_open() with an argument array; if a shell is unavoidable, escape every argument with
escapeshellarg() (escapeshellcmd() escapes a whole command but still allows argument injection).
<?php
declare(strict_types=1);
// Safe: no shell is involved; each array element is one argument
$process = proc_open(['sort', '-r'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
fwrite($pipes[0], "Kindred\nSolaris\nDawn\n");
fclose($pipes[0]);
echo stream_get_contents($pipes[1]); // Solaris, Kindred, Dawn (reverse order)
fclose($pipes[1]);
fclose($pipes[2]);
echo 'exit code: ', proc_close($process), "\n"; // exit code: 0
// When a shell is unavoidable: escape each argument
$userFile = "notes; rm -rf /";
var_dump(escapeshellarg($userFile)); // string(17) "'notes; rm -rf /'"
exec('ls -l ' . escapeshellarg('/etc/hostname') . ' 2>&1', $output, $code);
echo $code === 0 ? "listed\n" : "failed\n";
Symfony Process (and Laravel’s Process facade, see Processes,
Concurrency and Context) wrap proc_open() with timeouts, streaming output and testing fakes.
Source: Program execution, proc_open and escapeshellarg
Signals and long-running workers
Workers started by Supervisor or systemd receive SIGTERM when they must stop. With pcntl_async_signals(true) and
pcntl_signal() the worker can finish its current job and exit cleanly; pcntl is CLI-only and Unix-only. The
complete example, with forking, is in Fibers and
Asynchronous PHP. Long-running processes should also watch their memory (memory_get_usage()), restart after a
number of jobs, and avoid state accumulating in static properties.
Source: Process Control
phpdbg
phpdbg is an interactive debugger shipped with PHP (it must be enabled at build time; the official Docker images
include it). It runs scripts with breakpoints (break file.php:12, break functionName), stepping, watches and
evaluation, without any IDE or extension, and can also produce code coverage. Most developers use Xdebug with an IDE
instead; see Debugging and Profiling.
$ phpdbg -qrr script.php # run a script under phpdbg, quiet, then quit
$ phpdbg script.php
prompt> break script.php:12
prompt> run
prompt> ev $loan->dueAt() # evaluate an expression at the breakpoint
prompt> next
Source: phpdbg
Building CLI applications with Symfony Console
For real command-line tools — subcommands, --help, typed arguments and options, validation, coloured output,
tables, progress bars, questions, shell completion — use Symfony Console. Laravel’s Artisan is built on it, and
Laravel Prompts adds interactive forms on top (see Artisan,
Tinker and Prompts). Recent versions let a command be a plain invokable class whose parameters are declared with
attributes:
composer require symfony/console
#!/usr/bin/env php
<?php
declare(strict_types=1);
// bin/console.php
require __DIR__ . '/../vendor/autoload.php';
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'catalogue:overdue', description: 'List overdue loans')]
final class OverdueCommand
{
public function __invoke(
SymfonyStyle $io,
#[Argument('Days late threshold')] int $days = 0,
#[Option('Output format: table or csv')] string $format = 'table',
): int {
$rows = [['978-0-00-000001-1', 'Kindred', 13], ['978-0-00-000002-8', 'Dawn', 2]];
$rows = array_values(array_filter($rows, fn (array $r): bool => $r[2] > $days));
if ($format === 'csv') {
foreach ($rows as $row) {
$io->writeln(implode(',', $row));
}
} else {
$io->table(['ISBN', 'Title', 'Days late'], $rows);
}
$io->success(count($rows) . ' overdue loan(s)');
return Command::SUCCESS;
}
}
$app = new Application('Library catalogue', '1.0');
$app->addCommand(new OverdueCommand());
$app->run();
$ php bin/console.php catalogue:overdue 5
------------------- --------- -----------
ISBN Title Days late
------------------- --------- -----------
978-0-00-000001-1 Kindred 13
------------------- --------- -----------
[OK] 1 overdue loan(s)
php bin/console.php list and --help are generated automatically. For long jobs, SymfonyStyle::progressStart()
and progressAdvance() show a progress bar.
Where older books and tutorials are outdated
-
exec()with string concatenation andescapeshellcmd()(Kromann ch. 9, Nixon ch. 7) leaves room for argument injection; useproc_open()with an argument array (7.4) orescapeshellarg()for every argument. -
The backtick operator is deprecated since 8.5.
-
exit()/die()as language constructs: since 8.4 they are functions;die("message")error handling exits with status0, which hides failures from cron and CI. -
register_argc_argvfor web SAPIs (deriving$_SERVER['argv']from the query string) is deprecated since 8.5. -
Hand-written CLI frameworks: Symfony Console’s attribute-based invokable commands are recent (Symfony 7.3 and later).