splitbrain/php-cli
splitbrain/php-cli is a lightweight PHP library for building command-line tools. It offers argument parsing, colored output, tables, progress bars, and interactive prompts, making portable CLI apps easier without heavy dependencies.
splitbrain/php-cli is a dependency-free, single-namespace PHP library for building CLI tools. There is no service provider, no facade, and no Artisan integration — it is deliberately framework-agnostic. In a Laravel codebase you'd reach for it only for standalone bin/ scripts that must run without booting the framework (installers, cron entry points, package tooling), not for php artisan commands.
Install:
composer require splitbrain/php-cli
The four classes you will actually touch:
| Class | Role |
|---|---|
splitbrain\phpcli\CLI |
Abstract base; you extend it and implement setup() + main() |
splitbrain\phpcli\Options |
Option/command/argument registration and parsed access |
splitbrain\phpcli\Colors |
ANSI styling and TTY detection |
splitbrain\phpcli\Table |
Plain-text table renderer |
Minimal working script (bin/report, chmod +x it):
#!/usr/bin/env php
<?php
require __DIR__ . '/../vendor/autoload.php';
use splitbrain\phpcli\CLI;
use splitbrain\phpcli\Options;
class ReportCLI extends CLI
{
protected function setup(Options $options)
{
$options->setHelp('Generate a report.');
$options->registerOption('n', 'name', 'Filter by name', 'name');
$options->registerOption('d', 'dry-run', 'Do not write files');
$options->registerArgument('target', 'Output directory', true);
}
protected function main(Options $options)
{
$opt = $options->getOpt(); // ['name' => ..., 'dry-run' => ...]
$args = $options->getArgs(); // positional args, in order
$this->info("Writing to {$args[0]}");
$this->success('Done');
}
}
(new ReportCLI())->run();
Where to look first: Options::registerOption() / registerCommand() / registerArgument() in setup(), then getOpt() / getCmd() / getArgs() in main(). Help text is generated automatically from your registrations, so the fastest sanity check is running the script with --help — the output mirrors exactly what you declared.
First real use case: wrap an existing maintenance script that currently reads $argv by hand. Move the ad-hoc flag parsing into setup(), replace echo/fwrite(STDERR, ...) with $this->info() / $this->error(), and you get --help, consistent exit codes, and colored output for free in about twenty lines.
Treat setup() as declarative only. It runs before parsing; anything with side effects (DB connections, file reads) belongs in main(). If parsing fails, setup() will have already run.
Normalize options into a local config array immediately. getOpt() gives you the raw parsed shape; merge it with defaults so downstream code never branches on false vs null:
protected function main(Options $options)
{
$cfg = array_merge([
'name' => null,
'dry-run' => false,
], $options->getOpt());
}
Subcommand dispatch via getCmd(). Register each command in setup(), then switch in main(). Register the union of all flags across commands in setup() (or a shared "common options" helper method) since the parser is global, and validate per-command in the handler:
protected function setup(Options $options)
{
$options->registerCommand('build', 'Compile assets');
$options->registerCommand('clean', 'Remove build artifacts');
$options->registerOption('f', 'force', 'Skip confirmation'); // valid for both
}
protected function main(Options $options)
{
switch ($options->getCmd()) {
case 'build': $this->build($options); break;
case 'clean': $this->clean($options); break;
default:
$this->error('Unknown command');
exit(1);
}
}
Logging is your status channel. The base class exposes leveled helpers — debug(), info(), success(), notice(), warning(), error(), fatal() — each applying appropriate color. Drive verbosity from a flag:
$this->setLogLevel($opt['verbose'] ? CLI::DEBUG : CLI::INFO);
Then sprinkle $this->debug() through hot loops without gating every call site.
Failures: prefer fatal() over exit(). fatal() logs through the color pipeline and unwinds via splitbrain\phpcli\Exception, which run() catches to emit a clean message and a non-zero exit code. exit(1) bypasses the color/formatting path and any finally blocks you own.
Tables for machine-adjacent human output. Table renders fixed-width ASCII that stays readable in CI logs (unlike box-drawing output):
$table = new Table($this->colors);
$table->setHeaders(['Name', 'Status']);
foreach ($rows as $r) {
$table->addRow([$r->name, $r->status]);
}
echo $table->render();
Testing pattern. run() reads the global $argv, which makes full-CLI tests awkward. Test the parse layer directly instead — instantiate Options, register your options, and call parse(['script', '--name=foo', 'target']) to assert getOpt()/getArgs() without touching globals. Keep main() thin and push logic into injectable collaborators so the interesting code is unit-testable without the CLI harness.
run() pulls from $GLOBALS['argv'] / $_SERVER['argv']. Calling your CLI class from inside another process (a queue worker, a test) will parse that process's arguments. Keep CLI classes as thin entry points only.-n value and --name=value are the safe forms. Don't assume short-flag bundling (-abc) or optional-argument semantics — the parser is intentionally minimal. Test any exotic syntax you rely on before documenting it.-- terminator and negative arguments. Positional args that begin with - need the -- separator; verify behavior for your version rather than guessing, since the argument/option splitter is the most version-sensitive part of the library.getOpt() shape. Flags come back falsy when absent, and options-with-arguments come back false/null rather than a default. Always array_merge() defaults as shown above — isset() alone will bite you on empty-string values.fatal() throws. If you have cleanup (temp dirs, locks, transactions), either register a shutdown handler or wrap main() body in try/finally. An uncaught splitbrain\phpcli\Exception from deep inside your code will surface as a formatted fatal message — sometimes useful, sometimes masking a bug.Colors class sniffs terminal support, so piped output and cron runs generally come out clean, but Windows consoles and TERM=dumb containers are the usual edge cases. Always register a --no-color escape hatch and honor it by disabling $this->colors before logging anything.setup() — it's the only control you have over the generated --help layout, and it's the documentation most users will actually read.setName(), setVersion()) is worth setting even for internal tools; --version in a bug report saves a round trip.CLI for new tools, but avoid subclassing Options — the parser API is small and stable, and wrapping it usually creates more friction than it removes. Put shared behavior in a base CLI subclass or a trait with your own setupCommonOptions() helper.Illuminate\Console/Symfony Console instead. Reach for this package when you explicitly want zero framework bootstrapping and a ~400 KB vendor footprint.How can I help you explore Laravel packages today?