Une sous-commande Console Symfony 8.2 est une commande appelée avec des espaces (tenant users import) au lieu de deux-points. Chaque niveau de l'arbre analyse ses propres options, et seule la dernière commande s'exécute. Présentée le 16 septembre 2026 sur le blog officiel, contribution de Nicolas Grekas (PR #65825, avec un premier commit de Daniel Leech, et #65853), la fonctionnalité arrive dans Symfony 8.2, attendu en novembre 2026.
Tu tapes docker compose up, git remote add, gh pr create. Puis tu reviens à Symfony et tu tapes cache:pool:clear, avec ses deux-points. La console a toujours groupé ses commandes par des namespaces séparés par :, mais c'était une convention de nommage, pas une structure : aucun niveau n'avait ses propres options. Symfony 8.2 change ça, sans API d'enregistrement : l'arbre est dérivé des noms que Symfony connaît déjà. Et il introduit un changement de comportement à connaître avant de monter de version.
Quel problème résolvent les sous-commandes Console ?
Le cas d'usage du billet est une application multi-tenant : une commande tenant qui porte l'option --name, et une commande tenant:users:import qui fait le travail. Jusqu'ici, l'option du tenant devait être répétée sur chaque commande feuille, ou devenir une option globale. Avec un arbre, l'option vit au niveau qui lui donne du sens. La CLI Symfony devient un vrai produit, dans la lignée des interfaces terminal riches de la console Symfony.
Comment déclarer tenant users import
// 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 {
// ...
}
}Les deux appels suivants lancent la même commande :
$ php bin/console tenant users import customers.csv --dry-run
$ php bin/console tenant:users:import customers.csv --dry-runLa différence apparaît dans la forme espacée : chaque niveau enregistré parse ses propres options. Ici, tenant consomme --name et import consomme --dry-run :
$ php bin/console tenant --name=acme users import customers.csv --dry-runSelon la PR, quand aucun token ne cible un niveau intermédiaire, les deux syntaxes sont strictement équivalentes : si tu ne passes jamais d'option par niveau, tu ne vois aucune différence avec les namespaces actuels.
Comment Symfony résout l’arbre de commandes
Tous les niveaux n'ont pas besoin d'une commande. Dans l'exemple, users est un nœud implicite, qui existe parce que tenant:users:import est enregistrée. La même règle s'applique aux namespaces existants, donc ceci marche dès l'installation d'une application 8.2 :
# both run the "cache:clear" command
$ php bin/console cache clear
$ php bin/console cache:clear- Lancer une commande parente sans code, comme
tenant, affiche ses sous-commandes et retourne le code 1, comme un namespace nu aujourd’hui. tenant --helpaffiche l’aide de la commande résolue la plus profonde, terminée par la liste des sous-commandes disponibles.listreplie tout ce qui se trouve sous une commande enregistrée sur la ligne de cette commande ;--rawet--format=jsonrestent exhaustifs.- La complétion shell parcourt l’arbre :
tenant users im<TAB>complèteimport. - Les abréviations ne parcourent pas l’arbre : seuls les noms exacts fonctionnent.
Le piège : les sous-commandes l’emportent sur les arguments
Le cas délicat est une commande qui a à la fois un argument et des sous-commandes. Exemple du billet : deploy, avec un argument target, et deploy:rollback.
# 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 rollbackLes sous-commandes l'emportent toujours sur les arguments. Avant 8.2, deploy rollback passait rollback comme valeur de target ; en 8.2, cela lance deploy:rollback. C'est un changement de comportement silencieux, pas une erreur de syntaxe. Pour passer une valeur qui porte le nom d'une sous-commande, place -- devant :
Migration Symfony en vue ?
Bear Upgrade migre votre codebase 2,5× à 4,5× moins cher qu'une ESN, selon sa maturité.
# runs "deploy" with "rollback" as its target
$ php bin/console deploy -- rollbackL'UPGRADE-8.2.md le classe en rupture de compatibilité, et précise que cela vaut aussi en forme à deux-points : app:import users lance app:import:users quand les deux commandes existent. La PR note qu'aucune commande de Symfony lui-même n'est dans ce cas, mais tes commandes métier peuvent l'être.
Comment lire les options d’une commande parente avec CommandChain
Un enfant n'hérite pas des options de ses parents. Il accède à la chaîne des commandes résolues, et à leur input, via l'objet CommandChain, injectable comme les autres utilitaires console. Extrait du billet :
// 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');
// ...
}Note le nullsafe. Le parent n'est dans la chaîne qu'en forme espacée : si quelqu'un lance directement tenant:users:import, getInput('tenant') renvoie null. Une option parente indispensable à la logique métier doit donc avoir un défaut ou lever une erreur explicite. Seules les options d'application (-v, --env) sont acceptées à tous les niveaux. Hors de la commande, dans un listener par exemple, Application::getCommandChain() donne le même objet ; ce point s'articule avec l'ordre relatif des listeners de Symfony 8.2.
Comment définir tout un arbre dans une seule classe avec #[AsCommand]
La PR #65853 complète les commandes multi-méthodes introduites en Symfony 8.1. L'#[AsCommand] de niveau classe fixe le préfixe et ses options, chaque méthode annotée déclare une sous-commande, et le constructeur, donc les dépendances, est partagé :
// 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;
}
}Cette classe enregistre deux commandes : le groupe tenant et tenant:users:import. L'entrée options de l'attribut n'est pas réservée aux groupes : une commande invokable peut désormais lister ses options dans #[AsCommand] au lieu de les déclarer en paramètres. Il n'y a pas d'entrée arguments : sur un groupe, un token positionnel est d'abord une sous-commande. Attention en migrant depuis 8.1 : une classe à commandes par méthode avec un #[AsCommand] de niveau classe et sans __invoke() enregistre maintenant aussi le groupe, qui apparaît dans list.
Comment tester une sous-commande avec ApplicationTester
ArrayInput accepte maintenant des valeurs positionnelles qui remplissent les arguments dans l'ordre : ApplicationTester rejoue donc une invocation espacée telle quelle.
use Symfony\Component\Console\Tester\ApplicationTester;
$tester = new ApplicationTester($application);
$tester->run(['command' => 'tenant', '--name' => 'acme', 'users', 'import', 'customers.csv']);Ce changement d'ArrayInput est la seconde rupture listée par l'UPGRADE-8.2.md : les valeurs positionnelles étaient validées mais jamais lues, elles remplissent désormais les arguments libres. Revois tes tests qui construisent des ArrayInput avec des valeurs sans clé.
Faut-il migrer ses commandes app:foo:bar ?
Non, la forme à deux-points reste valide. Ma position : adopte la forme espacée pour les arbres de commandes que tu écris (multi-tenant, déploiement, outillage interne), où les options par niveau remplacent des options globales confuses. Pour tout ce qui est automatisé (CI, cron, Dockerfile, runbooks), garde cache:clear : la forme à deux-points ne dépend pas du parent et reste la plus stable. Avant la montée de version, une vérification est obligatoire :
- Lance
bin/console listet repère les commandes qui acceptent un argument libre et dont le nom est aussi le préfixe d’une autre commande. - Cherche dans tes scripts CI, crons et Makefile les appels où cet argument pourrait coïncider avec un nom de sous-commande, et ajoute
--. - Vérifie tes listeners sur
ConsoleEvents::ERROR: un namespace nu ne l’émet plus, puisque rien n’échoue. - Si un script parse la sortie de
list, passe à--format=json.
Ce genre de changement ne fait pas planter la montée de version : il se cache dans un script de déploiement ou un cron que personne n'a rouvert depuis des mois. C'est le type de point que Bear Upgrade traite avant de livrer : relire les appels à la console du projet, adapter ce qui doit l'être, et te remettre des Merge Requests testées, prêtes à merger.

Écrit par
Flavien Métivier
Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.
Mon parcours