Arguments, Options and Output
How a command declares arguments and options, and how SymfonyStyle turns writeln calls into readable tables, lists and progress bars.
#[Option(description: 'Rows to show', shortcut: 'l')] int $limit = 5Commands take input in two shapes. An argument is positional: app:text:stats README.md. An option is named and may appear in any order: --min-lines=20, or -m 20 if you gave it a shortcut. Options are for things with a sensible default; arguments are for the thing the command acts on.
Declaring input
The short form puts #[Argument] or #[Option] on a parameter of __invoke(), and Symfony fills the parameter in. Both attributes were introduced in Symfony 7.3. The long form is configure() with addArgument() and addOption(), reading values back through $input. They build the same definition.
With attributes, the mode comes from the PHP type and default. string $name is a required argument; string $name = '' or ?string $name = null is optional; array $names = [] accepts several values. For options, bool $x = false is a plain flag, bool $x = true and ?bool $x = null are negatable so --no-x also works, and anything else, such as int $limit = 10, takes a value. Every option needs a default.
Output
OutputInterface gives you write() and writeln(). SymfonyStyle wraps it and adds spacing and colour: title(), section(), text(), listing(), table(), success(), warning(), error(), and progressIterate() for looping over a collection. Send anything that is not the command's real result to $io->getErrorStyle(), which writes to stderr, so a caller can pipe the useful output somewhere.
Example
<?php
// src/Command/TextStatsCommand.php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:text:stats', description: 'Counts the lines in one or more text files.')]
class TextStatsCommand
{
public function __invoke(
SymfonyStyle $io,
#[Argument('One or more text files')]
array $files,
#[Option(description: 'Skip files with fewer lines than this', shortcut: 'm')]
int $minLines = 0,
#[Option(description: 'Append a totals row')]
bool $totals = true, // default true, so --no-totals exists too
): int {
if ([] === $files) {
$io->error('Pass at least one file.');
return Command::INVALID;
}
$rows = [];
$sum = 0;
foreach ($io->progressIterate($files) as $file) {
if (!is_readable($file)) {
// not the result, so it goes to stderr
$io->getErrorStyle()->warning(sprintf('Cannot read %s', $file));
continue;
}
$lines = substr_count((string) file_get_contents($file), "\n");
if ($lines < $minLines) {
continue;
}
$rows[] = [$file, $lines];
$sum += $lines;
}
if ($totals) {
$rows[] = new TableSeparator();
$rows[] = ['Total', $sum];
}
$io->table(['File', 'Lines'], $rows);
return Command::SUCCESS;
}
}
// $ php bin/console app:text:stats README.md CHANGELOG.md -m 10
// 2/2 [============================] 100%
//
// -------------- -------
// File Lines
// -------------- -------
// README.md 42
// CHANGELOG.md 18
// -------------- -------
// Total 60
// -------------- -------When to use it
- Give a maintenance command a --limit option so you can try it on ten records first.
- Print an import summary as a table instead of a wall of writeln calls.
- Show progress on a job that takes minutes, so nobody kills it thinking it hung.
More examples
The same input, declared the long way
Read this next to the primary example: the two declare an identical command. The attribute form infers from types, the `configure()` form states the mode by hand. Two things are easier to see in the long form. `VALUE_REQUIRED` describes the value, not the option, so an option declared that way is still optional. And `$input->getOption()` returns strings, because that is all a command line carries, which is why the attribute form's typed `int $minLines` is worth having.
<?php
// src/Command/TextStatsCommand.php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:text:stats')]
class TextStatsCommand extends Command
{
protected function configure(): void
{
$this
// IS_ARRAY matches `array $files` in the attribute version
->addArgument('files', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'One or more text files')
// VALUE_REQUIRED means the option needs a value, not that the option is mandatory
->addOption('min-lines', 'm', InputOption::VALUE_REQUIRED, 'Skip shorter files', 0)
// NEGATABLE is what `bool $totals = true` produces
->addOption('totals', null, InputOption::VALUE_NEGATABLE, 'Append a totals row', true);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$files = $input->getArgument('files'); // array
$minLines = (int) $input->getOption('min-lines'); // always a string from the CLI
$totals = (bool) $input->getOption('totals');
// ... same work as the attribute version
return Command::SUCCESS;
}
}Asking the user, and not asking when nobody is there
`ask()`, `askHidden()`, `confirm()` and `choice()` cover almost every prompt. The important detail is what happens under `--no-interaction` (`-n`), which cron and CI should always pass: nothing is asked and every prompt returns its default. Defaulting `confirm()` to `false` means an unattended run declines instead of deleting, and the explicit `--force` option gives the caller a way to say yes in writing.
<?php
// src/Command/PurgeCacheCommand.php
declare(strict_types=1);
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:cache:purge', description: 'Deletes generated files.')]
class PurgeCacheCommand
{
public function __invoke(
SymfonyStyle $io,
#[Option(description: 'Delete without asking')]
bool $force = false,
): int {
$io->title('Purge generated files');
$scope = $io->choice('What should be removed?', ['thumbnails', 'exports', 'everything'], 'thumbnails');
// confirm() returns the default when interaction is disabled with -n,
// so pass false as the default for anything destructive
if (!$force && !$io->confirm(sprintf('Remove all %s?', $scope), false)) {
$io->warning('Nothing was removed.');
return Command::SUCCESS;
}
// ... delete the files
$io->success(sprintf('Removed all %s.', $scope));
return Command::SUCCESS;
}
}
// $ php bin/console app:cache:purge
//
// Purge generated files
// =====================
//
// What should be removed? [thumbnails]:
// [0] thumbnails
// [1] exports
// [2] everything
// > 2
//
// Remove all everything? (yes/no) [no]:
// > yes
//
// [OK] Removed all everything.Progress over work of unknown length
`progressIterate()` is shorthand for the `progressStart()`, `progressAdvance()`, `progressFinish()` sequence below it, and it also closes the bar correctly when the loop breaks early. Use the manual form only when you cannot express the work as a single `foreach`. A progress bar redraws the line it is on, so do not `writeln()` while one is running: the output interleaves and the bar is left half-drawn. Collect messages and print them after `progressFinish()`.
<?php
// src/Command/ThumbnailCommand.php (fragment)
// progressIterate() when you have a countable collection
foreach ($io->progressIterate($paths) as $path) {
// ... resize $path
}
// the manual trio when the total is computed as you go
$io->progressStart(count($paths));
foreach ($paths as $path) {
// ... resize $path
$io->progressAdvance();
}
$io->progressFinish();
// no total known: start with no argument and you get a spinner-style bar
$io->progressStart();
while (null !== $job = $queue->pop()) {
// ... handle $job
$io->progressAdvance();
}
$io->progressFinish();
Discussion