Arguments, Options and Output

How a command declares arguments and options, and how SymfonyStyle turns writeln calls into readable tables, lists and progress bars.

Syntax#[Option(description: 'Rows to show', shortcut: 'l')] int $limit = 5

Commands 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

Example · php
<?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.

Example · php
<?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.

Example · php
<?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()`.

Example · php
<?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

  • Be the first to comment on this lesson.