6 min read

Less Boilerplate, More Console: Symfony 7.4's Improved Invokable Commands

Symfony 7.4 upgrades invokable console commands with enum arguments, a MapInput DTO attribute, and new interactive prompts. Here is how to use each one.

Featured image for "Less Boilerplate, More Console: Symfony 7.4's Improved Invokable Commands"

If you have written a Symfony console command in the last decade, you know the ceremony. Extend the base Command class, override configure() to register every argument and option, then dig those values back out of an InputInterface inside execute(). It works, but it spreads the definition of a single command across three places, and it never felt like idiomatic modern PHP.

Symfony 7.3 changed that with invokable commands, and Symfony 7.4, released at the end of November 2025 as the new long term support version, sharpens the idea considerably. If you run commands at all, and most of us run plenty, this is worth a close look.

A quick refresher on invokable commands

The 7.3 release let you write a command as a plain class with an __invoke() method. There is no base class to extend and no configure() to write. Arguments and options are ordinary method parameters, decorated with the #[Argument] and #[Option] attributes, and their values arrive as regular variables:

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:create-user')]
class CreateUserCommand
{
    public function __invoke(
        SymfonyStyle $io,
        #[Argument] string $name,
        #[Option] bool $activate = false,
    ): int {
        $io->success(sprintf('Created %s (activated: %s)', $name, $activate ? 'yes' : 'no'));

        return Command::SUCCESS;
    }
}

The PHP type and default value carry the metadata Symfony used to read from configure(). A parameter with no default becomes a required argument; one with a default becomes optional. Options are always optional, and a bool option becomes a value-less flag such as --activate. That single change removed a lot of repetitive wiring. Symfony 7.4 builds four practical improvements on top of it.

1. Backed enums as arguments and options

This is my favorite of the bunch because it moves validation out of your command body and into the type system. The #[Argument] and #[Option] attributes now accept backed enums. Symfony converts the string the user typed into the matching enum case for you:

enum CloudRegion: string
{
    case East = 'east';
    case West = 'west';
}

enum ServerSize: int
{
    case S = 1;
    case M = 2;
    case L = 3;
    case XL = 4;
}

#[AsCommand('app:add-server')]
class AddServerCommand
{
    public function __invoke(
        OutputInterface $output,
        #[Argument] CloudRegion $region,
        #[Option] ?ServerSize $size = null,
    ): int {
        $output->writeln($region->value);
        $output->writeln($size?->value ?? 'No value');

        return Command::SUCCESS;
    }
}

If the user passes a value that is not part of the enum, Symfony rejects it and prints the valid options without you writing a single if statement:

$ php bin/console app:add-server east-1
The value "east-1" is not valid for the "region" argument.
Supported values are "east", "west".

For any command that takes an environment name, a queue, or a report format, this alone is a reason to migrate.

2. Grouping input with the MapInput attribute

Invokable commands are tidy until a command grows to eight or nine arguments and options, at which point the __invoke() signature becomes a wall of parameters. Symfony 7.4 introduces #[MapInput], which lets you move the definition into a dedicated data transfer object. The only rule is that the DTO’s properties must be public and non-static:

class AddServerInput
{
    #[Argument]
    public CloudRegion $region;

    #[Option]
    public ?ServerSize $size = null;
}

#[AsCommand('app:add-server')]
class AddServerCommand
{
    public function __invoke(
        OutputInterface $output,
        #[MapInput] AddServerInput $server,
    ): int {
        // Read $server->region and $server->size

        return Command::SUCCESS;
    }
}

The DTO becomes a reusable, testable description of the command’s inputs, and you can even nest DTOs; Symfony merges them all into the full list of arguments and options. If you have a set of pagination or filtering options that recur across several commands, this is a clean way to share them. As one commenter on the release announcement noted, even the humble offset and limit pair benefits from being pulled into a DTO.

3. Interactive prompts without the old interact() method

Console commands have long supported an interactive mode that asks the user for missing values. Previously you implemented this by defining an interact() method, which meant reaching back to the base class conventions the invokable style was trying to leave behind. Symfony 7.4 adds the #[Interact] attribute, which you can place on any public method with a convenient signature:

use Symfony\Component\Console\Attribute\Interact;

#[AsCommand('app:add-server')]
class AddServerCommand
{
    #[Interact]
    public function prompt(InputInterface $input, SymfonyStyle $io): void
    {
        if (!$input->getArgument('region')) {
            $input->setArgument('region', $io->ask('Enter the cloud region name'));
        }
    }

    // ...
}

When the interaction is nothing more than “ask for this missing value,” you can go further with the #[Ask] attribute applied directly to the parameter:

use Symfony\Component\Console\Attribute\Ask;

public function __invoke(
    #[Argument, Ask('Enter the cloud region name')]
    CloudRegion $region,
): int {
    // ...
}

The #[Ask] attribute also works on the properties of a #[MapInput] DTO, so your prompts live right next to the fields they populate. The definition of a command, its validation, and its interactive behavior finally sit in one place.

4. Testing that keeps up

None of this would be worth much if it were hard to test. The CommandTester class, which exercises a command with fake input and output so you never touch a real console, was updated in 7.4 to be fully compatible with every one of these invokable features. Enum arguments, MapInput DTOs, and the new interaction attributes all behave under test exactly as they do at the terminal. If you are building a CLI tool at PHP Architect or wiring up maintenance commands for PHP Tek, your test suite comes along for free.

Should you adopt it?

Symfony 7.4 is the LTS release, supported for years, and it requires PHP 8.2 or higher, so most teams already meet the bar. The base Command class is not going anywhere, and existing commands keep working, but the invokable style is now clearly where the console component is heading. New commands are the natural place to start: reach for enums to validate inputs, #[MapInput] when a signature gets crowded, and #[Ask] for the values you would otherwise prompt for by hand. The result is console code that reads like the rest of a modern PHP application, which is exactly what it should have been all along.

Sources