The evolution of the Symfony Console component has been a journey of consistent refinement. For years, developers grew accustomed to the ritual of extending the Command class, implementing the configure() method to define arguments and options and placing their logic inside execute(). It was robust, deterministic and verbose.
With the advent of Symfony 5 and 6, we saw the introduction of Invokable Commands — a paradigm shift that allowed us to treat commands more like controllers. The __invoke() method became the new entry point and the boilerplate of configure() began to fade, replaced partially by PHP attributes like #[AsCommand]. However, one friction point remained: the disconnect between the command’s signature and the actual input parsing. We still found ourselves manually fetching arguments via $input->getArgument(‘…’) or relying on complex configurations to map inputs to typed variables.
Symfony 7.4 changes everything
Released in November 2025, Symfony 7.4 introduces a suite of quality-of-life improvements for the Console component that effectively bridges the gap between Console Commands and Http Controllers. With native support for Backed Enums, Input DTOs via #[MapInput] and declarative interactivity with #[Interact] and #[Ask], writing CLI tools has never been this type-safe or expressive.
In this comprehensive guide, we will explore these new features in depth. We will refactor a legacy command into a modern Symfony 7.4 masterpiece, covering installation, implementation, verification and testing.
Prerequisites and Installation
Before diving into the code, ensure your environment is ready. You will need:
- PHP 8.2 or higher (PHP 8.4 recommended for better syntax support).
- Composer installed globally.
To follow along with the examples, creating a new Symfony 7.4 project or upgrading an existing one is necessary.
If you are starting fresh:
composer create-project symfony/skeleton:^7.4 my_cli_app
cd my_cli_app
composer require symfony/console:^7.4
If you are upgrading an existing project, ensure your composer.json explicitly targets the 7.4 release for the console component:
{
"require": {
"php": ">=8.2",
"symfony/console": "^7.4",
"symfony/framework-bundle": "^7.4",
"symfony/runtime": "^7.4"
}
}
Run the update command:
composer update symfony/*
Verify your version:
php bin/console --version
# Output should look like: Symfony 7.4.x (env: dev, debug: true)
Native Enum Support
The Old Way (Pre-7.4)
Previously, handling enumerated values in commands was a manual process. You would accept a string argument, valid it against a list of allowed values manually and then perhaps map it to a PHP Enum usage.
// src/Command/LegacyServerCommand.php
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\Output\OutputInterface;
#[AsCommand(name: 'app:legacy-server')]
class LegacyServerCommand extends Command
{
protected function configure(): void
{
$this->addArgument('region', InputArgument::REQUIRED, 'The server region (us, eu)');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$region = $input->getArgument('region');
if (!in_array($region, ['us', 'eu'])) {
$output->writeln('<error>Invalid region.</error>');
return Command::FAILURE;
}
// Logic here...
return Command::SUCCESS;
}
}
This works, but it leaks validation logic into the execution flow and lacks type safety.
The Symfony 7.4 Way
In Symfony 7.4, the Console component’s ArgumentResolver logic has been ported to commands. You can now type-hint arguments with Backed Enums. Symfony will automatically:
- Read the input string.
- Try to map it to the Enum’s backing value.
- Throw a descriptive error if the value is invalid, listing available options.
Let’s define our Enum first.
// src/Enum/ServerRegion.php
namespace App\Enum;
enum ServerRegion: string
{
case US = 'us-east-1';
case EU = 'eu-central-1';
case ASIA = 'ap-northeast-1';
}
Now, the command becomes incredibly simple:
// src/Command/CreateServerCommand.php
namespace App\Command;
use App\Enum\ServerRegion;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Argument; // Note: In 7.4 attributes are often simplified
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:create-server', description: 'Creates a server in a specific region.')]
class CreateServerCommand extends Command
{
// The magic happens here: Type-hinting the Enum
public function __invoke(
OutputInterface $output,
#[Argument] ServerRegion $region
): int {
$output->writeln(sprintf('Creating server in region: %s', $region->value));
return Command::SUCCESS;
}
}
Try running the command with an invalid value:
php bin/console app:create-server mars
Output:
[ERROR] The value "mars" is not allowed for argument "region".
Allowed values are: "us-east-1", "eu-central-1", "ap-northeast-1".
This validation comes “for free,” strictly enforced by the framework before your code even executes.
Input DTOs with #[MapInput]
As your commands grow, your __invoke method can become cluttered with dozens of arguments and options. This is the “Long Parameter List” code smell. Controllers solved this with #[MapRequestPayload] and now Console follows suit with #[MapInput].
This allows you to extract your command’s input definition into a dedicated Data Transfer Object (DTO).
The DTO Class
Create a plain PHP class to hold your input data. Use standard validation constraints if you have the Validator component installed!
// src/Dto/ServerInput.php
namespace App\Dto;
use App\Enum\ServerRegion;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Validator\Constraints as Assert;
class ServerInput
{
#[Argument(description: 'The region to deploy to')]
public ServerRegion $region;
#[Option(description: 'The size of the instance')]
#[Assert\Choice(['small', 'medium', 'large'])]
public string $size = 'small';
#[AsOption(name: 'dry-run', description: 'Simulate the creation')]
public bool $dryRun = false;
}
The Refactored Command
We now inject this DTO into the command using the #[MapInput] attribute.
// src/Command/DeployServerCommand.php
namespace App\Command;
use App\Dto\ServerInput;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:deploy-server')]
class DeployServerCommand extends Command
{
public function __invoke(
OutputInterface $output,
#[MapInput] ServerInput $input
): int {
if ($input->dryRun) {
$output->writeln('<info>Dry run enabled. No changes made.</info>');
}
$output->writeln(sprintf(
'Deploying %s instance to %s...',
$input->size,
$input->region->value
));
return Command::SUCCESS;
}
}
Why this matters
- Reusability: You can reuse the ServerInput DTO in other services or even controllers if mapped correctly.
- Readability: The command logic is separated from the configuration of arguments and options.
- Validation: If you use symfony/validator, the DTO is validated automatically before __invoke is called.
Declarative Interaction with #[Interact] and #[Ask]
Interactive commands are vital for good DX, but implementing the interact() method often felt like writing a second command just to fill in the blanks of the first one. Symfony 7.4 introduces attributes to handle this declaratively.
The #[Ask] Attribute
For simple cases where a missing argument should prompt the user, use #[Ask].
// src/Command/HelloCommand.php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Ask; // New in 7.4
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:hello')]
class HelloCommand extends Command
{
public function __invoke(
OutputInterface $output,
#[Argument, Ask(question: "What is your name?")]
string $name
): int {
$output->writeln("Hello, $name!");
return Command::SUCCESS;
}
}
If the user runs php bin/console app:hello, the command will pause and ask “What is your name?”. If they run php bin/console app:hello World, it skips the prompt.
The #[Interact] Attribute
For complex interactions (e.g., dynamic questions based on previous answers), you can now mark any method as an interaction handler, injecting InputInterface and StyleInterface automatically.
// src/Command/WizardCommand.php
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Interact;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:wizard')]
class WizardCommand extends Command
{
protected function configure(): void
{
$this->addArgument('password', InputArgument::REQUIRED);
}
// This method is called automatically before __invoke if arguments are missing
#[Interact]
public function promptForPassword(InputInterface $input, SymfonyStyle $io): void
{
if (null === $input->getArgument('password')) {
$password = $io->askHidden('Please enter your API password');
$input->setArgument('password', $password);
}
}
public function __invoke(OutputInterface $output, string $password): int
{
$output->writeln('Password received (hashed): ' . md5($password));
return Command::SUCCESS;
}
}
Testing Invokable Commands
Symfony 7.4 ensures that CommandTester works seamlessly with these new abstractions. Testing commands that use DTOs or Enums requires no special setup.
The Test Case
We’ll test the DeployServerCommand we created earlier.
// tests/Command/DeployServerCommandTest.php
namespace App\Tests\Command;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
class DeployServerCommandTest extends KernelTestCase
{
public function testExecuteWithDto(): void
{
$kernel = self::bootKernel();
$application = new Application($kernel);
$command = $application->find('app:deploy-server');
$commandTester = new CommandTester($command);
$commandTester->execute([
'region' => 'us-east-1', // Passing string, automatically converted to Enum
'--size' => 'large',
'--dry-run' => true,
]);
$commandTester->assertCommandIsSuccessful();
$output = $commandTester->getDisplay();
$this->assertStringContainsString('Dry run enabled', $output);
$this->assertStringContainsString('Deploying large instance to us-east-1', $output);
}
public function testInvalidEnumThrowsError(): void
{
$kernel = self::bootKernel();
$application = new Application($kernel);
$command = $application->find('app:deploy-server');
$commandTester = new CommandTester($command);
// We expect a runtime exception or validation error depending on configuration
// In Console context, this usually results in a status code 1 and error output
$this->expectException(\Throwable::class);
// Or inspect status code:
// $exitCode = $commandTester->execute(['region' => 'mars']);
// $this->assertNotSame(0, $exitCode);
$commandTester->execute([
'region' => 'mars',
]);
}
}
Run your tests:
php bin/phpunit tests/Command/DeployServerCommandTest.php
Real-World Scenario: A Complex Report Generator
Let’s combine all features — Enums, DTOs and Attributes — into a cohesive, professional-grade command. Imagine a command that generates financial reports.
The Enums
namespace App\Enum;
enum ReportFormat: string {
case PDF = 'pdf';
case CSV = 'csv';
case JSON = 'json';
}
enum ReportPeriod: string {
case DAILY = 'daily';
case WEEKLY = 'weekly';
case MONTHLY = 'monthly';
}
The Input DTO
namespace App\Dto;
use App\Enum\ReportFormat;
use App\Enum\ReportPeriod;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Ask;
class ReportInput
{
#[Argument(description: 'Type of report')]
#[Ask('What type of report would you like to generate?')]
public string $reportType;
#[Option]
public ReportPeriod $period = ReportPeriod::WEEKLY;
#[Option]
public ReportFormat $format = ReportFormat::PDF;
#[AsOption(name: 'email', description: 'Email to send report to')]
public ?string $recipientEmail = null;
}
The Service
Ideally, your logic is in a service, not the command.
namespace App\Service;
use App\Dto\ReportInput;
class ReportGenerator
{
public function generate(ReportInput $input): void
{
// ... generation logic
}
}
The Command
namespace App\Command;
use App\Dto\ReportInput;
use App\Service\ReportGenerator;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Interact;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:report:generate')]
class GenerateReportCommand extends Command
{
public function __construct(
private ReportGenerator $generator
) {
parent::__construct();
}
#[Interact]
public function interactRecipient(InputInterface $input, SymfonyStyle $io): void
{
// Only ask for email if not provided and format is PDF (business rule)
// Accessing raw input here since mapping happens later
if (null === $input->getOption('email') && 'pdf' === $input->getOption('format')) {
$email = $io->ask('Enter recipient email for the PDF');
$input->setOption('email', $email);
}
}
public function __invoke(
OutputInterface $output,
#[MapInput] ReportInput $input
): int {
$output->writeln("Starting {$input->period->value} report generation...");
$this->generator->generate($input);
$output->writeln("Report generated in {$input->format->value} format.");
if ($input->recipientEmail) {
$output->writeln("Sent to: {$input->recipientEmail}");
}
return Command::SUCCESS;
}
}
This example demonstrates the power of Symfony 7.4:
- Separation of Concerns: The ReportInput DTO handles data structure. The Service handles logic. The Command handles the CLI interface.
- Context-Aware Interactivity: The #[Interact] method allows for dynamic questions (only asking for email if PDF is selected) that pure attributes can’t easily handle.
- Type Safety: We never manually validate if $format is pdf or csv. The Enum casting guarantees it.
Conclusion
Symfony 7.4 marks a significant maturity point for the Console component. By adopting attributes and DTOs, the framework acknowledges that Console commands are first-class citizens in modern applications, deserving the same developer experience (DX) as HTTP Controllers.
Key Takeaways:
- Stop parsing, start declaring: Use #[MapInput] and Enums to let Symfony handle data hydration and validation.
- Clean up your signatures: Move long lists of arguments into DTOs.
- Embrace declarative interaction: Use #[Ask] for simple prompts and #[Interact] for complex flows without overriding the parent class logic.
These changes reduce boilerplate, increase testability and make your code significantly easier to read. If you haven’t upgraded to Symfony 7.4 yet, the improved Console component alone is a compelling reason to make the jump.
Let’s be in touch
Are you ready to modernize your CLI tools? Start by refactoring your most complex command using #[MapInput] and share your experience.
If you found this guide helpful or have questions about specific edge cases, be in touch! You can find me on LinkedIn. Subscribe to the newsletter for more deep dives into Symfony’s ecosystem.
Happy Coding!
Top comments (0)