Invokable commands and input attributes were one of the most popular and important features introduced in Symfony 7.3. In Symfony 7.4, we've improved them in several ways.

Enum Support in Invokable Commands

Jérôme Tamarelle
Contributed by Jérôme Tamarelle in #60586

The type of the #[Argument] and #[Option] attributes can now be backed enums. The string input provided by the user is automatically converted into the corresponding enum. If you define the following enums:

1
2
3
4
5
6
7
8
9
10
11
12
13
enum CloudRegion: string
{
    case East = 'east';
    case West = 'west';
}

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

You can then use them in your command as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[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 provides a value that doesn't match the enum, Symfony displays a clear error message showing the possible values:

1
2
3
4
5
$ php bin/console app:add-server east --size=2

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

New MapInput Attribute

Yonel Ceruto
Contributed by Yonel Ceruto in #61478

When a command defines many arguments and/or options, its __invoke() method can look crowded. In Symfony 7.4, we're introducing a new #[MapInput] attribute that can be applied to a DTO class where you define the command arguments and options.

Continuing the previous example, you could define the command input using this DTO (the only requirement is that properties must be public and non-static):

1
2
3
4
5
6
7
8
class AddServerInput
{
    #[Argument]
    public CloudRegion $region;

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

Then you can define the command like this:

1
2
3
4
5
6
7
8
9
10
11
12
#[AsCommand('app:add-server')]
class AddServerCommand
{
    public function __invoke(
        OutputInterface $output,
        #[MapInput] AddServerInput $server,
    ): int {
        // use $server->region and $server->size

        return Command::SUCCESS;
    }
}

You can even nest DTOs, and Symfony will merge them all to build the full list of arguments and options.

Support Invokable Commands in Tests

Ruud Kamphuis
Contributed by Ruud Kamphuis in #60823

The CommandTester class eases testing commands using special input and output classes to test commands without a real console. In Symfony 7.4, we updated it to make sure it's fully compatible with all the new invokable command features.

Interactive Invokable Commands

Yonel Ceruto
Contributed by Yonel Ceruto in #61748

Symfony commands provide interactive features to ask for missing arguments or options, retry inputs when invalid values are given, and more. In Symfony 7.4, we've enhanced invokable commands to include interactive features through two new attributes: #[Interact] and #[Ask].

First, you can apply the #[Interact] attribute to any non-static public method in your command. That method will be called during interactive mode. Previously, you had to define a method called interact():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[AsCommand('app:add-server')]
class AddServerCommand extends Command
{
    protected function interact(InputInterface $input, OutputInterface $output): void
    {
        $io = new SymfonyStyle($input, $output);

        if (!$input->getArgument('region')) {
            $input->setArgument('region', $io->ask('Enter the cloud region name'));
        }
    }

    // ...
}

Now you can apply the #[Interact] attribute to any method of the command and use a more convenient method signature:

1
2
3
4
5
6
7
8
9
10
11
12
13
#[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'));
        }
    }

    // ...
}

If your interactions only involve asking for missing argument or option values, you can make them even more concise with the #[Ask] attribute:

1
2
3
4
5
6
7
8
9
10
11
#[AsCommand('app:add-server')]
class AddServerCommand
{
    public function __invoke(
        #[Argument, Ask('Enter the cloud region name')]
        CloudRegion $region,
        // ...
    ): int {
        // ...
    }
}

The #[Ask] attribute can also be applied to DTOs that define the input:

1
2
3
4
5
6
7
8
9
class AddServerInput
{
    #[Argument]
    #[Ask('Enter the cloud region name')]
    public CloudRegion $region;

    #[Option]
    public ?ServerSize $size = null;
}
Published in #Living on the edge