Building Console Applications
A console application in Pop is a stand-alone CLI application object serving a different kind of request.
It is intentionally separate and distinct from kettle, providing a separate place to organize and build
out a large console application that could potentially have many commands.
The router matches command arguments instead of a URL path, dispatches a controller or a command class, and
that code writes back through Pop\Console\Console instead of an HTTP response. This page covers the
console side: the entry script, the command surface, and everything Console can put on a terminal.
Controllers vs Commands#
Two different dispatchable objects can be configured and deployed via the console application: commands and
controllers. The difference is really organization. A command class handles one command. A controller class
handles a group of them, one action method per command, giving you the ability to organize a subset of commands
in one class. For example, your App\Console\Controller\UsersController class may have the methods list(),
create() and delete() in it, all providing functionality related to users. Conversely, you could wire up
three command classes App\Console\Command\UsersList, App\Console\Command\UsersCreate and
App\Console\Command\UsersDelete that each deploy their handle() method.
A CLI Application in Pop#
The entry point is a PHP script with a shebang, sitting in the project root or in script/. It builds
an application from a CLI routes config and runs it:
#!/usr/bin/env php
<?php
$autoloader = include __DIR__ . '/vendor/autoload.php';
$app = new Pop\Application($autoloader, include __DIR__ . '/app/config/app.cli.php');
$app->run();
Pop\Application decides which router to build by looking at how PHP was invoked, so nothing in that
script says "this is CLI". The route table is what makes it a console application:
<?php
return [
'routes' => [
'users list [--limit=]' => [
'controller' => 'App\Console\Controller\UsersController',
'action' => 'index',
'help' => 'List the users'
],
'help' => [
'controller' => 'App\Console\Controller\ConsoleController',
'action' => 'help',
'help' => 'Show the help screen'
],
'*' => [
'controller' => 'App\Console\Controller\ConsoleController',
'action' => 'error'
]
]
];
A controller reaches the console the same way an HTTP controller reaches the request and response — through
a trait. Pop\Dispatch\ConsoleTrait supplies a constructor taking the application and a
Pop\Console\Console, the second defaulted, so $this->console and $this->application are both there:
<?php
namespace App\Console\Controller;
use Pop\Controller\AbstractController;
use Pop\Dispatch\ConsoleTrait;
class UsersController extends AbstractController
{
use ConsoleTrait;
public function index(array $options = []): void
{
$limit = $options['limit'] ?? 'all';
$this->console->write('Listing ' . $limit . ' users.');
}
}
./app users list --limit=25
Listing 25 users.
That leading indent is the console's margin, not something the sample printed. A Console a controller
receives by default is new Console(120) — a wrap width of 120 columns and a margin of four spaces —
while a Console you construct yourself with no arguments wraps at 80. Both are covered under
The response buffer.
The '*' catch-all is not optional in practice. Without it a mistyped command produces no output at
all, and the user has no way to tell a typo from a command that did nothing. Route it at an action that
says so:
<?php
namespace App\Console\Controller;
use Pop\Controller\AbstractController;
use Pop\Dispatch\ConsoleTrait;
class ConsoleController extends AbstractController
{
use ConsoleTrait;
public function help(): void
{
$this->console->addCommandsFromRoutes(
$this->application->router()->getRouteMatch(), './app'
);
$this->console->help();
}
public function error(): void
{
$this->console->alertDanger('Invalid command', 40);
}
}
Two routes, a controller each, and the application answers both a real command and a wrong one. The rest of this page is what goes inside those actions.
CLI Routes#
A CLI route pattern is a command signature: bare words are literal, <name> is required, [<name>]
optional, [--flag] a boolean and [-l|--limit=] an option taking a value. Parameters reach the action
positionally and options arrive in one trailing array. The full syntax table is in
Routing.
What is console-specific is that a route can carry a help key. Nothing dispatches on it; it's read
by the console object when it builds the help screen, described under
Help screens.
For an application that puts one command per class, the route table does not have to be written by hand.
Pop\Console\CommandRegistry::loadRoutes() scans a directory and returns a routes array built from what
it finds:
#!/usr/bin/env php
<?php
$autoloader = include __DIR__ . '/vendor/autoload.php';
$config = include __DIR__ . '/app/config/app.cli.php';
$config['routes'] = Pop\Console\CommandRegistry::loadRoutes(
$config['routes'], __DIR__ . '/app/src/Console/Command'
);
$app = new Pop\Application($autoloader, $config);
$app->run();
Each .php file holds one loadable class named after the file, and every class in a directory shares the
namespace parsed from the first file scanned. Each is constructed with no arguments and keyed by its own
string cast, so a command class names itself. A UsersList class naming itself users list with params
[--limit=] comes back as:
$config['routes'] = [
'users list [--limit=]' => [
'controller' => 'App\Console\Command\UsersList',
'help' => 'List the users'
],
'help' => [
'controller' => 'App\Console\Controller\ConsoleController',
'action' => 'help'
]
];
The third argument, $prepend, defaults to true and merges discovered routes before the ones you passed,
so your explicit route wins a key collision. Pass false to let the scanned directory override the config.
loadRoutes() is the only part of CommandRegistry you call directly; the rest belongs to a registry
Console builds and holds for itself.
Commands#
Pop\Console\Command is a command's name, parameters and help text as an object. Registering one with
the console is what puts it on the help screen:
use Pop\Console\Command;
use Pop\Console\Console;
$console = new Console();
$users = new Command(name: 'users list');
$users->setParams('[--limit=]')
->setHelp('List the users');
$roles = new Command(name: 'roles list', params: '[--limit=]', help: 'List the roles');
$console->addCommands([$users, $roles]);
$console->hasCommand('users list'); // true
$console->getCommand('roles list'); // the $roles object, or null
$console->getCommands(); // ['users list' => $users, 'roles list' => $roles]
addCommand() takes one, addCommands() takes an array. The named arguments are the constructor's
third, fourth and fifth parameters — the first two are the Application and the Console the command
works through, which is why naming them is worth the keystrokes.
A command can also be the dispatch target itself rather than a description of one. Command implements
Pop\Dispatch\DispatchableInterface, the same contract Pop\Controller\AbstractController implements,
so a route can point at a Command subclass with no action key at all. Write a handle() method with
whatever signature the route's parameters call for:
<?php
namespace App\Console\Command;
use Pop\Console\Command;
class UsersShow extends Command
{
public function __construct(...$args)
{
parent::__construct(...$args);
$this->setName('users show')
->setParams('<id>')
->setHelp('Show one user');
}
public function handle(string $id): void
{
$this->console()->write('Showing user ' . $id);
}
}
./app users show 1001
Showing user 1001
Setting the name in the constructor rather than accepting it from outside is what makes the class
self-describing, and it's the shape CLI routes requires — loadRoutes() constructs each
class with no arguments and asks it what it is called. (string)$command returns the name and params
joined, users show <id> here, which is exactly the route key.
dispatch() resolves to handle() whenever no action name is given, and always forwards the route's
parameters. A subclass without a handle() throws Pop\Dispatch\Exception with
The action to handle the route is not defined. rather than doing nothing.
A command reaches the application and the console through Pop\Dispatch\ConsoleTrait, the same trait a
console controller uses, so application(), console(), hasApplication(), hasConsole(),
setApplication() and setConsole() are all there. Console defaults to a fresh new Console(120) if
none is supplied, so hasConsole() is true from the start; Application stays null until something
gives the command one — the router does, when it dispatches it.
This is the other half of Controllers vs Commands: a Command subclass is one
class per command, where a console controller groups related actions that share setup. A command class is
also what directory scanning picks up. Either way both dispatch through the same router and reach the same
help screen.
Output: Lines, Headers and Alerts#
write() is the workhorse — one line of text, printed immediately. Around it are three shaping methods
that exist so an application does not spend its time counting dashes.
use Pop\Console\Console;
$console = new Console(40);
$console->write('Migrating the database.');
$console->line();
$console->line('=', 20);
$console->header('Hello World');
$console->header('Hello World', '=', 40, 'center');
Migrating the database.
----------------------------------------
====================
Hello World
-----------
Hello World
========================================
line() draws a horizontal rule, defaulting to - at the console's wrap width. header() writes text
with a rule under it, and takes the character, the width and an alignment. headerLeft(),
headerCenter() and headerRight() are the same thing with the alignment fixed and the size defaulted
to 'auto' — the wrap width, falling back to the terminal's own width when there is no wrap set.
Alerts are the loud option: a padded, colored block for something the user has to notice.
use Pop\Console\Console;
$console = new Console(40);
$console->alertDanger('Migration failed', 'auto');
$console->alertWarning('Two tables were skipped', 'auto');
$console->alertSuccess('Migration complete', 'auto');
$console->alertInfo('Nothing to do', 'auto');
Each renders three lines — a blank line, the centered message, a blank line — filled with the alert's
background color. alertPrimary(), alertSecondary(), alertDark() and alertLight() complete the
set. alertBox() is the colorless one, drawing a border out of characters you choose instead:
use Pop\Console\Console;
$console = new Console(40);
$console->alertBox('Boxed', '-', '|', 'auto');
----------------------------------------
| |
| Boxed |
| |
----------------------------------------
Passing null as alertBox()'s vertical character drops the side borders and leaves the two rules.
line(), header() and every alert take a final bool $return argument. Pass true and the method
hands back the rendered string instead of printing it, which is how you put one of these inside
something else — a string you are building, a value you are testing against:
use Pop\Console\Console;
$console = new Console(40);
$header = $console->header('Hello World', '-', 40, 'left', true, true);
$alert = $console->alertSuccess('Done', 'auto', 'center', 4, true, true);
The argument before $return is $newline in both, so the flags are positional and the defaults in
between have to be repeated. $size accepts the string 'auto' as well as an integer, and 'auto' is
what you want whenever the console's own width should decide.
The Response Buffer#
Console has two ways to put text on the terminal, and they are not interchangeable. write() prints
immediately. append() adds to an internal buffer that stays there until send() flushes it.
use Pop\Console\Console;
$console = new Console();
$console->setHeader('My Application');
$console->setFooter('The End');
$console->append('Here is some console information.');
$console->append('Hope you enjoyed it!');
$console->send();
My Application
Here is some console information.
Hope you enjoyed it!
The End
setHeader() and setFooter() are what the buffer buys you. send() prints the header, the buffered
content and the footer as one response, which is the right shape for a command that decides what to say
as it goes and says it at the end. Calling send() again prints nothing — the buffer is cleared on the
way out.
Reach for write() when the user needs to see progress as it happens: a long migration reporting each
step, a worker logging each job. The output goes out immediately, and nothing is held back.
A header and footer wrap every write(), so build multi-line output as one write() call when you have
them set.
Two constructor arguments control the shape of everything written either way: the wrap width and the margin.
use Pop\Console\Console;
$console = new Console(40, 2);
$console->append(
'Here is some console information. This is a really long string. It will have to wrap.'
);
$console->send();
Here is some console information. This
is a really long string. It will have to
wrap.
A bare new Console() wraps at 80 with a margin of 4; a console handed to a dispatched controller or
command is new Console(120). Passing null as the wrap width leaves it unset, at which point the
methods that take 'auto' fall back to the terminal's real width, detected at construction.
Both write() and append() add the margin at the start and a newline at the end. Two boolean flags
turn each off, which is how several calls compose one line:
use Pop\Console\Console;
$console = new Console(40);
$console->write('Here ', false); // margin, no newline
$console->write('is ', false, false); // no margin, no newline
$console->write('some ', false, false); // no margin, no newline
$console->write('content.', true, false); // no margin, newline
Here is some content.
Colors#
colorize() wraps a string in ANSI escape codes. It returns the wrapped string rather than printing it,
so a colored word sits inside an ordinary line of output:
use Pop\Console\Color;
use Pop\Console\Console;
$console = new Console();
$console->write(
'Here is some ' .
$console->colorize('IMPORTANT', Color::BOLD_RED) .
' console information.'
);
The same method is a static on Pop\Console\Color, which is what you reach for in code that has no
console object in hand — a formatter, a value object, anything below the output layer:
use Pop\Console\Color;
$important = Color::colorize('IMPORTANT', Color::BOLD_RED);
The second argument is the foreground and the third is the background, so a highlighted run of text is one call:
use Pop\Console\Color;
use Pop\Console\Console;
$console = new Console();
$console->write($console->colorize('white on red', Color::BOLD_WHITE, Color::RED));
The constants come in four families of eight — RED through WHITE plus BLACK, then BRIGHT_,
BOLD_ and BRIGHT_BOLD_ prefixed versions of each — with NORMAL making thirty-three in all.
getAvailableColors() returns the lot as a name-to-value array, which is the reliable way to enumerate
them:
use Pop\Console\Console;
$console = new Console();
$console->getAvailableColors(); // ['NORMAL' => 0, 'BLACK' => 1, 'RED' => 2, ...]
Two more helpers answer questions about the terminal rather than about the text. isColor() reports
whether the TERM environment variable indicates color support, and isWindows() reports the platform:
use Pop\Console\Color;
use Pop\Console\Console;
$console = new Console();
$label = ($console->isColor())
? $console->colorize('OK', Color::BOLD_GREEN)
: 'OK';
$console->write($label);
Gate colorized output on isColor() so output piped into a file or another program stays clean —
colorize() emits its escape codes wherever it is sent.
Tables#
table() renders headers and rows into a bordered grid, sizing every column to its widest cell.
use Pop\Console\Color;
use Pop\Console\Console;
$console = new Console();
$console->table(
['Name', 'Status'],
[
['foo', 'active'],
['brew', 'idle'],
],
'-', '|', Color::BOLD_GREEN
);
+--------+--------+
| Name | Status |
+--------+--------+
| foo | active |
| brew | idle |
+--------+--------+
The third and fourth arguments are the horizontal and vertical border characters, and the fifth and
sixth are a foreground and background color for the header row. Passing null as the vertical character
drops the column dividers and the box corners, leaving two rules around a plainer listing:
use Pop\Console\Console;
$console = new Console();
$console->table([], [['a', 'bb'], ['ccc', 'd']], '-', null);
----------
a bb
ccc d
----------
An empty headers array is what removes the header row, as it does there. The last two arguments are
$newline and $return, matching header() and the alerts — pass true for $return and you get the
grid back as a string instead of on the terminal.
Give table rows as plain lists rather than associative arrays, so the values line up under the headers.
use Pop\Console\Console;
$console = new Console();
$rows = [
['id' => 1001, 'username' => 'admin'],
['id' => 1002, 'username' => 'editor'],
];
$console->table(['id' => 'Id', 'username' => 'Username'], $rows);
+------+----------+
| Id | Username |
+------+----------+
| 1001 | admin |
| 1002 | editor |
+------+----------+
Progress Bars#
progressBar() is the one output method that does not print and return. It hands back a
Pop\Console\ProgressBar object that redraws a single terminal line in place as you advance it:
use Pop\Console\Console;
$console = new Console();
$items = range(1, 100);
$bar = $console->progressBar(count($items), 'Processing');
foreach ($items as $item) {
// do the work for $item
$bar->advance();
}
$bar->finish();
Processing [===================> ] 70% (70/100)
advance() moves the bar on by one, or by whatever step you pass. setProgress() sets an absolute
position instead, which suits work whose progress you measure rather than count — bytes written, rows
imported. Both clamp to the range, so setProgress(999) against a total of 50 lands on 50 rather than
overflowing the bar.
Call finish() when the loop ends, including when it completed on its own. It forces the bar to 100% and
prints a newline so the next write() starts on a clean line, and isFinished() stays false until you
call it.
The bar's third constructor argument is its width in characters, and three setters change its appearance:
use Pop\Console\Color;
use Pop\Console\Console;
$console = new Console();
$bar = $console->progressBar(50, 'Importing', 40);
$bar->setChars('#', '#', '.')
->setColor(Color::BOLD_CYAN);
$bar->setProgress(25);
Importing [####################....................] 50% (25/50)
setChars() takes the filled character, the leading-edge character and the empty character in that
order — passing the same character for the first two, as above, gives a bar with no arrow head.
setColor() takes a foreground and an optional background from the same Color constants as
Colors, and setMessage(), setWidth() and setIndent() change the rest after
construction. getCurrent() and getTotal() read the position back.
A progress bar redraws its line with control characters, so reach for it when the destination is a terminal and print plain lines when it might not be.
Prompts#
prompt() writes a question and returns what the user typed:
use Pop\Console\Console;
$console = new Console();
$name = $console->prompt('Please provide your name: ', null, true);
$console->write('Hello ' . $name . '!');
Please provide your name: Nick
Hello Nick!
prompt()'s third argument is $caseSensitive and defaults to false, which lowercases the answer.
Pass true when you want it back as the user typed it.
The second argument constrains the answer to a set. The prompt then re-asks until it gets one of them, so the value you get back is always valid and needs no checking:
use Pop\Console\Console;
$console = new Console();
$letter = $console->prompt(
'Which is your favorite letter: A, B, C, or D? ',
['A', 'B', 'C', 'D'],
true
);
$console->write('Your favorite letter is ' . $letter . '.');
Which is your favorite letter: A, B, C, or D? x
Which is your favorite letter: A, B, C, or D? B
Your favorite letter is B.
promptMulti() is the same idea for more than one answer. The user types a comma-separated list and it
returns an array of the values that matched:
use Pop\Console\Console;
$console = new Console();
$types = $console->promptMulti('Select one or more, comma-separated: ', ['1', '2', '3']);
Typing 1,3 returns ['1', '3'].
confirm() is the shorthand for a yes/no gate. It asks, and on a N it ends the process rather than
returning:
use Pop\Console\Console;
$console = new Console();
$console->confirm('Drop every table?');
$console->write('The user said yes.');
The exit status on N is 127, which is what a shell script wrapping the command should test. Pass
false as confirm()'s sixth argument to have it return the answer instead of exiting, when you want
to handle a refusal yourself.
Testing a Command that Prompts#
prompt() and confirm() read php://stdin, which makes any command that asks a question untestable
by default. setInputStream() swaps that for a stream you control — one line per expected answer, read
exactly the way real input is:
use Pop\Console\Console;
$stream = fopen('php://memory', 'r+');
fwrite($stream, 'Nick' . PHP_EOL);
rewind($stream);
$console = new Console();
$console->setInputStream($stream);
$name = $console->prompt('Please provide your name: ', null, true);
$name is 'Nick' with no terminal involved. hasInputStream() and getInputStream() read the
injected stream back, and setInputStream() throws Pop\Console\Exception when handed anything that
is not a resource. A stream that runs out mid-run does not hang: a prompt reading end-of-file returns
what it has rather than looping forever on input that will never arrive.
Help Screens#
The console builds the help screen from the commands registered with it. Register them by hand and
help() prints the list:
use Pop\Console\Command;
use Pop\Console\Console;
$console = new Console();
$console->addCommands([
new Command(name: 'db:migrate', help: 'Migrate the database'),
new Command(name: 'db:seed', help: 'Seed the database'),
new Command(name: 'user:list', help: 'List users'),
]);
$console->help();
db:migrate Migrate the database
db:seed Seed the database
user:list List users
Passing a command name returns that command's help string instead of printing anything, which is what a
help <command> route wants: $console->help('db:seed') gives back 'Seed the database'.
An application does not usually register its commands twice, though — they are already in the route
table. addCommandsFromRoutes() reads them straight out of the CLI route match, taking each route's
help value as the description and prefixing every line with the script name you pass:
use Pop\Console\Console;
$console = new Console();
$console->addCommandsFromRoutes($routeMatch, './app');
$console->help();
Those are the two lines inside the help() action of the ConsoleController under
A CLI application in Pop, where $routeMatch is
$this->application->router()->getRouteMatch(). The screen it produces for that application's routes:
./app users show <id> Show one user
./app users list [--limit=] List the users
./app help
A route with no help key is not necessarily undescribed. When its controller is a Command subclass,
the class is constructed and its own getHelp() is used — which is why users show above carries a
description its route never set. A route with neither leaves the description blank, as help does
there.
Two arguments narrow or plain-text the output, and they are the same two on every method that renders
help. help(?string $command, bool $raw, ?string $subCommand) takes them second and third;
displayHelp(bool $raw, ?string $subCommand) takes them first and second.
use Pop\Console\Console;
$console = new Console();
$console->help(null, false, 'db'); // only commands under 'db'
$console->help(null, true); // no ANSI codes
db:migrate Migrate the database
db:seed Seed the database
The trailing colon on a namespace is optional — 'db' and 'db:' filter identically — and matching is
a plain prefix test against each command's own name with any registered script name stripped off first.
That makes it work for space-separated names too, so 'users' matches users list and users show. A
filter matching nothing renders an empty list rather than throwing.
displayHelp() is the version that goes through the response buffer, so it picks up setHeader() and
setFooter() the way The response buffer describes:
use Pop\Console\Console;
$console = new Console();
$console->setHeader('My Application');
$console->setFooter('The End');
$console->displayHelp();
Finally, setHelpColors() takes up to four colors and uses them to break each command line into its
parts — the first word of the command name, the second word, then the parameters:
use Pop\Console\Color;
use Pop\Console\Console;
$console = new Console();
$console->setHelpColors(Color::BOLD_CYAN, Color::BOLD_GREEN, Color::BOLD_MAGENTA);
Against users list [-l|--limit=] that colors users cyan, list green and [-l|--limit=] magenta;
against a single-word command like db:seed the second color goes unused and the parameters still take
the third. Two lines in a base controller's constructor — the colors and
addCommandsFromRoutes() — are the whole of what produces Kettle's own help screen.
pop-console has more surface than this page covers — the Console utilities, and every argument on
every render method — see the pop-console README.
See Also#
- Routing — the full CLI route syntax, and how command patterns are matched
- Controllers —
ConsoleTrait, action resolution and dispatch precedence - Applications & Bootstrap — what
load()wires up before a command runs - Kettle — registering a command with
kettleinstead of a script of your own - Queues & Scheduled Tasks — running a console command from a queued job
- pop-console README — the full console API surface