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 versionnamespace 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.
| 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.
$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.
| 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.
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.
- 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.