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
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]
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.