Symfony console applications group their commands with colon-separated namespaces, such as cache:clear or messenger:consume. Tools like Docker and Git use a different style: spaces separate the levels (docker compose up) and each level parses its own options. Symfony 8.2 allows you to create this type of console sub-commands.

Sub-Commands Separated by Spaces

Nicolas Grekas
Contributed by Nicolas Grekas in #65825

Commands whose names share a prefix now form a tree. Consider a multi-tenant application that registers a tenant command with a --name option and a tenant:users:import command that does the actual work:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/Command/TenantCommand.php
namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputOption;

#[AsCommand(name: 'tenant', description: 'Manages tenants')]
class TenantCommand extends Command
{
    protected function configure(): void
    {
        $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'The tenant name');
    }

    // no execute(): running "tenant" alone lists its sub-commands
}

// src/Command/ImportUsersCommand.php
namespace App\Command;

use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;

#[AsCommand(name: 'tenant:users:import', description: 'Imports users from a CSV file')]
class ImportUsersCommand
{
    public function __invoke(
        #[Argument] string $file,
        #[Option] bool $dryRun = false,
    ): int {
        // ...
    }
}

Both of the following run the same tenant:users:import command:

1
2
$ php bin/console tenant users import customers.csv --dry-run
$ php bin/console tenant:users:import customers.csv --dry-run

The difference is that, in the spaced form, each registered level parses its own options and only the last command runs:

1
$ php bin/console tenant --name=acme users import customers.csv --dry-run

How the Command Tree Is Resolved

Not every level needs a command of its own. In the previous example, users is an implicit node that exists only because tenant:users:import is registered. The same applies to any existing namespace, so this works out of the box in Symfony 8.2 applications:

1
2
3
# both run the "cache:clear" command
$ php bin/console cache clear
$ php bin/console cache:clear

Running a command that has sub-commands but no code of its own, like tenant, lists those sub-commands and exits with code 1, the same as running a bare namespace does today.

Things get trickier when a command defines an argument and also has sub-commands. Imagine a deploy command with a target argument and a deploy:rollback command:

1
2
3
4
5
# runs "deploy" with "staging" as its target
$ php bin/console deploy staging

# runs the "deploy:rollback" command (not "deploy" with "rollback" as its target)
$ php bin/console deploy rollback

Sub-commands always take precedence. Before Symfony 8.2, deploy rollback considered rollback as the value of the target argument. If you need to pass a value that matches the name of a sub-command, add -- before it:

1
2
# runs "deploy" with "rollback" as its target
$ php bin/console deploy -- rollback

The --help option, the help and list commands and shell completion understand the tree too. tenant users import --help shows the help of the import command and tenant users im<TAB> completes import.

Reading the Input of Parent Commands

A sub-command doesn't inherit the options of its parents. Instead, commands can access the chain of resolved commands and their input via the CommandChain object. Inject it into an invokable command like any other console utility:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/Command/ImportUsersCommand.php
namespace App\Command;

use Symfony\Component\Console\CommandChain;
// ...

public function __invoke(
    CommandChain $chain,
    #[Argument] string $file,
    #[Option] bool $dryRun = false,
): int {
    $tenantName = $chain->getInput('tenant')?->getOption('name');

    // ...
}

getInput() accepts the name of the command or its class, so getInput(TenantCommand::class) works too. The parent is only part of the chain when using the spaced form: if you run tenant:users:import directly, getInput('tenant') returns null (that's why the example uses the nullsafe operator).

Outside of the command itself, for example in an event listener, call Application::getCommandChain() while a command is running to get the same object.

The options of the application, such as -v or --env, are accepted at any level, so tenant -v users import and tenant users import -v behave the same.

Spaced invocations can be tested with ApplicationTester, because ArrayInput now accepts positional values that fill the argument slots in order:

1
2
3
4
use Symfony\Component\Console\Tester\ApplicationTester;

$tester = new ApplicationTester($application);
$tester->run(['command' => 'tenant', '--name' => 'acme', 'users', 'import', 'customers.csv']);

Command Groups with #[AsCommand]

Nicolas Grekas
Contributed by Nicolas Grekas in #65853

Symfony 8.1 already lets you define commands as methods of the same class, using a class-level #[AsCommand] attribute as the common prefix of their names. Symfony 8.2 completes this feature to let you define whole trees of commands in a single class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/Command/TenantCommands.php
namespace App\Command;

use App\Repository\TenantRepository;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\CommandChain;
use Symfony\Component\Console\Input\InputOption;

#[AsCommand('tenant', description: 'Manages tenants', options: [
    new InputOption('name', null, InputOption::VALUE_REQUIRED, 'The tenant name'),
])]
class TenantCommands
{
    public function __construct(
        private TenantRepository $tenants,
    ) {
    }

    #[AsCommand('users:import', description: 'Imports users from a CSV file')]
    public function importUsers(
        CommandChain $chain,
        #[Argument] string $file,
        #[Option] bool $dryRun = false,
    ): int {
        $tenantName = $chain->getInput('tenant')?->getOption('name');

        // ...

        return Command::SUCCESS;
    }
}

This class registers two commands: tenant (the group) and tenant:users:import. The group behaves like the TenantCommand class of the first example, but without any code: its description and its --name option come from the attribute, and running tenant alone lists its sub-commands.

The options entry of the attribute is not limited to groups. Until now, an invokable command declared its options as method parameters with the #[Option] attribute. In Symfony 8.2 you can also list them in #[AsCommand] and read them from the input object, in the same way as in traditional Command classes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/Command/CreateUserCommand.php
namespace App\Command;

use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;

#[AsCommand('app:create-user', options: [
    new InputOption('locale', null, InputOption::VALUE_REQUIRED, 'The user locale', 'en'),
])]
class CreateUserCommand
{
    public function __invoke(InputInterface $input, #[Argument] string $email): int
    {
        $locale = $input->getOption('locale');

        // ...
    }
}

If you want to learn more about creating modern Symfony commands and standalone CLI apps, don't miss the Web-less Console: Standalone Apps in Symfony 8.2 talk by Robin Chalas during the SymfonyCon Warsaw 2026 conference.

Published in #Living on the edge