Arguments, Options and Output
Arguments and options arrive through InputInterface, output goes through OutputInterface, and the return value is the exit code.
$this->addOption(string $name, string|array|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null): staticA command needs to be told what to act on, and a deploy script needs to know whether it worked. Both go through the objects handed to execute().
Input
An argument is positional: bin/magento soundscode:report:summary default. An option is named: --limit=5 or -l 5. Declare an argument with addArgument($name, $mode, $description, $default), where $mode is InputArgument::REQUIRED, OPTIONAL or IS_ARRAY. Declare an option with addOption($name, $shortcut, $mode, $description, $default), where $mode is one of InputOption::VALUE_NONE (a flag), VALUE_REQUIRED, VALUE_OPTIONAL, VALUE_IS_ARRAY or VALUE_NEGATABLE. Read them back with $input->getArgument() and $input->getOption(). A value arrives as a string, an IS_ARRAY input as an array of strings, and a VALUE_NONE flag as a boolean that defaults to false, so cast anything numeric yourself.
Output
Wrap text in <info>, <comment> or <error> for colour. For rows, new Table($output) is what core commands such as indexer:status use. For long work, inject Symfony\Component\Console\Helper\ProgressBarFactory and create a bar with ['output' => $output, 'max' => $total].
Exit codes
Return Cli::RETURN_SUCCESS (0) or Cli::RETURN_FAILURE (1) from Magento\Framework\Console\Cli. A shell script reads that as $?. Printing the word error and returning 0 makes a broken step look like a passing one.
Shorthand and longhand
addOption(...) builds an InputOption and appends it to the definition. Passing setDefinition([new InputOption(...)]) does the same thing, with the same constructor arguments in the same order. Core uses the longhand; both are equivalent.
Example
<?php
// app/code/Soundscode/Report/Console/Command/SummaryCommand.php
declare(strict_types=1);
namespace Soundscode\Report\Console\Command;
use Magento\Framework\Console\Cli;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
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: 'soundscode:report:summary', description: 'Prints a daily order count')]
class SummaryCommand extends Command
{
protected function configure(): void
{
$this->addArgument('store', InputArgument::REQUIRED, 'Store code');
$this->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Rows to print', '2');
$this->addOption('csv', null, InputOption::VALUE_NONE, 'Print CSV instead of a table');
parent::configure();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$limit = (int) $input->getOption('limit');
if ($limit < 1) {
$output->writeln('<error>--limit must be 1 or more</error>');
return Cli::RETURN_FAILURE;
}
$rows = array_slice([['2026-10-01', '12'], ['2026-10-02', '31']], 0, $limit);
if ($input->getOption('csv')) {
foreach ($rows as $row) {
$output->writeln(implode(',', $row));
}
return Cli::RETURN_SUCCESS;
}
$table = new Table($output);
$table->setHeaders(['Date', (string) $input->getArgument('store')]);
$table->addRows($rows);
$table->render();
return Cli::RETURN_SUCCESS;
}
}
// $ bin/magento soundscode:report:summary default --csv
// 2026-10-01,12
// 2026-10-02,31
//
// $ bin/magento soundscode:report:summary default --limit=0; echo $?
// --limit must be 1 or more
// 1When to use it
- Accept a store code as a required argument so the command refuses to run against the wrong store.
- Add a --format=csv flag so the same command feeds both a human and a spreadsheet.
- Return a non-zero exit code so a CI step fails when the import found bad rows.
More examples
The definition written longhand
The same three inputs, declared the way core commands declare them. addArgument() and addOption() are convenience wrappers that construct these exact objects, so the arguments line up one for one. Reach for the longhand when a base class builds part of the definition and subclasses add to it, which is how Magento's indexer commands share their --help text. Note that setDefinition() replaces the whole definition rather than adding to it, so a subclass must merge the parent's list rather than call it twice.
<?php
// app/code/Soundscode/Report/Console/Command/SummaryCommand.php
declare(strict_types=1);
namespace Soundscode\Report\Console\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
class SummaryCommand extends Command
{
protected function configure(): void
{
$this->setName('soundscode:report:summary')
->setDescription('Prints a daily order count')
->setDefinition($this->getOptionsList());
parent::configure();
}
private function getOptionsList(): array
{
return [
new InputArgument('store', InputArgument::REQUIRED, 'Store code'),
new InputOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Rows to print', '2'),
new InputOption('csv', null, InputOption::VALUE_NONE, 'Print CSV instead of a table'),
];
}
}A progress bar for work that takes minutes
ProgressBarFactory is a factory Magento generates for the Symfony ProgressBar class, which is why the constructor arguments arrive as an array keyed by parameter name. Injecting the factory rather than writing new ProgressBar($output, $max) keeps the class testable and is the pattern catalog:images:resize uses. The setOverwrite(false) guard matters in practice: a bar that rewrites its own line turns into thousands of junk lines when the output is a log file or when someone passes -v, so switch overwriting off whenever verbosity is not normal.
<?php
// app/code/Soundscode/Report/Console/Command/RebuildCommand.php
declare(strict_types=1);
namespace Soundscode\Report\Console\Command;
use Magento\Framework\Console\Cli;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Helper\ProgressBarFactory;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RebuildCommand extends Command
{
public function __construct(
private readonly ProgressBarFactory $progressBarFactory
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$items = ['a', 'b', 'c'];
/** @var ProgressBar $progress */
$progress = $this->progressBarFactory->create(
['output' => $output, 'max' => count($items)]
);
$progress->setFormat('%current%/%max% [%bar%] %percent:3s%% | <info>%message%</info>');
if ($output->getVerbosity() !== OutputInterface::VERBOSITY_NORMAL) {
$progress->setOverwrite(false);
}
foreach ($items as $item) {
$progress->setMessage($item);
$progress->advance();
}
$output->write(PHP_EOL);
return Cli::RETURN_SUCCESS;
}
}
Discussion