Type-Safe Commands with Attributes
Symfony Console commands are often the first helpers that touch production data directly. They deserve the same care as HTTP controllers: type-safe input handling, clear output structure, meaningful exit codes and full testability.
Table of Contents
- 1. Why type-safe console commands matter
- 2. #[AsCommand]: declarative command naming
- 3. Declaring arguments and options type-safely
- 4. Reading input and structuring output
- 5. Validating input and using exit codes correctly
- 6. Progress bars and tables for long operations
- 7. Interactive commands with confirmations and prompts
- 8. Testing console commands completely
- 9. Command patterns compared
- 10. Summary
- 11. FAQ
1. Why type-safe console commands matter
A Symfony Console command reaches directly into the production database, sends mass emails, or imports thousands of records. A mistake in input handling, a misinterpreted option, an overlooked required argument, or a wrong exit code, can lead to data loss or behavior that is hard to diagnose. Unlike HTTP requests, there is no browser to show a readable error and no HTTP status code that monitoring systems can query. Symfony Console offers everything needed for robust CLI tools, it is just rarely used consistently.
Type-safe input handling in Symfony Console means: declare arguments and options explicitly with types, validate input in the initialize() hook before the actual logic starts, use exit codes consistently (0 for success, 1 for failure, 2 for command misuse), and use output sections for clean, structured output. These practices make commands testable, a command that clearly declares its inputs and writes output through SymfonyStyle can be tested completely in isolation with CommandTester, without ever opening a terminal.
2. #[AsCommand]: declarative command naming
The #[AsCommand] attribute in Symfony Console has been the recommended way to register commands since Symfony 5.3. It replaces the protected static $defaultName property and the configuration inside configure(). The attribute-based approach is cleaner and can be read directly by the service container without instantiating the command, which improves the performance of bin/console list because command classes no longer need to be instantiated just to determine their names. With aliases you can define short forms, and hidden: true hides a command from the command list without disabling it.
The description argument of the #[AsCommand] attribute is shown directly in the Symfony Console help output. It should describe the purpose of the command briefly and precisely, no trailing period, at most one line. A complete, multi-line description with usage examples belongs in the $this->setHelp() call inside configure(). The namespace in command names, the part before the colon, so app in app:user:sync, groups related commands in the output of bin/console list app. That improves discoverability in projects with many commands.
<?php
declare(strict_types=1);
namespace App\Command;
use App\Service\UserSyncService;
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;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Synchronizes users from an external CRM system into the local database.
* Supports dry-run mode and batch size configuration.
*/
#[AsCommand(
name: 'app:user:sync',
description: 'Sync users from external CRM to local database',
aliases: ['app:sync-users'],
hidden: false,
)]
final class UserSyncCommand extends Command
{
// Inject services via constructor, Command is a regular Symfony service
public function __construct(
private readonly UserSyncService $syncService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('source', InputArgument::REQUIRED, 'CRM source identifier (e.g. crm-de, crm-at)')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulate without writing to database')
->addOption('batch-size', 'b', InputOption::VALUE_REQUIRED, 'Records per batch', 100)
->addOption('since', null, InputOption::VALUE_OPTIONAL, 'Sync records changed since date (Y-m-d)')
->setHelp(<<<'HELP'
The <info>app:user:sync</info> command synchronizes users from the configured CRM.
<info>php bin/console app:user:sync crm-de</info>
<info>php bin/console app:user:sync crm-de --dry-run</info>
<info>php bin/console app:user:sync crm-de --batch-size=50 --since=2026-01-01</info>
HELP
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// ... implementation
return Command::SUCCESS;
}
}
3. Declaring arguments and options type-safely
Symfony Console distinguishes between arguments and options. Arguments are positional required or optional values (command arg1 arg2), options are named parameters with or without a value (--option or --option=value). Arguments are declared with addArgument(), with a mode (REQUIRED, OPTIONAL, IS_ARRAY) and a description. Options are declared with addOption(), with a short form (-b), a mode (VALUE_NONE, VALUE_REQUIRED, VALUE_OPTIONAL, VALUE_IS_ARRAY), a description and a default value.
The challenge with Symfony Console input is the lack of typing: $input->getArgument('batch-size') always returns a string, even when an integer is expected. The correct pattern: read all inputs in the initialize() hook, convert them to the proper PHP types and store them as class properties. The execute() method then works exclusively with the typed properties, not with the raw InputInterface object. That makes typing explicit and makes tests simpler, because you can check properties directly.
4. Reading input and structuring output
SymfonyStyle is the most important output abstraction in Symfony Console. It provides structured methods for all common output types: $io->title() for the command heading, $io->section() for subheadings, $io->success() for green success messages, $io->warning() for yellow warnings, $io->error() for red error messages. Listing data is printed with $io->listing(), tabular data with $io->table(['Column 1', 'Column 2'], $rows). These methods automatically adapt their output to the verbosity level.
The verbosity system in Symfony Console enables multi-level output. -v enables VERBOSITY_VERBOSE, -vv enables VERBOSITY_VERY_VERBOSE and -vvv enables VERBOSITY_DEBUG. In a command you check $output->isVerbose() and $output->isVeryVerbose() before printing detailed debugging information. The default output stays clean and minimal, developers and monitoring systems can pull more detail with -v without changing the command code. This is the CLI equivalent of log levels in HTTP applications.
<?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;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:user:sync', description: 'Sync users from external CRM')]
final class UserSyncCommand extends Command
{
// Typed properties, populated in initialize(), used in execute()
private string $source = '';
private bool $isDryRun = false;
private int $batchSize = 100;
private ?\DateTimeImmutable $since = null;
protected function configure(): void
{
$this
->addArgument('source', InputArgument::REQUIRED, 'CRM source identifier')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulate without writing')
->addOption('batch-size', 'b', InputOption::VALUE_REQUIRED, 'Records per batch', '100')
->addOption('since', null, InputOption::VALUE_OPTIONAL, 'Changed since date (Y-m-d)');
}
protected function initialize(InputInterface $input, OutputInterface $output): void
{
// Read and cast all inputs here, execute() only uses typed properties
$this->source = (string) $input->getArgument('source');
$this->isDryRun = (bool) $input->getOption('dry-run');
$this->batchSize = (int) $input->getOption('batch-size');
$sinceStr = $input->getOption('since');
if ($sinceStr !== null) {
$this->since = new \DateTimeImmutable($sinceStr);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title(sprintf('User Sync: %s', $this->source));
if ($this->isDryRun) {
$io->warning('DRY-RUN mode active, no changes will be written to the database.');
}
// Verbose output: only shown with -v flag
if ($output->isVerbose()) {
$io->comment(sprintf('Batch size: %d | Since: %s',
$this->batchSize,
$this->since?->format('Y-m-d') ?? 'all time'
));
}
$io->success('Sync completed successfully.');
return Command::SUCCESS;
}
}
5. Validating input and using exit codes correctly
Validation of command input should happen in the interact() or initialize() hook, not in execute(). The initialize() hook is well suited for programmatic validation: checking whether a file exists, whether a database connection parameter is valid, whether an enum value is correct. On validation failure, throw an \InvalidArgumentException, which Symfony Console catches and prints as an error message in the correct red error format. This prevents execute() from starting with invalid input and failing midway through a database operation.
Exit codes are the return-channel system for shell scripting and monitoring. Symfony Console defines three constants: Command::SUCCESS (0) for a successful run, Command::FAILURE (1) for an error during execution, Command::INVALID (2) for incorrect arguments or configuration. CI/CD pipelines and monitoring systems evaluate exit codes, a command that always returns 0 on failure hides problems. Cron monitoring tools such as Cronitor or Healthchecks.io read exit codes and alert on non-zero returns.
6. Progress bars and tables for long operations
Long-running Symfony Console commands without feedback appear frozen. The SymfonyStyle progress bar offers a simple API: $io->progressStart($total) initializes the bar, $io->progressAdvance() increments it, $io->progressFinish() finishes it cleanly. For more complex customization, colors, placeholders, multi-stage progress, you use the ProgressBar class directly, which gives access to format strings and lets you set arbitrary step sizes.
Tables in Symfony Console format structured data clearly. $io->table(['ID', 'Name', 'Status'], $rows) automatically produces column-width table borders. For very large data sets, tables are unsuitable, structured single-line output with verbosity control is enough there. The difference between a good and a bad CLI experience is often in these details: an informative summary after processing ($io->info("Processed 1.247 records in 3.2s")), a progress bar for long batch jobs, and a clear error message with the exact record that failed.
7. Interactive commands with confirmations and prompts
Destructive Symfony Console commands, database truncates, mass deletes, production data modifications, should require confirmation before they run. $io->confirm('Really delete all 5,000 records?', false) returns a boolean: the second parameter is the default value used on plain Enter or with --no-interaction. With the --no-interaction flag, commands run non-interactively in CI/CD pipelines where no keyboard input is possible, the default value decides automatically.
Interactive selection from a list is provided by $io->choice('Which environment?', ['dev', 'staging', 'prod'], 'dev'). Free-text input with validation is possible with $io->ask('Email address?', null, function(string $answer) { ... }), the third parameter is a validation closure that throws an exception on invalid input and repeats the prompt. Symfony Console distinguishes between ask() for free text, askHidden() for passwords (no echo in the terminal) and choice() for selection lists. Interactive commands should always remain meaningfully configurable with --no-interaction as well.
8. Testing console commands completely
The CommandTester from symfony/console is the tool for isolated Symfony Console tests. It simulates input and captures output without a terminal. $commandTester->execute(['argument' => 'value', '--option' => 'value']) runs the command programmatically. The exit code is available via $commandTester->getStatusCode(), the output via $commandTester->getDisplay(). This enables precise assertions: assertStringContainsString('Sync completed', $commandTester->getDisplay()) and assertSame(Command::SUCCESS, $commandTester->getStatusCode()).
In Symfony integration tests, you fetch the command from the DI container via $this->getContainer()->get(UserSyncCommand::class), the command is a fully instantiated service with all its dependencies. This enables tests that run against the real database or against mock services. For unit tests you mock all dependencies and test only the command logic. A proven rule of thumb: one integration test for the happy path, unit tests for error and edge cases. Symfony Console commands are often the last untested part of PHP projects, even though, thanks to CommandTester, they are just as easy to test as HTTP controllers.
<?php
declare(strict_types=1);
namespace App\Tests\Command;
use App\Command\UserSyncCommand;
use App\Service\UserSyncService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Unit tests for UserSyncCommand, no database, no external services.
*/
final class UserSyncCommandTest extends TestCase
{
private MockObject&UserSyncService $syncService;
private CommandTester $commandTester;
protected function setUp(): void
{
// Mock all external dependencies for unit testing
$this->syncService = $this->createMock(UserSyncService::class);
$command = new UserSyncCommand($this->syncService);
$this->commandTester = new CommandTester($command);
}
public function testSuccessfulSync(): void
{
$this->syncService
->expects($this->once())
->method('sync')
->with('crm-de', false, 100);
$exitCode = $this->commandTester->execute([
'source' => 'crm-de',
]);
self::assertSame(Command::SUCCESS, $exitCode);
self::assertStringContainsString('Sync completed', $this->commandTester->getDisplay());
}
public function testDryRunDoesNotPersist(): void
{
$this->syncService
->expects($this->once())
->method('sync')
->with('crm-de', true, 50); // isDryRun = true
$this->commandTester->execute([
'source' => 'crm-de',
'--dry-run' => true,
'--batch-size' => '50',
]);
self::assertStringContainsString('DRY-RUN', $this->commandTester->getDisplay());
}
public function testInvalidBatchSizeReturnsFailure(): void
{
$exitCode = $this->commandTester->execute([
'source' => 'crm-de',
'--batch-size' => '-1', // invalid negative batch size
]);
self::assertSame(Command::FAILURE, $exitCode);
}
}
| Pattern | Wrong | Right | Reason |
|---|---|---|---|
| Command name | static $defaultName |
#[AsCommand] |
No instantiation needed for the list |
| Reading input | In execute() with casts | In initialize() as property | Type safety, testability |
| Signaling errors | return 0 + echo |
Command::FAILURE |
Monitoring, shell scripting |
| Destructive ops | Run directly | $io->confirm() |
Confirmation guards against accidents |
| Output | echo directly |
SymfonyStyle |
Structured, testable, verbosity-aware |
9. Command patterns compared
The table shows common anti-patterns in Symfony Console commands and their correct counterparts. The most important point: commands are services and benefit from dependency injection. A command that reaches directly for new SomeService() or calls static methods is not testable. Commands whose dependencies are injected through the constructor are testable in isolation and benefit from Symfony's service container management.
A frequently overlooked detail: Symfony Console commands are often run via cron in production. That means the command must be idempotent, running it multiple times with the same parameters must not create duplicates or an inconsistent state. Idempotency can be achieved through upserts instead of inserts, checksum comparisons before updates, and clear state tracking in the database. A command that reliably picks up where it left off after a restart following an interruption is a command you can trust in production too.
Mironsoft
Symfony backend development, CLI tooling and batch processing
Need robust Symfony Console commands?
We build type-safe, tested and idempotent Symfony Console commands for data migration, data import, synchronization and batch processing, with a complete test suite and monitoring integration.
CLI development
Type-safe commands with validation, progress bar and structured output
Batch processing
Idempotent import and sync commands with error handling and restart logic
Command tests
Full test suite with CommandTester for all paths and exit codes
10. Summary
Symfony Console commands with PHP attributes are precise, maintainable and fully testable. The #[AsCommand] attribute registers commands without boilerplate properties. Separating input reading (initialize()) from business logic (execute()) enforces type-safe handling. SymfonyStyle delivers structured, verbosity-driven output. Exit codes communicate success and failure to shell scripts and monitoring. CommandTester makes every path, success, validation errors, service failures, testable in isolation without a terminal or real infrastructure.
Commands that manipulate production data deserve the same quality standard as HTTP controllers. Idempotency, clear error handling, structured output and complete tests are not luxury features, they are baseline requirements for CLI tools that need to run reliably in cron jobs and CI/CD pipelines. Symfony Console provides all the necessary building blocks, consistently using these APIs is what decides whether a command becomes a reliable tool or a black box in production.
Symfony Console Commands: the key points at a glance
#[AsCommand]
Declarative command name without static $defaultName. Symfony reads the attribute without instantiation, faster bin/console list.
initialize() for input
Read, cast and store all arguments and options as typed properties in initialize(). execute() works only with properties.
Exit codes
Command::SUCCESS (0), Command::FAILURE (1), Command::INVALID (2). Monitoring and shell scripting rely on exit codes.
CommandTester
Full tests without a terminal: execute(), getStatusCode(), getDisplay(). Unit tests with mocked services, integration tests with a real container.