Interactive CLI runtime for PHP microservice and hexagonal architectures. Reactive terminal components, structured command execution, and a unified I/O layer — designed for OpenSwoole, multi-repository platforms, and production backend systems.
- Overview
- Installation
- Architecture
- Building Commands
- Interactive Components
- Display Components
- Shell Execution
- I/O Layer
- Application Bootstrap
- Internals
- Testing
- Requirements
php-io-cli is a self-contained CLI framework for PHP 8.2+. It provides:
- Reactive terminal components with ANSI-safe rendering and flicker-free redraws
- A structured command layer (
AbstractCommand) with argument/option parsing, help generation, and typed component factory methods - A unified I/O interface (
IOInterface) bridging Symfony Console, PSR-3 logging, and reactive components under one API - A
Shelldriver that streams stdout/stderr without deadlocks and feeds live output intoProgressBaranimations - A self-bootstrapping
CLIApplicationwith Composer-based command discovery, fuzzy "did you mean?" suggestions, and built-inlist,help, andversioncommands
The design is inspired by Composer's CLI internals and Laravel Zero's component model, adapted for multi-repository hexagonal architectures where commands are distributed across packages.
composer require alfacode-team/php-io-cliRequirements: PHP 8.2+, psr/log: ^3.0. Symfony Console is an optional dev dependency used only by ConsoleIO and BufferIO.
CLIApplication
└── AbstractCommand # Your commands extend this
├── IOInterface # Unified I/O (write, ask, select …)
│ ├── ConsoleIO # Real terminal — delegates to reactive components on TTY
│ ├── BufferIO # In-memory capture for testing
│ └── NullIO # Silent, returns defaults (CI / daemons)
└── Components
├── TextInput / Password / NumberInput / Confirm
├── Select / MultiSelect / Autocomplete / DatePicker
├── Table / Alert / ProgressBar / SpinnerComponent
└── AbstractPrompt → ILifecycle (mount/render/update/destroy)
├── State # Reactive key-value store with watchers
├── Input # Key binding dispatcher
├── Renderer # Scroll windowing, cursor management
└── Terminal # Raw mode, escape sequences, cross-platform
Extend AbstractCommand, implement configure() to declare metadata, and handle() to run your logic.
useAlfacodeTeam\PhpIoCli\AbstractCommand;
finalclass DeployCommand extends AbstractCommand
{
protectedfunctionconfigure(): void
{
$this->name = 'deploy';
$this->description = 'Deploy the application to a target environment';
$this->addArgument('environment', 'Target environment (production, staging)', required: true);
$this->addOption('dry-run', 'd', 'Simulate the deployment without side-effects');
$this->addOption('tag', 't', 'Git tag to deploy', acceptsValue: true);
}
protectedfunctionhandle(): int
{
$env = $this->argument('environment');
$dryRun = $this->hasOption('dry-run');
$tag = $this->option('tag', 'latest');
if (!$this->confirm("Deploy {$tag} to {$env}?")) {
$this->muted('Aborted.');
returnself::SUCCESS;
}
// ... deployment logic$this->alertSuccess("Deployed {$tag} → {$env}");
returnself::SUCCESS;
}
}Return one of the three exit code constants from handle():
| Constant | Value | Meaning |
|---|---|---|
self::SUCCESS | 0 | Command completed normally |
self::FAILURE | 1 | Command failed |
self::INVALID | 2 | Bad input / missing required arguments |
// Positional argument$this->addArgument(
name: 'name',
description: 'Module name in kebab-case',
required: true,
default: null,
);
// Long option (--force)// Short option (-f)// Flag (no value) or value-accepting option$this->addOption(
long: 'force',
short: 'f',
description: 'Skip confirmation prompts',
acceptsValue: false,
default: null,
);
// Value option: --tag=v1.2.0 or --tag v1.2.0$this->addOption('tag', 't', 'Git tag', acceptsValue: true, default: 'latest');Retrieving values inside handle():
$env = $this->argument('environment'); // string|null$force = $this->hasOption('force'); // bool$tag = $this->option('tag', 'latest'); // mixed, with fallback defaultAll output methods are available inside handle() without touching IOInterface directly.
$this->info('Connecting to database…'); // cyan text$this->success('Migration complete.'); // ✔ green$this->warning('Disk usage above 80%.'); // ! yellow (stderr)$this->error('Connection refused.'); // ✘ red (stderr)$this->muted('Skipped — already exists.'); // dim gray$this->section('Build Pipeline'); // bold cyan heading + underline$this->newLine(2); // blank lines// Inline component factory shortcuts$name = $this->ask('Project name');
$env = $this->select('Target', ['prod', 'staging', 'dev']);
$confirm = $this->confirm('Continue?');
$bar = $this->progressBar('Installing', 10);
$spin = $this->spinner('Compiling');
$table = $this->table();
// Alert boxes$this->alertSuccess('Deployed!', ['Version: 2.4.1', 'Region: eu-west-1']);
$this->alertError('Build failed', ['Check logs at /var/log/build.log']);
$this->alertWarning('API rate limit at 80%');
$this->alertInfo('New version available: 3.0.0');All components implement IPromptComponent::run(): mixed — call ->run() to start the reactive loop and block until the user submits.
Every component is usable standalone or through the AbstractCommand factory helpers. They activate raw terminal mode automatically and restore it on exit, including on Ctrl+C.
Free-text input with virtual block cursor, inline validation, placeholder, default value, and HOME/END navigation.
useAlfacodeTeam\PhpIoCli\Components\TextInput;
$hostname = (newTextInput('Database hostname'))
->placeholder('localhost')
->default('127.0.0.1')
->validate(function (string$value): ?string {
returnfilter_var($value, FILTER_VALIDATE_IP) || $value === 'localhost'
? null// null = valid
: 'Must be a valid IP or hostname';
})
->run();| Key | Action |
|---|---|
| Printable chars | Insert at cursor |
← / → | Move cursor |
HOME / END | Jump to start / end |
Backspace / Delete | Delete left / right |
Enter | Submit (runs validator) |
Masked input with toggle visibility, live strength meter, and ● masking.
useAlfacodeTeam\PhpIoCli\Components\Password;
$secret = (newPassword('Encryption key'))
->showStrength() // renders a 5-point strength bar
->run();| Key | Action |
|---|---|
| Printable chars | Append to value |
Backspace | Delete last character |
TAB | Toggle plaintext / masked |
Enter | Submit |
Strength scoring: length ≥ 8, length ≥ 12, uppercase, digit, special character — one point each. Displayed as Very weak → Weak → Fair → Good → Strong.
Numeric input with arrow-key stepping, min/max clamping, integer mode, and an inline range hint.
useAlfacodeTeam\PhpIoCli\Components\NumberInput;
$port = (newNumberInput('Server port'))
->min(1)
->max(65535)
->default(8080)
->step(1)
->integer() // rejects decimal input
->run(); // returns int| Key | Action |
|---|---|
Digits / - / . | Append character |
Backspace | Delete last character |
↑ / ↓ | Increment / decrement by step |
Enter | Submit (validates range) |
Boolean toggle rendered as highlighted buttons.
useAlfacodeTeam\PhpIoCli\Components\Confirm;
$proceed = (newConfirm('Overwrite existing files?', default: false))->run(); // bool| Key | Action |
|---|---|
y / Y | Set Yes |
n / N | Set No |
← / → | Toggle |
Enter | Confirm |
Searchable single-selection list with fuzzy filtering and scroll windowing (8 items visible).
useAlfacodeTeam\PhpIoCli\Components\Select;
$region = (newSelect('Deploy region', [
'eu-west-1', 'us-east-1', 'ap-southeast-1', 'us-west-2',
]))->run(); // string| Key | Action |
|---|---|
| Printable chars | Filter list (fuzzy) |
↑ / ↓ | Navigate |
Backspace | Delete filter character |
Enter | Select highlighted item |
Checkbox list with spacebar toggle.
useAlfacodeTeam\PhpIoCli\Components\MultiSelect;
$features = (newMultiSelect('Enable features', [
'Auth', 'API Gateway', 'Queue Worker', 'Scheduler', 'Websockets',
]))->run(); // string[]| Key | Action |
|---|---|
↑ / ↓ | Navigate |
Space | Toggle selected |
Enter | Confirm selection |
Text input with a live fuzzy-search dropdown. Useful for large suggestion sets.
useAlfacodeTeam\PhpIoCli\Components\Autocomplete;
$package = (newAutocomplete('Package name', $allPackages))
->maxSuggestions(8)
->run(); // string| Key | Action |
|---|---|
| Printable chars | Type / filter |
↑ / ↓ | Navigate dropdown |
TAB | Fill highlighted suggestion |
Enter | Confirm (fills suggestion first if divergent) |
Backspace | Delete character |
Interactive calendar grid with week and month navigation.
useAlfacodeTeam\PhpIoCli\Components\DatePicker;
$date = (newDatePicker('Release date'))->run(); // DateTimeImmutableecho$date->format('Y-m-d');| Key | Action |
|---|---|
← / → | Previous / next day |
↑ / ↓ | Previous / next week |
[ / ] | Previous / next month |
t | Jump to today |
Enter | Confirm |
Unicode box-drawing table with ANSI-safe column width calculation, alignment, and alternating row shading.
useAlfacodeTeam\PhpIoCli\Components\Table;
Table::make()
->headers(['Service', 'Status', 'Latency'])
->rows([
['api-gateway', Colors::wrap('healthy', Colors::GREEN), '12 ms'],
['auth-service', Colors::wrap('degraded', Colors::YELLOW), '340 ms'],
['payment-worker',Colors::wrap('down', Colors::RED), '—'],
])
->style('box') // 'box' (default) | 'bold' | 'compact' | 'minimal'
->align([0 => 'left', 2 => 'right'])
->striped()
->render();All four border styles:
| Style | Characters |
|---|---|
box | ╔ ╗ ╚ ╝ ═ ║ (double-line, default) |
bold | ┏ ┓ ┗ ┛ ━ ┃ |
compact | ┌ ┐ └ ┘ ─ │ |
minimal | space-separated, horizontal rules only |
Column widths are measured on the visual (stripped) string, so ANSI color codes in cells never corrupt alignment.
Attention-grabbing bordered notification boxes.
useAlfacodeTeam\PhpIoCli\Components\Alert;
Alert::success('Deployment complete!', ['Version: 2.4.1', 'Uptime: 99.98%']);
Alert::error('Migration failed', ['Error: duplicate key constraint on users.email']);
Alert::warning('API quota at 80%', ['Resets in 4 hours']);
Alert::info('Maintenance window tonight 02:00–04:00 UTC');Each variant renders a Unicode box with a coloured icon and optional multi-line body section. Use Alert::block() for solid-background enterprise-style error blocks.
Determinate (percentage fill) and indeterminate (bounce) progress bars.
Determinate — tracks a known number of steps with ETA and throughput:
$bar = newProgressBar('Processing records', total: 1000);
$bar->start();
foreach ($recordsas$record) {
process($record);
$bar->advance(); // +1 step
}
$bar->finish('All records processed');Indeterminate — animating bounce bar for unknown-duration work:
$bar = newProgressBar('Waiting for lock'); // total=0 → indeterminate$bar->start();
// ... do work ...$bar->finish('Lock acquired');Fluent configuration:
$bar->width(60) // bar width in characters (default 40)
->fill('▓') // fill character
->empty('░'); // empty characterDeterminate bars automatically render:
- Colour-coded fill: red → yellow → cyan → green as percentage climbs
current / totalcounter- Items/second throughput
- ETA in seconds
Non-blocking spinner for wrapping long-running tasks. Best used when integrated with Shell::run() (see Shell + ProgressBar Pattern).
useAlfacodeTeam\PhpIoCli\Components\SpinnerComponent;
useAlfacodeTeam\PhpIoCli\Depends\SpinnerFrames;
$spin = newSpinnerComponent('Connecting to cluster', style: 'dots');
$spin->start();
$result = doSlowWork();
$result->ok()
? $spin->stop('Connected')
: $spin->fail('Connection refused');Available styles: dots (default), line, bars, pulse, arc, bounce.
Shell::run() executes a command via proc_open and streams stdout and stderr simultaneously using stream_select(), eliminating the classic pipe-deadlock problem. It fires a $tick callback on every ≤50 ms poll cycle, making it trivially composable with animated UI components.
useAlfacodeTeam\PhpIoCli\Depends\Shell;
$result = Shell::run(
command: 'composer install --no-interaction',
tick: function (string$lastLine, bool$isStderr): void {
// called every 50 ms with the most recent output line
},
env: ['COMPOSER_NO_INTERACTION' => '1'],
cwd: '/var/www/app',
);
if ($result->failed()) {
echo$result->errors(); // all stderr joinedexit($result->exitCode);
}
echo$result->output(); // all stdout joinedShell::capture() is a convenience wrapper for quick value reads:
$branch = Shell::capture('git rev-parse --abbrev-ref HEAD', cwd: $projectRoot);
// returns trimmed stdout string, or null on failureShellResult API:
$result->ok(); // exitCode === 0$result->failed(); // exitCode !== 0$result->exitCode; // int$result->output(); // stdout joined with PHP_EOL$result->errors(); // stderr joined with PHP_EOL$result->meaningfulErrors(); // non-empty stderr lines as string[]$result->stdout; // string[] raw lines$result->stderr; // string[] raw linesThe canonical pattern for animated shell steps in commands: pass the live ProgressBar into Shell::run()'s tick, and call advance(0) to redraw without moving $current forward. Only one ProgressBar instance draws at a time — no terminal fighting.
protectedfunctionhandle(): int
{
$overall = $this->progressBar('Deploying', total: 4);
$overall->start();
// Each shell step redraws the same bar on every 50 ms tick.// advance(0) = redraw only; advance() = redraw + increment.$result = Shell::run(
'composer install --no-dev',
tick: fn() => $overall->advance(0),
cwd: $this->projectRoot(),
);
if ($result->failed()) {
$overall->finish('Aborted');
$this->alertError('composer install failed', $result->meaningfulErrors());
returnself::FAILURE;
}
$overall->advance(); // step 1 complete → fill moves forward// Pure-PHP steps need no tick — just do the work and advance.$this->generateConfig();
$overall->advance(); // step 2 complete// ... more steps ...$overall->finish('Deployment complete');
returnself::SUCCESS;
}Why not nest two bars? Each
ProgressBarinstance tracks$lastLinesindependently. If two instances are live simultaneously, theirmoveCursorUp()calls interfere, producing interleaved frames. Always use a single instance per command and pass it by reference into helpers.
The production I/O implementation. On a real TTY (posix_isatty(STDIN) === true) every interactive method delegates to the corresponding reactive component. On non-interactive streams (piped input, CI) it falls back gracefully to Symfony's QuestionHelper.
useAlfacodeTeam\PhpIoCli\ConsoleIO;
useSymfony\Component\Console\Helper\HelperSet;
useSymfony\Component\Console\Helper\QuestionHelper;
useSymfony\Component\Console\Input\ArgvInput;
useSymfony\Component\Console\Output\ConsoleOutput;
$io = newConsoleIO(
newArgvInput(),
newConsoleOutput(),
newHelperSet([newQuestionHelper()]),
);
$io->ask('Project name');
$io->askConfirmation('Continue?');
$io->askAndValidate('Email', fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL) ? $v : thrownew \RuntimeException('Invalid email'));
$io->askAndHideAnswer('Password');
$io->select('Environment', ['prod', 'staging'], default: 'staging');
$io->select('Features', ['Auth', 'API', 'Queue'], default: 0, multiselect: true);Verbosity levels mirror Symfony's constants and are set via --verbose / --very-verbose / --debug flags:
$io->write('Always visible', verbosity: IOInterface::NORMAL);
$io->write('With --verbose', verbosity: IOInterface::VERBOSE);
$io->write('With --very-verbose', verbosity: IOInterface::VERY_VERBOSE);
$io->write('With --debug', verbosity: IOInterface::DEBUG);ConsoleIO also implements LoggerInterface (PSR-3) — severity levels map to ANSI-themed output and stderr automatically.
Enable debug timing (prepends [MiB/seconds] to every line):
$io->enableDebugging(microtime(true));In-memory I/O implementation for testing. Captures all output and simulates user input via pre-set answer streams.
useAlfacodeTeam\PhpIoCli\BufferIO;
$io = newBufferIO();
$io->setUserInputs(['my-module', 'y', '']); // simulated keystrokes$command->execute(['module:add', 'my-module', 'git@…', 'acme'], $io);
$output = $io->getOutput(); // captured, ANSI-stripped stringassertStringContainsString('added successfully', $output);getOutput() strips all ANSI/VT100 escape sequences and cleans up backspace characters, returning a plain readable string safe for assertStringContainsString.
Completely silent implementation — every write is a no-op, every interactive method returns its $default. Ideal for daemons, background workers, and unit tests that don't care about output.
useAlfacodeTeam\PhpIoCli\NullIO;
$io = newNullIO();
$io->ask('Name', 'fallback'); // returns 'fallback'$io->askConfirmation('Sure?', true); // returns true$io->select('Env', ['prod'], 'prod'); // returns 'prod'$io->write('ignored'); // no-opCLIApplication is the entry point for your CLI binary. It auto-builds the I/O layer, dispatches commands, handles exceptions, and provides built-in list, help, and version commands.
#!/usr/bin/env php<?phprequire__DIR__ . '/vendor/autoload.php';
(newAlfacodeTeam\PhpIoCli\CLIApplication('MyPlatform', '2.1.0'))
->discoverCommands(__DIR__ . '/composer.json')
->add(newApp\Commands\ServeCommand())
->run();Built-in commands available automatically:
| Command | Description |
|---|---|
list | All registered commands grouped by namespace |
help <command> | Detailed usage for a specific command |
version | Application name and version |
Global flags:
| Flag | Effect |
|---|---|
--no-ansi | Disable all ANSI color output |
--debug / -d | Enable debug verbosity + timing prefix |
Not-found handling: when an unknown command is typed, CLIApplication computes Levenshtein distances against all registered names and offers up to three suggestions. On a real TTY with multiple matches, an interactive Select picker is shown.
// Swap I/O for tests or custom environments$app->withIO(newBufferIO());
// Prevent the application from swallowing exceptions (e.g. in tests)$app->catchExceptions(false);Register commands explicitly or via Composer's extra block for zero-configuration auto-discovery across multiple packages.
Explicit registration:
$app->add(
newApp\Commands\MigrateCommand(),
newApp\Commands\SeedCommand(),
newApp\Commands\ServeCommand(),
);Composer auto-discovery — add to your project's composer.json (not the library's):
{
"extra": {
"php-io-cli": {
"commands": [
"App\\Commands\\MigrateCommand",
"App\\Commands\\SeedCommand",
"App\\Commands\\ServeCommand",
"Modules\\Auth\\Commands\\CreateUserCommand"
]
}
}
}Then call discoverCommands() in your bootstrap:
$app->discoverCommands('/path/to/composer.json');
// or omit the path to auto-detect from the project root:$app->discoverCommands();Classes that are absent, abstract, or not an AbstractCommand subclass are silently skipped (logged under --debug). Commands are grouped by namespace in the list output — the segment before the first : is the group name (module:add → group module).
Every interactive component extends AbstractPrompt, which drives the reactive event loop. The loop is started by run() and runs until stop() is called (typically from an ENTER binding).
run()
├── Terminal::enableRaw()
├── mount() ← Component::setup() — wire State + Input bindings
│
└── loop:
├── render() ← draw the current frame to the terminal
├── readKey() ← block until a keypress arrives
└── update() ← dispatch key through Input bindings → mutate State
→ context->markDirty() → triggers re-render on next cycle
On CTRL+C the loop calls handleCancel() which prints a cancellation message and exits cleanly. On any exception, handleError() prints the error before re-throwing. destroy() and Terminal::disableRaw() run in a finally block, guaranteeing terminal restoration.
State is a reactive key-value container. Property access uses __get / __set. Watchers fire synchronously on change.
useAlfacodeTeam\PhpIoCli\Depends\State;
$state = newState(['count' => 0, 'selected' => []]);
// Magic access$state->count = 5;
echo$state->count; // 5// Batch update (single notification burst)$state->batch(['index' => 0, 'search' => '', 'done' => false]);
// Navigation helpers$state->increment('index', max: 9); // clamps at max$state->decrement('index'); // clamps at 0// Multi-select toggle$state->toggle('selected', 'Auth'); // adds if absent, removes if present// Reactivity$state->watch('index', function (mixed$new, mixed$old, State$state): void {
// fires whenever 'index' changes
});Input maps normalized key names to handler closures. Handlers receive (State $state, string $key) and may return false to stop propagation.
useAlfacodeTeam\PhpIoCli\Depends\Input;
$input = newInput();
// Single key$input->bind('ENTER', fn($s) => $this->stop());
// Multiple keys to same handler$input->bind(['y', 'Y'], fn($s) => $s->confirmed = true);
// Fallback for unbound keys (typically used for character typing)$input->fallback(function ($state, $key): void {
if (Key::isPrintable($key)) {
$state->value .= $key;
}
});
// Stop propagation$input->bind('UP', function ($state, $key): false {
$state->decrement('index');
returnfalse; // prevents any further handlers for this key
});
// Remove a binding$input->unbind('ESC');Normalized key names: UP, DOWN, LEFT, RIGHT, HOME, END, ENTER, TAB, ESC, BACKSPACE, DELETE, CTRL_C, CTRL_D, printable characters as-is.
Hooks provides a lightweight pub/sub event bus wired into AbstractPrompt's lifecycle. Standard lifecycle events: mount, render, update, submit, destroy.
useAlfacodeTeam\PhpIoCli\Hooks;
$hooks = newHooks();
// Subscribe$hooks->on('submit', function (mixed$value, string$event, Hooks$hooks): void {
Log::info("User submitted: {$value}");
});
// Subscribe once then auto-unsubscribe$hooks->once('mount', fn() => $this->loadDefaults());
// Dispatch$hooks->dispatch('submit', $resolvedValue);
// Chain of Responsibility — stops at first non-null return$handled = $hooks->dispatchUntil('validate', $inputValue);
// Unsubscribe specific listener or all listeners for an event$hooks->off('render', $specificHandler);
$hooks->off('render'); // remove allColors manages ANSI output with automatic environment detection (NO_COLOR, FORCE_COLOR, Windows VT100, Unix TTY).
useAlfacodeTeam\PhpIoCli\Depends\Colors;
// Wrap with style constants
Colors::wrap('Hello', Colors::BOLD);
Colors::wrap('Hello', [Colors::BOLD, Colors::CYAN]);
// Semantic helpers
Colors::success('Done'); // ✔ green bold
Colors::error('Failed'); // ✘ red bold
Colors::warning('Caution'); // ! yellow bold
Colors::info('Note'); // cyan
Colors::muted('Skipped'); // dim gray// True-color hex
Colors::hex('#e94560', 'Alert!');
// Print line directly
Colors::line('Status: OK', [Colors::GREEN, Colors::BOLD]);
// Strip all ANSI sequences (for testing / width measurement)$clean = Colors::strip($ansiString);
// Force on/off regardless of environment
Colors::enable();
Colors::disable();Terminal handles raw mode, input reading, and output cursor control. It is used internally by all components — you rarely need to call it directly.
useAlfacodeTeam\PhpIoCli\Depends\Terminal;
Terminal::enableRaw(); // disable echo + canonical mode
Terminal::disableRaw(); // restore saved tty state$key = Terminal::readKey(); // block until input; returns escape sequences as a single string
Terminal::hideCursor();
Terminal::showCursor();
Terminal::clearLine(); // \r\033[2K
Terminal::moveCursorUp(3); // \033[3ARaw mode is automatically restored on shutdown via register_shutdown_function. On Unix, SIGINT and SIGTERM are caught via pcntl_signal to ensure clean exit. On Windows, sapi_windows_vt100_support() is called to enable VT100 sequences in modern terminals.
Escape sequence reading uses a 10 ms settle window (stream_set_blocking(STDIN, false) + microtime() polling) to collect multi-byte sequences (arrows, Home, Delete) as a single string, preventing the classic "ghost character" problem.
Use BufferIO to test commands without a TTY. Pre-set user inputs with setUserInputs() — each string maps to one prompt answer in order.
useAlfacodeTeam\PhpIoCli\BufferIO;
usePHPUnit\Framework\TestCase;
class ModuleAddCommandTest extends TestCase
{
publicfunctiontest_adds_module_successfully(): void
{
$io = newBufferIO();
$io->setUserInputs([
'y', // Confirm prompt
]);
$command = newModuleAddCommand();
$exit = $command->execute(
['my-module', 'git@github.com:acme/my-module.git', 'acme'],
$io
);
$this->assertSame(AbstractCommand::SUCCESS, $exit);
$this->assertStringContainsString('added successfully', $io->getOutput());
}
publicfunctiontest_aborts_when_user_declines(): void
{
$io = newBufferIO();
$io->setUserInputs(['n']); // decline confirmation$command = newModuleAddCommand();
$exit = $command->execute(['my-module', 'git@…', 'acme'], $io);
$this->assertSame(AbstractCommand::SUCCESS, $exit);
$this->assertStringContainsString('Aborted', $io->getOutput());
}
}BufferIO::getOutput() returns ANSI-stripped output with backspace sequences cleaned up, making plain-string assertions reliable regardless of terminal formatting.
For commands that must not produce any output (daemon processes, silent pipelines), use NullIO and assert only the return code.
| Requirement | Version |
|---|---|
| PHP | ^8.2 |
psr/log | ^3.0 |
symfony/console | ^6.0 || ^7.0(dev, optional at runtime) |
| OS | Linux, macOS, Windows (VT100 terminal) |
PHP extensions used:pcntl (signal handling, Unix only), posix (TTY detection, Unix only), mbstring (multibyte string operations). All extension calls are guarded with function_exists() — the library degrades gracefully when they are absent.
MIT © 2026 AlfaCode Team. See LICENSE for full terms.