italix / console
Framework CLI runner: commands listed not discovered, dependencies declared like a controller's, PSR-11 container, exit codes that mean something
Requires
- php: >=7.4
- psr/container: ^1.0 || ^2.0
Requires (Dev)
- italix/mvc: ^2.0
- italix/testing: ^2.0
This package is not auto-updated.
Last update: 2026-08-30 22:29:53 UTC
README
One CLI for the whole framework. Commands are listed, not discovered; dependencies are declared, not reflected; exit codes mean something.
One dependency: psr/container, which is interface-only.
php bin/ix # every command, grouped php bin/ix migrate --dry-run # what would run, without running it php bin/ix encode:lint # template output that is not encoded php bin/ix version # every Italix library and its version
Writing a command
namespace App\Console; use Italix\Console\{BaseCommand, Input, Output}; use Italix\Orm\DataManager; final class PurgeTokensCommand extends BaseCommand { /** Injected by the runner — protected, because BaseCommand assigns it */ protected DataManager $dm; public static function name_code(): string { return 'tokens:purge'; } public static function summary(): string { return 'Delete expired verification tokens.'; } public static function depends_on(): array { return ['dm' => DataManager::class]; } public function run(Input $in, Output $out): int { $days_n = $in->int_option('older-than', 30); if ($in->flag('dry-run')) { $out->comment("Would purge tokens older than {$days_n} days."); return 0; } $done_n = $this->dm->execute('DELETE FROM …')->rowCount(); $out->success("{$done_n} tokens purged."); return 0; } }
Register it in conf.php:
'console.commands' => [ \App\Console\MigrateCommand::class, \App\Console\PurgeTokensCommand::class, ],
Injected properties must be protected. A private property of the subclass is invisible from
BaseCommand's scope. The runner says so by name rather than letting PHP's "Cannot access private
property" stand as the whole explanation.
Input
| Written | Read as |
|---|---|
--max=100 |
option('max') → '100', int_option('max') → 100 |
--force |
flag('force') → true, has_option('force') → true |
--no-cache |
flag('cache') → false, has_option('cache') → true |
-vqf |
three flags: v, q, f |
-- |
stops option parsing; everything after is an argument |
| anything else | a positional argument, in order |
flag() reads 0, false, no and off as false.
One trap worth knowing: --max with no value does not become 0. option() and
int_option() fall back to the default, because a bare --max and an omitted --max diverging
into a zero nobody asked for is how a worker ends up processing no jobs and reporting success.
has_option() still reports it as given.
Output
$out->line('plain'); // stdout $out->info('cyan'); // stdout $out->success('green'); // stdout $out->comment('dim'); // stdout $out->warn('yellow'); // stderr $out->error('red'); // stderr $out->table(['a', 'b'], $rows);
Diagnostics go to stderr. A command whose errors land on stdout cannot be piped:
ix jobs:status | grep failed would match its own error message.
Colour only when a terminal is watching. Escape codes are emitted when the stream is a TTY and
suppressed otherwise, so a CI log gets text rather than \e[32m. NO_COLOR is honoured.
Output::in_memory() returns one that writes into memory; fetch() and fetch_errors() read it
back. That is how the suite asserts on which stream a message went to.
Exit codes
| Code | Meaning |
|---|---|
| 0 | the command succeeded |
| 1 | the command failed, or threw |
| 2 | usage error — unknown verb, bad registration, missing container |
The 1/2 split is the one encode-lint already used. run() returning a code the runner passes
through verbatim is the point: a command that fails with 0 is the CLI equivalent of a validation
rule that never runs.
An uncaught exception prints class, message, file and line, then suggests -v for the trace.
Wiring it up
bin/ix boots the same configuration the web entry point does and takes the container from the
engine:
$config = require "{$site_dir}/conf.php"; $config['base_dir'] = $base_dir; $config['host'] = $host; $engine = new Engine($config); exit(console($config['console.commands'] ?? [], $engine->registry(), 'ix', 'Acme')->run($argv));
Engine::registry() builds the container without the dispatcher, so ix version does not write
a route cache file as a side effect.
That shared container is the fix for a real bug: the old run_migration.php rebuilt the database
credentials from $_ENV with its own defaults, so a change in conf.php never reached migrations.
Deliberately not
- No auto-discovery. Commands are listed in
conf.php, like routes. A command that appears because a file was copied into place is a command nobody decided to ship. - No interactive prompts, no progress bars. Both assume a human is watching; these verbs run from cron and CI as often as from a terminal.
- No option schema. A command asks for what it wants with the default it wants, rather than maintaining a second description of every flag.