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

max_execution_time

always 0 (no limit), whatever php.ini says

display_errors

errors go to STDOUT (set display_errors=stderr to separate them)

html_errors

Off: plain-text error messages

implicit_flush

On: output is written immediately, not buffered

register_argc_argv

On: $argv/$argc are available

headers, cookies, sessions

header() has no effect; there is no HTTP request

working directory

the directory the command was started in, not the script’s (use __DIR__)

php.ini

often a separate file (php-cli.ini, /etc/php/8.5/cli/php.ini): check with php --ini

memory_limit

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

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.

Source: $argv and getopt

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));

Running other programs

PHP can run external programs in several ways:

Function Behaviour

exec($cmd, &$output, &$code)

runs through the shell, collects output lines and the exit code

shell_exec($cmd) / backticks

runs through the shell, returns the whole output (backticks deprecated in 8.5)

system($cmd), passthru($cmd)

print the output directly

proc_open($cmd, $descriptors, &$pipes)

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.

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 and escapeshellcmd() (Kromann ch. 9, Nixon ch. 7) leaves room for argument injection; use proc_open() with an argument array (7.4) or escapeshellarg() 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 status 0, which hides failures from cron and CI.

  • register_argc_argv for 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).