Symfony 7.4 makes Console commands expressive and type-safe.Symfony 7.4 makes Console commands expressive and type-safe.

Write Symfony Commands Like You Write Controllers—Finally

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 EnumsInput 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:

  1. Read the input string.
  2. Try to map it to the Enum’s backing value.
  3. 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

  1. Reusability: You can reuse the ServerInput DTO in other services or even controllers if mapped correctly.
  2. Readability: The command logic is separated from the configuration of arguments and options.
  3. 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 — EnumsDTOs 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:

  1. Separation of Concerns: The ReportInput DTO handles data structure. The Service handles logic. The Command handles the CLI interface.
  2. 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.
  3. 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:

  1. Stop parsing, start declaring: Use #[MapInput] and Enums to let Symfony handle data hydration and validation.
  2. Clean up your signatures: Move long lists of arguments into DTOs.
  3. 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] (https://www.linkedin.com/in/matthew-mochalkin/). Subscribe to the newsletter for more deep dives into Symfony’s ecosystem.

Happy Coding!

\

Market Opportunity
Wink Logo
Wink Price(LIKE)
$0,003104
$0,003104$0,003104
+1,40%
USD
Wink (LIKE) Live Price Chart
Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact service@support.mexc.com for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.

You May Also Like

Whales Dump 200 Million XRP in Just 2 Weeks – Is XRP’s Price on the Verge of Collapse?

Whales Dump 200 Million XRP in Just 2 Weeks – Is XRP’s Price on the Verge of Collapse?

Whales offload 200 million XRP leaving market uncertainty behind. XRP faces potential collapse as whales drive major price shifts. Is XRP’s future in danger after massive sell-off by whales? XRP’s price has been under intense pressure recently as whales reportedly offloaded a staggering 200 million XRP over the past two weeks. This massive sell-off has raised alarms across the cryptocurrency community, as many wonder if the market is on the brink of collapse or just undergoing a temporary correction. According to crypto analyst Ali (@ali_charts), this surge in whale activity correlates directly with the price fluctuations seen in the past few weeks. XRP experienced a sharp spike in late July and early August, but the price quickly reversed as whales began to sell their holdings in large quantities. The increased volume during this period highlights the intensity of the sell-off, leaving many traders to question the future of XRP’s value. Whales have offloaded around 200 million $XRP in the last two weeks! pic.twitter.com/MiSQPpDwZM — Ali (@ali_charts) September 17, 2025 Also Read: Shiba Inu’s Price Is at a Tipping Point: Will It Break or Crash Soon? Can XRP Recover or Is a Bigger Decline Ahead? As the market absorbs the effects of the whale offload, technical indicators suggest that XRP may be facing a period of consolidation. The Relative Strength Index (RSI), currently sitting at 53.05, signals a neutral market stance, indicating that XRP could move in either direction. This leaves traders uncertain whether the XRP will break above its current resistance levels or continue to fall as more whales sell off their holdings. Source: Tradingview Additionally, the Bollinger Bands, suggest that XRP is nearing the upper limits of its range. This often points to a potential slowdown or pullback in price, further raising concerns about the future direction of the XRP. With the price currently around $3.02, many are questioning whether XRP can regain its footing or if it will continue to decline. The Aftermath of Whale Activity: Is XRP’s Future in Danger? Despite the large sell-off, XRP is not yet showing signs of total collapse. However, the market remains fragile, and the price is likely to remain volatile in the coming days. With whales continuing to influence price movements, many investors are watching closely to see if this trend will reverse or intensify. The coming weeks will be critical for determining whether XRP can stabilize or face further declines. The combination of whale offloading and technical indicators suggest that XRP’s price is at a crossroads. Traders and investors alike are waiting for clear signals to determine if the XRP will bounce back or continue its downward trajectory. Also Read: Metaplanet’s Bold Move: $15M U.S. Subsidiary to Supercharge Bitcoin Strategy The post Whales Dump 200 Million XRP in Just 2 Weeks – Is XRP’s Price on the Verge of Collapse? appeared first on 36Crypto.
Share
Coinstats2025/09/17 23:42
Yiyitong: The company's cross-border payment and domestic supply and sales services are not currently involved in digital RMB payment scenarios.

Yiyitong: The company's cross-border payment and domestic supply and sales services are not currently involved in digital RMB payment scenarios.

PANews, January 5th - According to Zhitong Finance, Yiyitong stated on its interactive platform today that its cross-border payment and domestic supply and sales
Share
PANews2026/01/05 09:28
Bitcoin at Crucial Pivot Point, Here's Why Fed Can Tilt Balance

Bitcoin at Crucial Pivot Point, Here's Why Fed Can Tilt Balance

Bitcoin volatility might peak as market awaits major FOMC rate cut decision
Share
Coinstats2025/09/17 23:28