Structuring Real Console Applications: Subcommands, Help, Input, and Progress
Design a PHP CLI tool as a proper console application: dispatch multiple subcommands from a single entry script, generate --help output, prompt for interactive input with confirmation, render a progress indicator for long-running work, and log appropriately across STDOUT, STDERR, and a log file.
A single-purpose script that reads a couple of arguments and prints a result is fine for a one-off task, but real CLI tools -- git, composer, artisan, symfony console -- are console applications: one entry point that dispatches to many named subcommands, each with its own arguments and its own --help text, plus shared conveniences like interactive prompts, progress feedback, and consistent logging. This lesson builds exactly that shape from plain PHP (no framework required, though the same ideas are what Symfony Console and Laravel's Artisan formalize), so you understand what those frameworks are actually doing under the hood.
A Command Dispatcher: One Entry Point, Many Subcommands
The core pattern behind every multi-command CLI tool is a dispatcher: the entry script looks at the first real argument ($argv[1]) to decide *which* command the user wants, then hands the remaining arguments ($argv[2] onward) to that command's own handler. This is exactly how git commit -m "msg", composer require foo/bar, and php artisan make:model Post all work -- commit, require, and make:model are the subcommand name, everything after it belongs to that subcommand.
The cleanest way to build this in plain PHP is a small associative array mapping command names to callables (or class instances implementing a common interface), plus a fallback for unknown commands and a dedicated help command that lists what's available. Keeping each subcommand as its own function or class -- rather than one giant switch statement with all the logic inline -- is what keeps the dispatcher itself simple and lets each subcommand be tested independently.
A well-behaved dispatcher also standardizes the exit code contract across every subcommand: each handler returns an int (or throws, caught once at the top level), and the entry script is the only place that actually calls exit(), passing along whatever the matched handler returned. That way no individual subcommand needs to remember to call exit() correctly -- there's exactly one place that can get it wrong.
• $argv[1] -- the subcommand name; $argv[2..] -- that subcommand's own arguments. • Map command name -> handler (callable or class) in one place; add a fallback for unrecognized commands. • Each handler returns an int exit code; only the entry script actually calls exit() with it. • This is the same shape Symfony Console and Artisan formalize with classes and attributes/annotations.
Help Text and Argument Conventions
Every real CLI tool responds to --help (and often bare invocation, or -h) with usage text: what the tool does, the list of subcommands, and for a specific subcommand, what arguments and options it accepts. Generating this from data -- an array describing each command's name, summary, and options -- rather than hand-writing a separate echo block per command keeps the help text from drifting out of sync with the actual dispatcher as commands are added.
Two argument conventions are worth following because users expect them from every other CLI tool they use: a bare word is a positional argument (deploy production), while a value prefixed with -- is a named option, either a flag (--force, present or absent, no value) or a key=value pair (--env=staging or --env staging). PHP's built-in getopt() function parses both short (-e) and long (--env) options according to this convention and is usually sufficient without pulling in a library for a small tool.
Whatever parsing approach is used, validate early and fail loudly: an unrecognized subcommand or a missing required argument should print a short, specific error to STDERR, print (or point to) the relevant help text, and exit with a non-zero code -- never fall through into running partial logic with a missing or malformed argument.
• --help / -h / bare invocation -- all three commonly show usage; generate the text from one data source. • Positional argument: a bare value. Option: --name, either a flag or --name=value. • getopt() -- PHP's built-in short/long option parser; enough for most hand-rolled tools. • Unknown command or missing argument -> STDERR message + relevant help + non-zero exit, never a silent partial run.
Interactive Input, Confirmation, and Progress
Some subcommands need to ask the user something mid-run rather than requiring every value up front as an argument -- a migration tool confirming "This will drop 3 tables, continue? (y/N)", a setup wizard asking for a database host one field at a time. This is built with fgets(STDIN): print a prompt to STDOUT (or STDERR, so it isn't captured if output is redirected), read one line, trim() the trailing newline, and validate what came back before proceeding.
A confirmation prompt is the same pattern with a strict yes/no interpretation: default to the *safe* answer (typically "no") when the user just presses Enter, and only proceed on an explicit, recognized affirmative (y or yes, case-insensitively) -- treating an empty or unrecognized response as "yes" is a real-world cause of destructive commands running by accident. For fully non-interactive contexts (a CI pipeline calling the same command), a --yes/--force flag should skip the prompt entirely rather than hanging forever waiting for input that will never arrive on a closed STDIN.
For work that takes a visible amount of time -- processing a few thousand records, uploading files -- a simple progress indicator reassures the user the process hasn't hung. The lowest-effort version prints a running count or a . per item; a proper progress bar overwrites the same terminal line using a carriage return (\r instead of \n) so it updates in place rather than scrolling the terminal with hundreds of lines. Progress output belongs on STDERR (or is skipped entirely when output isn't an interactive terminal), because it's a UI element for a human watching, not data a pipeline downstream should have to filter out.
• fgets(STDIN) -- read one line of interactive input; always trim the trailing newline. • Confirmation prompts default to the safe answer; only an explicit 'y'/'yes' proceeds. • A --yes/--force flag bypasses prompts for non-interactive (CI/cron) invocations. • Progress bars overwrite the line with \r; send progress/UI output to STDERR, not STDOUT.
Logging From a Console Application
A console application's "logging" is really three related but distinct outputs: STDOUT for the actual result a user or pipeline wants, STDERR for warnings and errors surfaced to whoever is watching the terminal right now, and, often, a persistent log file that records what happened regardless of whether anyone was watching -- useful for a tool run unattended (which leads directly into the next lesson on cron) or for after-the-fact debugging.
A small logging helper -- even just a function that takes a level and message, formats a timestamp, and writes to the right destination(s) based on that level -- avoids scattering raw fwrite() calls with inconsistent formatting throughout a codebase. INFO-level messages might go only to STDOUT (or nowhere, if a --quiet flag is set); WARNING and ERROR should go to STDERR *and* to the log file, since both a live operator and a future reader of the log need to see them.
A --verbose/-v flag (and its opposite, --quiet/-q) is a standard convention letting the same command produce chatty diagnostic output during development and terse output in production, without maintaining two separate code paths -- the logging helper simply checks a verbosity level before deciding whether a given DEBUG-level message actually gets written anywhere.
• STDOUT: the result. STDERR: warnings/errors for a live viewer. Log file: a durable record for later. • A tiny log(level, message) helper beats scattered, inconsistently formatted fwrite() calls. • WARNING/ERROR should go to both STDERR and the log file; INFO/DEBUG can be gated by --verbose/--quiet. • This same logging discipline is what makes an unattended cron job's failures discoverable later (next lesson).
getopt() for Options, fgets(STDIN) for Prompts
getopt()'s three-argument form (with $restIndex by reference) also tells you where option parsing stopped, so you can read remaining positional arguments from $argv. Always give confirm() an explicit safe default and never treat a blank/garbled reply as 'yes'.
// Parsing options with PHP's built-in getopt():
// -e / --env : requires a value (":" suffix)
// -f / --force : a flag, no value ("::" would mean *optional* value)
$shortOpts = "e:f";
$longOpts = ["env:", "force"];
$options = getopt($shortOpts, $longOpts, $restIndex);
// php tool.php deploy --env=staging --force
// $options === ['env' => 'staging', 'force' => false]
// A confirmation prompt that defaults to "no":
function confirm(string $question, bool $default = false): bool {
$suffix = $default ? '[Y/n]' : '[y/N]';
fwrite(STDOUT, "{$question} {$suffix} ");
$answer = trim((string) fgets(STDIN));
if ($answer === '') {
return $default;
}
return in_array(strtolower($answer), ['y', 'yes'], true);
}Code Examples
A single entry script dispatches on $argv[1], each subcommand is its own function returning an int, help text is generated from the same $commands array the dispatcher uses (so it can't drift out of sync), the purge command demonstrates a safe-by-default confirmation prompt with a --force bypass, and progress() redraws one line on STDERR while the real completion message is logged through app_log(). Expected result: $ php tool.php purge This will delete all cached files. Continue? [y/N] y [##############################] 100% (20/20) $ php tool.php purge --force [##############################] 100% (20/20) $ php tool.php frobnicate Unknown command: frobnicate Usage: php tool.php <command> [options] Commands: help Show this help text purge Delete cached files (prompts unless --force) Note that the progress bar and the confirmation prompt both go through STDOUT/STDERR directly (they're interactive UI), while the actual outcome of the run -- success or cancellation -- is also recorded via app_log() so it's visible later even if nobody watched the terminal.
$exitCode = $name === 'help' ? cmd_help($commands) : $commands[$name]['handler']($rest);
#!/usr/bin/env php
<?php
declare(strict_types=1);
/** Minimal logger: INFO -> STDOUT, WARNING/ERROR -> STDERR + log file. */
function app_log(string $level, string $message, string $logFile): void {
$line = sprintf('[%s] %s: %s', date('Y-m-d H:i:s'), $level, $message);
if ($level === 'INFO') {
fwrite(STDOUT, $line . "\n");
return;
}
fwrite(STDERR, $line . "\n");
file_put_contents($logFile, $line . "\n", FILE_APPEND | LOCK_EX);
}
function confirm(string $question, bool $default = false): bool {
$suffix = $default ? '[Y/n]' : '[y/N]';
fwrite(STDOUT, "{$question} {$suffix} ");
$answer = trim((string) fgets(STDIN));
if ($answer === '') {
return $default;
}
return in_array(strtolower($answer), ['y', 'yes'], true);
}
/** Overwrites the current terminal line to show progress in place. */
function progress(int $done, int $total): void {
$pct = (int) round(($done / $total) * 100);
$barWidth = 30;
$filled = (int) round(($pct / 100) * $barWidth);
$bar = str_repeat('#', $filled) . str_repeat('-', $barWidth - $filled);
fwrite(STDERR, "\r[{$bar}] {$pct}% ({$done}/{$total})");
if ($done === $total) {
…Dispatch Flow
One entry point reads the subcommand name, dispatches, and is the single place that turns a handler's return value into a real process exit code.
$argv = ["tool.php", "purge", "--force"]
|
v
+----------------------------+
| Entry script: read argv[1]|
| ("purge") -> look up in |
| $commands map |
+----------------------------+
|
found? --+-- no --> STDERR "Unknown command"
| + print help, exit(1)
yes
|
v
+----------------------------+
| cmd_purge($rest) |
| - confirm() unless --force|
| - progress() loop |
| - app_log() outcome |
| - return int exit code |
+----------------------------+
|
v
exit($exitCode) <-- the ONLY place that calls exit()Important Rules
Exactly one place in the whole program calls exit() -- the entry script, using the value each handler returns.
Generate --help text from the same data structure the dispatcher uses, never a separately hand-written block.
Confirmation prompts default to the safe answer and are always bypassable with a flag for non-interactive callers.
Progress/UI output goes to STDERR; only the actual result belongs on STDOUT.
Warnings and errors are written to STDERR and to a persistent log file, not one or the other.
This Is What Framework Console Components Formalize
Symfony Console (used standalone and inside Laravel's Artisan) is precisely this pattern made reusable: a Command class per subcommand with a configure() method that declares its arguments/options (which also generates --help automatically), an execute() method that returns an exit code, an InputInterface/OutputInterface pair that wraps STDIN/STDOUT with helpers for prompts and formatted output, and a ProgressBar helper class that does exactly the carriage-return redraw shown above but with more polish. Building the hand-rolled version first is what makes reading that framework's source, or debugging why a custom Artisan command behaves oddly, straightforward instead of mysterious.
if ($argv[1] === 'y') { doDangerousThing(); } // treats ANY unrecognized input as confirmation if (in_array(strtolower(trim($answer)), ['y', 'yes'], true)) { doDangerousThing(); } else { /* safe default: do nothing */ }
Comparing only for the exact string that means 'yes', and defaulting to the safe branch for anything else (including an empty Enter press), prevents a stray keystroke or a script piping unexpected input into STDIN from triggering a destructive action.
In a well-structured subcommand dispatcher, where should exit() actually be called? A. Inside every subcommand handler, right after it finishes its work B. Only in the entry script, using the int exit code each handler returns C. It doesn't matter, as long as the process eventually exits D. Only inside the help command
B. Only in the entry script, using the int exit code each handler returns — Keeping exit() to one place -- the entry script, acting on each handler's returned int -- means the exit-code contract can only be gotten wrong in one spot, and individual handlers stay easily testable as plain functions that return a value instead of terminating the process.
A confirmation prompt is implemented as: if (trim(fgets(STDIN)) !== 'n') { deleteEverything(); }. What's wrong with this? A. fgets(STDIN) cannot be used inside a function B. It treats every input except the literal string 'n' -- including a blank Enter press or a typo -- as confirmation to proceed with a destructive action C. trim() cannot be applied to the result of fgets() D. Nothing is wrong; this is a standard confirmation pattern
B. It treats every input except the literal string 'n' -- including a blank Enter press or a typo -- as confirmation to proceed with a destructive action — The safe pattern inverts this: default to NOT proceeding, and only proceed on an explicit recognized affirmative answer. This version proceeds on literally everything except one specific string, which will eventually delete something by accident.
Extend the dispatcher example with a new status subcommand that takes no arguments, prints how many lines are currently in tool.log to STDOUT, and returns 0. Wire it into the $commands map so it shows up automatically in help output.
function cmd_status(array $args): int { $lines = is_file(LOG_FILE) ? count(file(LOG_FILE)): 0; fwrite(STDOUT, "tool.log has {$lines} line(s).\n"); return 0; } // then add: 'status' => ['summary' => 'Show log line count', 'handler' => 'cmd_status'], count(file(LOG_FILE)) gives you the line count if the file exists; guard the case where tool.log doesn't exist yet and report 0 lines instead of a fatal error.