A command line kernel: a command is a class, and what it needs it asks for. Full documentation: https://quillstack.org/cli
No annotations, no definitions, no builder. A class says what it is called, what it does, and what happens when it runs — and the container builds it with whatever it asked for in its constructor.
Console libraries tend to ask you to describe a command twice: once as a class, and once as a definition of its name, arguments and options — in a constructor call, an attribute, or a configuration array. The two drift, and the definition is what runs. Here there is one description, because the class is the definition, and what a command needs it takes in its constructor like anything else in the framework.
- PHP 8.1 or newer
- A PSR-11 container, to build the commands
composer require quillstack/cliuseQuillstack\Cli\CommandInterface;
useQuillstack\Cli\Input;
useQuillstack\Output\OutputInterface;
finalclass GreetCommand implements CommandInterface
{
publicfunction__construct(privatereadonlyGreeting$greeting)
{
}
publicfunctiongetName(): string
{
return'greet';
}
publicfunctiongetDescription(): string
{
return'Says hello to somebody';
}
publicfunctionrun(Input$input, OutputInterface$output): int
{
$output->writeln($this->greeting->to((string) $input->getArgument(0, 'world')));
return0;
}
}Greeting is built for it, the same way everything else is.
useQuillstack\Cli\CommandProviderInterface;
finalclass CommandProvider implements CommandProviderInterface
{
publicfunctiongetCommands(): array
{
return [GreetCommand::class];
}
}#!/usr/bin/env php<?phprequire__DIR__ . '/../vendor/autoload.php';
useQuillstack\Cli\Console;
useQuillstack\Cli\CommandProviderInterface;
useQuillstack\DI\Container;
$console = newConsole(newContainer([
CommandProviderInterface::class => CommandProvider::class,
]));
exit($console->run($argv));$ ./bin/tool greet ada
Hello, ada
$ ./bin/tool
Commands
greet Says hello to somebody
list Lists the commands there areTyping nothing lists what there is.
./bin/tool queue:work emails --sleep=5 --keep-running -v$input->getCommand(); // 'queue:work'$input->getArgument(0); // 'emails'$input->getArgument(1, 'none'); // 'none'$input->getOption('sleep'); // '5'$input->getOption('keep-running'); // true$input->hasOption('v'); // true--name=value carries one, --name and -n are the fact that they were written, and -abc
is three of them. Only the first = separates, so --dsn=mysql:host=localhost;dbname=shop
arrives whole. Options may be written before the arguments, after them, or on both sides.
Anything a command throws is reported rather than reaching the terminal as a fatal error, and
the exit code is 1. Where failures are described — while developing, not on a server — the
exception, where it came from, and the trace are shown as well:
newConsole($container, describeFailures: true);A command nobody knows says so, and says how to find out what there is.
| Class | What it is |
|---|---|
Console | the short way in: builds the kernel and runs $argv |
ConsoleKernel | finds the command, runs it, turns anything thrown into something readable |
Input | what was typed, taken apart |
CommandInterface | getName(), getDescription(), run() |
CommandProviderInterface | getCommands(): array — the classes |
Commands\ListCommand | comes with the package |
Exceptions\CliException | what everything here extends |
ConsoleKernel::add() puts commands on top of whatever the provider lists, which is how
quillstack/framework adds the ones that only apply
where the application configured something — db:migrate where there are entities,
queue:work where there is a queue.
The list command is built by the kernel rather than through the container, because a kernel asked for from a container would be a second one, knowing none of the commands added to the first.
Against symfony/console 7.4.17 and minicli 4.2.1, on PHP 8.4 on an M-series Mac.
The number a user actually feels is how long the whole command takes, so start there. 60 runs
of the same greet ada, interleaved, median of five:
| Whole process | Of which is the library | |
|---|---|---|
bare php on an empty file | 44.22 ms | — |
| quillstack/cli | 46.61 ms | 2.4 ms |
| minicli | 46.92 ms | 2.7 ms |
| symfony/console | 51.92 ms | 7.7 ms |
Read that column on the left first: about 95% of the wait is PHP starting up, and no console library can do anything about it. Choosing between these three moves a command by a few milliseconds against a fixed 44 that you pay regardless.
That said, the part which is the library differs by more than the whole-process figures let you see, because most of those milliseconds are spent reading code off disk once. Measuring bootstrap and dispatch inside a single process, where the loading is amortised away, leaves what it costs to run:
| Package | Version | Bootstrap + dispatch |
|---|---|---|
| quillstack/cli | 0.6.0 | 5.0 µs |
| minicli | 4.2.1 | 12.5 µs |
| symfony/console | 7.4.17 | 78.5 µs |
And what has to be loaded to get there, which does not vary by machine:
| Package | Files loaded | Memory | On disk |
|---|---|---|---|
| quillstack/cli | 30 | 104 KB | 108 KB |
| minicli | 36 | 220 KB | 352 KB |
| symfony/console | 39 | 774 KB | 952 KB |
The reason is not that this is written better. symfony/console is doing considerably more:
typed options and arguments with validation, output formatting and styles, progress bars,
tables, interactive questions, shell completion, and command discovery. If you want any of
those, that 78.5 µs buys them, and this package will not do them for you. What is measured
here is the cost of the part all three share — deciding which command was asked for and
running it — which is the only part this package has.
composer test
composer test:coveragecomposer stanThis is one component of Quillstack, a PHP framework which is as simple to use as it is strict about what it does.
- quillstack/di — what builds the commands
- quillstack/output — where a command writes
- quillstack/framework — the same idea for HTTP
- quillstack/benchmark — commands worth timing
MIT — see LICENSE.