DEV Community

Cover image for Symfony 7.4: The Ultimate Guide to Modern Invokable Commands
Matt Mochalkin
Matt Mochalkin

Posted on

Symfony 7.4: The Ultimate Guide to Modern Invokable Commands

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:

  1. PHP 8.2 or higher (PHP 8.4 recommended for better syntax support).
  2. 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
Enter fullscreen mode Exit fullscreen mode

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"
    }
}
Enter fullscreen mode Exit fullscreen mode

Run the update command:

composer update symfony/*
Enter fullscreen mode Exit fullscreen mode

Verify your version:

php bin/console --version
# Output should look like: Symfony 7.4.x (env: dev, debug: true)
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

Try running the command with an invalid value:

php bin/console app:create-server mars
Enter fullscreen mode Exit fullscreen mode

Output:

[ERROR] The value "mars" is not allowed for argument "region".
         Allowed values are: "us-east-1", "eu-central-1", "ap-northeast-1".
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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', 
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run your tests:

php bin/phpunit tests/Command/DeployServerCommandTest.php
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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. Subscribe to the newsletter for more deep dives into Symfony’s ecosystem.

Happy Coding!

Top comments (0)