Architecture Console et cycle de vie d'une commande
Un Command Symfony suit un cycle de vie en 4 phases — configure, initialize, interact, execute — et s'intègre dans une Application qui gère le routing et l'I/O.
Le composant Console Symfony est plus qu'une simple bibliothèque de parsing — c'est un mini-framework
avec une Application qui route par nom, gère les erreurs, affiche les listes de commandes
et délègue l'exécution à des objets Command.
Point d'entrée du binaire (
bin/console). Elle enregistre toutes les commandes via
add(Command), résout le nom saisi et orchestre le cycle d'exécution.
Dans Symfony full-stack, le Kernel l'alimente automatiquement.
Cycle de vie d'une commande
Chaque commande traverse exactement quatre méthodes, dans l'ordre. Ne jamais les mélanger :
| Méthode | Rôle | I/O |
|---|---|---|
configure() |
Déclarer les arguments, options, description, help | Non |
initialize() |
Bootstrap sans interaction — charger fichiers, valider env | Non |
interact() |
Poser des questions interactives pour compléter les entrées manquantes | Oui |
execute() |
Logique principale — retourner SUCCESS/FAILURE/INVALID | Oui |
execute() est la seule méthode obligatoire à implémenter.
Les trois autres sont optionnelles — surcharge-les seulement si tu en as besoin.
Codes de retour
La valeur retournée par execute() devient le code de sortie du processus :
// Constantes de la classe Command Command::SUCCESS // 0 — tout s'est bien passé Command::FAILURE // 1 — erreur métier ou technique Command::INVALID // 2 — arguments/options invalides
#[AsCommand]
L'attribut remplace la constante protected static $defaultName héritée :
#[AsCommand( name: 'app:users:create', description: 'Crée un utilisateur en base', aliases: ['app:user:new'], hidden: false, )] final class CreateUserCommand extends Command { // ... }
help accepte une longue description affichée avec --help.
Utilise-le pour documenter les cas d'usage avancés — il supporte les balises de formatage.
Arguments et options
Les arguments sont positionnels et obligatoires ou optionnels — les options commencent par -- et peuvent être des flags, des scalaires ou des tableaux.
Arguments et options sont les deux façons d'alimenter une commande depuis la ligne de commande. Ils ont des sémantiques très différentes — les confondre nuit à l'ergonomie de la CLI.
- Positionnels — l'ordre compte
- Syntaxe :
command valeur - Modes : REQUIRED, OPTIONAL, IS_ARRAY
- Nommées — l'ordre ne compte pas
- Syntaxe :
--nom=valeurou--flag - Modes : VALUE_NONE, VALUE_REQUIRED, VALUE_OPTIONAL, VALUE_IS_ARRAY, VALUE_NEGATABLE
Arguments — addArgument()
$this->addArgument( 'username', InputArgument::REQUIRED, // 1 — obligatoire 'Nom de l\'utilisateur', null, // valeur par défaut (OPTIONAL seulement) ['alice', 'bob'] // suggestedValues pour l'autocomplétion ); $this->addArgument('roles', InputArgument::IS_ARRAY); // Usage : command alice ROLE_USER ROLE_ADMIN // $input->getArgument('roles') retourne ['ROLE_USER', 'ROLE_ADMIN']
| Constante | Valeur | Comportement |
|---|---|---|
REQUIRED | 1 | Doit être fourni — erreur sinon |
OPTIONAL | 2 | Peut être omis — retourne la valeur par défaut |
IS_ARRAY | 4 | Capture tous les arguments restants dans un tableau |
Options — addOption()
$this->addOption( 'format', // nom long : --format 'f', // raccourci : -f InputOption::VALUE_REQUIRED, // 2 — --format=json obligatoire 'Format de sortie', 'text', // valeur par défaut ['text', 'json', 'csv'] // suggestedValues ); // VALUE_NONE : flag booléen // --dry-run : $input->getOption('dry-run') === true // absent : $input->getOption('dry-run') === false $this->addOption('dry-run', null, InputOption::VALUE_NONE); // VALUE_NEGATABLE : --format / --no-format // --format : true, --no-format : false, absent : null $this->addOption('format', null, InputOption::VALUE_NEGATABLE);
| Constante | Valeur | Syntaxe CLI | Retour getOption() |
|---|---|---|---|
VALUE_NONE | 1 | --flag | true / false |
VALUE_REQUIRED | 2 | --opt=val | string |
VALUE_OPTIONAL | 4 | --opt ou --opt=val | string ou null |
VALUE_IS_ARRAY | 8 | --opt=a --opt=b | string[] |
VALUE_NEGATABLE | 16 | --opt / --no-opt | true / false / null |
IS_ARRAY | REQUIRED (opérateur bitwise PHP).
Change le type et le mode pour voir le comportement correspondant.
OutputInterface et verbosité
OutputInterface est le canal de sortie — il contrôle les niveaux de verbosité et les tags de formatage.
OutputInterface est l'abstraction sur laquelle s'appuient toutes les sorties Console.
Elle n'est pas liée au terminal — tu peux la substituer par un BufferedOutput ou
un NullOutput sans changer une ligne de code.
Niveaux de verbosité
Cinq niveaux, chacun correspondant à un drapeau CLI. Le niveau minimal déclenche l'affichage de tout le contenu en-dessous de lui :
| Constante | Valeur | Drapeau | Usage |
|---|---|---|---|
VERBOSITY_QUIET | 16 | -q | Aucune sortie sauf erreurs fatales |
VERBOSITY_NORMAL | 32 | (rien) | Sorties utilisateur standard |
VERBOSITY_VERBOSE | 64 | -v | Détails d'exécution |
VERBOSITY_VERY_VERBOSE | 128 | -vv | Informations de debug de haut niveau |
VERBOSITY_DEBUG | 256 | -vvv | Tout — SQL, requêtes HTTP, traces internes |
Écriture conditionnelle
// Méthode directe : passage du niveau en 3e argument de writeln() $output->writeln('Message verbose', OutputInterface::VERBOSITY_VERBOSE); // Méthode conditionnelle : garde explicite if ($output->isVerbose()) { $output->writeln('Connexion à la base : ' . $dsn); } if ($output->isDebug()) { $output->writeln('Requête SQL : ' . $sql); }
Tags de formatage
La Console interprète des balises XML légères pour colorier la sortie :
$output->writeln('<info>Succès</info>'); // vert $output->writeln('<comment>Attention</comment>'); // jaune $output->writeln('<question>Confirmer ?</question>'); // cyan $output->writeln('<error>Erreur critique</error>'); // blanc sur rouge $output->writeln('<fg=magenta>Personnalisé</>'); $output->writeln('<fg=white;bg=blue;options=bold>Surligné</>');
write() n'ajoute pas de saut de ligne — utile pour une progression sur une seule ligne.
writeln() termine toujours par \n. Les deux acceptent un tableau de chaînes.
Implémentation de
OutputInterface qui absorbe silencieusement toute écriture.
Parfait pour les tests qui ne se soucient pas de la sortie, ou pour forcer le mode silencieux
dans un contexte non-interactif.
ConsoleOutput sépare stdout et stderr
(accessible via getErrorOutput()). Préfère écrire les erreurs sur stderr pour
permettre la redirection indépendante (2>/dev/null).
SymfonyStyle — la boîte à outils I/O
SymfonyStyle est une façade sur Output qui fournit des blocs visuels structurés — titres, sections, tables, notifications.
SymfonyStyle emballe un InputInterface et un OutputInterface pour
offrir une API de haut niveau. Elle impose une cohérence visuelle — espaces, couleurs, encadrés —
sans que tu aies à construire les séquences ANSI toi-même.
use Symfony\Component\Console\Style\SymfonyStyle; protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $io->title('Importation des utilisateurs'); // ... return Command::SUCCESS; }
Blocs structurants
$io->title('Import CSV'); // === titre === en jaune $io->section('Étape 1 : lecture'); // --- section --- en bleu $io->text('Fichier : import.csv'); // ligne de texte simple $io->text(['Ligne 1', 'Ligne 2']); // tableau = plusieurs lignes $io->listing(['Alice', 'Bob']); // liste à puces $io->newLine(2); // 2 lignes vides
Notifications
$io->success('42 utilisateurs importés'); // bloc vert [OK] $io->error('Fichier introuvable'); // bloc rouge [ERROR] $io->warning('Encodage non UTF-8 détecté'); // bloc orange [WARNING] $io->note('Aucun doublon trouvé'); // bloc bleu [NOTE] $io->info('Durée : 2.3s'); // bloc cyan [INFO] $io->caution('Opération irréversible !'); // bloc rouge [CAUTION] // Variantes outline — même couleur, sans fond plein $io->outlineSuccess('Terminé'); $io->outlineError('Échec partiel');
Tableaux
// Tableau vertical classique $io->table( ['ID', 'Nom', 'Email'], [ [1, 'Alice', 'alice@example.com'], [2, 'Bob', 'bob@example.com'], ] ); // Tableau horizontal — headers à gauche, valeurs à droite $io->horizontalTable( ['Symfony', 'PHP', 'Doctrine'], [['8.1', '8.4', '3.x']] ); // Liste de définitions — clé : valeur sur une ligne $io->definitionList( ['Slug' => 'symfony-console'], ['Sections' => 10], ['Statut' => 'publié'], );
OutputInterface est attendu — pratique pour transmettre l'objet $io
à des méthodes privées qui écrivent elles aussi.
Questions et interactions
Les helpers de questions permettent d'obtenir des données interactives — avec validation, autocomplétion et gestion des erreurs.
Les commandes interactives posent des questions à l'utilisateur pour collecter des données manquantes. L'objectif est de faire marcher la commande en mode non-interactif (scripts, CI) et de guider l'utilisateur humain lorsqu'il oublie un argument.
Symfony appelle
interact() avant execute(), mais seulement si
l'entrée est interactive. C'est l'endroit idéal pour compléter des arguments optionnels
avec des valeurs saisies — sans polluer la logique de execute().
Les quatre helpers SymfonyStyle
// Texte libre avec valeur par défaut $name = $io->ask('Nom d\'utilisateur', 'alice'); // Texte masqué — mot de passe, token $password = $io->askHidden('Mot de passe'); // Confirmation booléenne if (!$io->confirm('Êtes-vous sûr ?', false)) { return Command::SUCCESS; } // Choix dans une liste $format = $io->choice('Format de sortie', ['json', 'csv', 'text'], 'text'); // Sélection multiple (4e argument = true) $roles = $io->choice('Rôles', ['ADMIN', 'USER', 'API'], null, true);
Validation et autocomplétion
use Symfony\Component\Console\Question\Question; $question = new Question('Email : '); $question->setValidator(function (?string $value): string { if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { throw new \RuntimeException('Email invalide'); } return $value; }); $question->setMaxAttempts(3); // exception après 3 essais $question->setAutocompleterValues([ // tab-complétion 'alice@example.com', 'bob@example.com' ]); $helper = $this->getHelper('question'); $email = $helper->ask($input, $output, $question);
$input->isInteractive() avant tout
ask(). En CI, Symfony passe automatiquement en mode non-interactif quand l'entrée
n'est pas un TTY — mais c'est une bonne pratique de le vérifier explicitement dans
interact() pour les tests unitaires.
null — l'utilisateur peut boucler indéfiniment. Toujours fixer
une limite dans les scripts automatisés.
ProgressBar — retour visuel
ProgressBar est le composant de progression visuelle — il affiche des barres, des pourcentages et des estimations de temps.
ProgressBar transforme une boucle silencieuse en retour visuel — sans avoir à
calculer les pourcentages ou gérer les séquences d'échappement ANSI.
C'est l'un des composants Console les plus utiles pour les commandes longues.
Via SymfonyStyle — la voie rapide
$io->progressStart(100); // total = 100 éléments foreach ($items as $item) { // traitement… $io->progressAdvance(); // avance de 1 } $io->progressFinish(); // affiche 100% et passe à la ligne
Via ProgressBar directe — le contrôle total
use Symfony\Component\Console\Helper\ProgressBar; $bar = new ProgressBar($output, 50); $bar->setFormat('verbose'); // format prédéfini $bar->setBarCharacter('█'); $bar->setEmptyBarCharacter('░'); $bar->setProgressCharacter('▶'); $bar->setBarWidth(40); $bar->start(); foreach ($items as $item) { $bar->setMessage('Traitement : ' . $item->name); // traitement… $bar->advance(); } $bar->finish();
Formats prédéfinis
| Format | Rendu type |
|---|---|
normal | 0/50 [>--] 0% |
verbose | 0/50 [>--] 0% 1 sec |
very_verbose | 0/50 [>--] 0% 1 sec/est. 50 secs |
debug | 0/50 [>--] 0% 1 sec/est. 50 secs 1.0 MB |
Placeholders
Un format personnalisé peut utiliser ces placeholders :
$bar->setFormat('[%bar%] %percent:3s%% %current%/%max% %elapsed:6s%/%estimated:-6s% %memory:6s% %message%');
| Placeholder | Description |
|---|---|
%current% | Étape courante |
%max% | Total |
%bar% | Barre graphique |
%percent% | Pourcentage (0–100) |
%elapsed% | Temps écoulé |
%remaining% | Temps restant estimé |
%estimated% | Durée totale estimée |
%memory% | Mémoire courante |
%message% | Message défini par setMessage() |
progressIterate() — la voie la plus concise
// Wrap automatique — aucune gestion start/advance/finish foreach ($io->progressIterate($items, count($items)) as $item) { // traitement… }
0
— la barre affichera une animation en cours sans pourcentage.
Table et rendu structuré
Table génère des tableaux ASCII stylisés — avec des séparateurs, des styles prédéfinis et des titres.
Table génère des tableaux alignés automatiquement dans le terminal.
Elle gère les largeurs de colonnes, les séparateurs et plusieurs styles visuels.
Usage de base
use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Helper\TableSeparator; $table = new Table($output); $table ->setHeaderTitle('Utilisateurs') ->setHeaders(['ID', 'Nom', 'Rôle']) ->setRows([ [1, 'Alice', 'ADMIN'], [2, 'Bob', 'USER'], new TableSeparator(), [3, 'Carol', 'API'], ]) ->setFooterTitle('3 utilisateurs') ->render();
Styles visuels
$table->setStyle('box'); // ┌─┬─┐ / │ │ │ / └─┴─┘ $table->setStyle('box-double'); // ╔═╦═╗ / ║ ║ ║ / ╚═╩═╝ $table->setStyle('compact'); // sans bordures externes $table->setStyle('default'); // +---+---+ (ASCII pur)
| Style | Caractères | Cas d'usage |
|---|---|---|
default | ASCII + - | | Compatibilité maximale |
box | Unicode box-drawing | Terminaux modernes |
box-double | Double trait Unicode | Titres importants |
compact | Espaces seuls | Logs, sortie parsée |
symfony-style-guide | Mixte | Outil officiel Symfony |
Contrôle des largeurs
// Largeur fixe d'une colonne (index 0-based) $table->setColumnWidth(1, 30); // colonne 1 = 30 chars // Largeur maximale — tronque avec "…" si dépassée $table->setColumnMaxWidth(2, 20);
setRows() remplace toutes les lignes existantes.
addRow() en ajoute une seule à la fin.
Pour construire un tableau ligne par ligne dans une boucle, utilise addRow().
new TableCell('texte', ['colspan' => 2]).
Utile pour les en-têtes de groupe couvrant plusieurs colonnes.
Le dispatch de l'Application
run(), doRun() et surtout find() transforment le nom que tu tapes en une commande précise — abréviation par segments, alias et détection d'ambiguïté.
Quand tu tapes bin/console app:us:cr, aucune de tes commandes ne s'exécute
directement. La saisie traverse d'abord un routeur : l'Application transforme
le texte que tu as tapé en un objet Command précis, avant de lui passer la main.
run() installe les I/O et attrape les erreurs, doRun() lit le nom de la commande,
find() résout ce nom en un objet Command, et doRunCommand()
orchestre l'exécution réelle. Chaque étage a une responsabilité unique.
find() — la résolution de nom
C'est le cœur du routeur. Il tente les stratégies dans l'ordre, et s'arrête à la première qui donne un résultat certain :
// Simplifié depuis Application::find() // 1. Correspondance exacte (nom OU alias déjà enregistré) if ($this->has($name)) { return $this->get($name); } // 2. Abréviation : chaque segment séparé par ":" peut être tronqué $expr = implode('[^:]*:', array_map('preg_quote', explode(':', $name))) . '[^:]*'; $commands = preg_grep('{^' . $expr . '}', $allCommands);
Concrètement, app:us:cr devient la regex app[^:]*:us[^:]*:cr[^:]*. Elle accepte
application:users:create — chaque segment est
un préfixe, les deux-points sont les frontières. C'est pour ça que tu peux taper c:c pour
cache:clear, mais jamais sauter un segment.
app:create ne trouvera pas app:users:create : le segment
users est absent, pas tronqué.
Les trois issues possibles
| Situation | Comportement |
|---|---|
| Un seul match | La commande est retournée et exécutée |
| Plusieurs matches non-alias | CommandNotFoundException — « Command … is ambiguous. Did you mean one of these? » avec la liste |
| Aucun match | CommandNotFoundException — « Command … is not defined » + suggestions par proximité (findAlternatives()) |
Avant de trancher l'ambiguïté, find() fait deux nettoyages : il retire les alias qui pointent
vers une commande déjà listée, puis vérifie si tous les candidats restants sont en réalité des alias
d'une seule et même commande — auquel cas il n'y a pas d'ambiguïté. Les commandes
isHidden() sont aussi écartées des suggestions.
findAlternatives(), qui calcule
une distance de type Levenshtein entre ce que tu as tapé et chaque nom/alias connu. C'est un filet de
sécurité contre les fautes de frappe, pas de l'abréviation.
doRunCommand() — le relais
Une fois la commande trouvée, doRunCommand() ne se contente pas d'appeler
$command->run(). S'il n'y a pas d'EventDispatcher, c'est effectivement tout ce
qu'il fait. Mais dès qu'un dispatcher est présent (le cas par défaut en full-stack), il enveloppe
l'exécution dans un cycle d'événements et de gestion de signaux — c'est l'objet des deux sections
suivantes.
Tape un nom partiel et observe comment find() le résout contre ce
catalogue : app:users:create, app:users:list, app:users:delete,
app:cache:clear, cache:pool:clear, debug:router.
Les événements Console
COMMAND, ERROR et TERMINATE ceinturent chaque exécution — de quoi désactiver, rattraper ou nettoyer une commande sans toucher à son code.
Autour de chaque exécution, quand un EventDispatcher est branché sur l'application
(le cas par défaut en full-stack), Console déclenche une petite chorégraphie d'événements. Ils te
permettent de journaliser, mesurer, désactiver ou rattraper une commande sans jamais toucher
son code.
Quatre événements ceinturent l'exécution :
COMMAND avant, ERROR en cas
d'exception, SIGNAL à la réception d'un signal Unix, et TERMINATE toujours,
à la toute fin. Sans dispatcher, doRunCommand() appelle simplement $command->run()
et rien de tout cela ne se produit.
L'ordre garanti
| Événement | Classe | Quand | Pouvoir |
|---|---|---|---|
console.command | ConsoleCommandEvent | Avant execute(), après le binding des entrées | Désactiver la commande |
console.error | ConsoleErrorEvent | Uniquement si une exception est levée | Remplacer l'erreur et le code de sortie |
console.terminate | ConsoleTerminateEvent | Toujours, même après une erreur | Réécrire le code de sortie |
COMMAND — désactiver avant de lancer
L'événement COMMAND est émis après que les arguments et options ont été liés à la
commande : tes listeners voient donc déjà les entrées. Un listener peut appeler
disableCommand() ; doRunCommand() vérifie ensuite commandShouldRun()
et, si c'est faux, saute execute() en renvoyant le code spécial 113 :
use Symfony\Component\Console\ConsoleEvents; use Symfony\Component\Console\Event\ConsoleCommandEvent; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; #[AsEventListener(event: ConsoleEvents::COMMAND)] final class MaintenanceModeListener { public function __invoke(ConsoleCommandEvent $event): void { if ($this->maintenance->isOn() && $event->getCommand()?->getName() === 'app:import') { $event->getOutput()->writeln('<comment>Import gelé pendant la maintenance.</comment>'); $event->disableCommand(); // execute() ne sera pas appelée } } }
RETURN_CODE_DISABLED = 113,
pas un succès. Un script shell qui enchaîne des commandes peut ainsi distinguer « désactivée » de
« exécutée avec succès ».
ERROR — intercepter et neutraliser
Si execute() (ou un listener COMMAND) lève une exception, ERROR
est déclenché avec l'exception dans l'événement. Un listener peut la remplacer
(setError()) ou la neutraliser en forçant un code de sortie — si ce code
vaut 0, l'exception est oubliée et n'est pas relancée :
#[AsEventListener(event: ConsoleEvents::ERROR)] public function onError(ConsoleErrorEvent $event): void { $this->logger->error($event->getError()->getMessage()); // Transforme un timeout réseau en code 75 (EX_TEMPFAIL) sans stacktrace if ($event->getError() instanceof TransportException) { $event->setExitCode(75); } }
TERMINATE — le nettoyage inconditionnel
TERMINATE est le finally du cycle : il part toujours, succès
ou échec, et c'est le dernier à pouvoir réécrire le code de sortie via setExitCode().
Son getInterruptingSignal() te dit même si l'arrêt vient d'un signal Unix (voir la section
suivante). C'est l'endroit idéal pour fermer une connexion, purger un buffer ou pousser une métrique de
durée.
execute() : un listener qui « fait le travail » rend le flux impossible à suivre.
Réagir aux signaux Unix
SignalableCommandInterface et l'événement SIGNAL permettent d'arrêter proprement un worker sur Ctrl-C ou SIGTERM, en finissant la tâche en cours.
Ctrl-C au milieu d'un import de deux millions de lignes : soit tu coupes brutalement et laisses une transaction à moitié écrite, soit tu finis proprement la ligne en cours puis rends la main. Console offre les deux mécanismes pour ça — au niveau de la commande, ou au niveau de l'application.
Une commande qui implémente cette interface déclare les signaux qui l'intéressent avec
getSubscribedSignals() et réagit dans handleSignal(). Console enregistre alors
un gestionnaire pcntl pour ces signaux le temps de l'exécution, puis restaure les gestionnaires
précédents à la fin.
use Symfony\Component\Console\Command\SignalableCommandInterface; final class ImportCommand extends Command implements SignalableCommandInterface { private bool $shouldStop = false; public function getSubscribedSignals(): array { return [SIGINT, SIGTERM]; // Ctrl-C et arrêt propre } public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false { $this->shouldStop = true; return false; // false = ne quitte pas maintenant, laisse execute() finir } protected function execute(InputInterface $input, OutputInterface $output): int { foreach ($this->rows() as $row) { if ($this->shouldStop) { break; // on sort de la boucle après la ligne en cours } $this->process($row); } return Command::SUCCESS; } }
handleSignal() décide du sort du processus.
Un int fait quitter immédiatement avec ce code de sortie. false signifie
« je note, mais continue » — c'est le pattern du drapeau $shouldStop testé dans la boucle.
Le second paramètre $previousExitCode te transmet le code déjà décidé par un handler
antérieur, pour pouvoir l'ajuster.
ConsoleEvents::SIGNAL — au niveau application
Il existe une seconde voie, découplée de toute commande : l'événement SIGNAL. Quand un
dispatcher est présent, l'application écoute par défaut un jeu de signaux et émet un
ConsoleSignalEvent à leur réception. Utile pour une préoccupation globale — vider un cache,
flusher des métriques — quelle que soit la commande en cours :
// Signaux écoutés par défaut quand un dispatcher est branché : [SIGINT, SIGQUIT, SIGTERM, SIGUSR1, SIGUSR2, SIGALRM] // Ajustable via Application::setSignalsToDispatchEvent(...) #[AsEventListener(event: ConsoleEvents::SIGNAL)] public function onSignal(ConsoleSignalEvent $event): void { $this->metrics->flush(); $event->abortExit(); // empêche la sortie automatique, on laisse tourner }
Pour un même signal, l'application dispatche d'abord
SIGNAL (préoccupation globale), puis
appelle handleSignal() de la commande si elle est signalable. La commande a donc le
dernier mot sur le code de sortie.
Sous le capot — SignalRegistry
Le mécanisme repose sur l'extension pcntl. À la construction, l'application active
pcntl_async_signals(true) via un SignalRegistry : les signaux sont livrés de
façon asynchrone, sans avoir à parsemer ton code d'appels à pcntl_signal_dispatch(). Le
registre empile les gestionnaires existants avant l'exécution et les restaure après, pour ne pas
écraser ceux d'un éventuel processus parent.
pcntl (absente sous Windows). Sans elle, getSignalRegistry() lève une
RuntimeException : conçois tes workers signalables en sachant qu'ils tournent sur un OS
POSIX.
Autocomplétion et LazyCommand
Console supporte l'autocomplétion native du shell — et LazyCommand diffère l'instanciation pour des applications avec beaucoup de commandes.
Deux fonctionnalités avancées qui améliorent l'ergonomie et les performances des CLI complexes :
l'autocomplétion native du shell, et LazyCommand qui diffère l'instanciation
des commandes jusqu'à leur invocation.
Autocomplétion — complete()
Pour qu'une commande propose des suggestions au shell (bash/zsh/fish), implémente la méthode
complete(). Elle est appelée par la sous-commande interne _complete :
use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; public function complete( CompletionInput $input, CompletionSuggestions $suggestions ): void { // Suggestion pour un argument if ($input->mustSuggestArgumentValuesFor('username')) { $suggestions->suggestValues(['alice', 'bob', 'carol']); return; } // Suggestion pour une option if ($input->mustSuggestOptionValuesFor('format')) { $suggestions->suggestValues(['json', 'csv', 'text']); return; } // Suggestion d'options supplémentaires if ($input->getCompletionType() === CompletionInput::TYPE_OPTION_NAME) { $suggestions->suggestOptions($this->getDefinition()->getOptions()); } }
| Type CompletionInput | Contexte |
|---|---|
TYPE_ARGUMENT_VALUE | L'utilisateur tape la valeur d'un argument |
TYPE_OPTION_VALUE | L'utilisateur tape la valeur d'une option (--opt=…) |
TYPE_OPTION_NAME | L'utilisateur tape un nom d'option (--…) |
TYPE_NONE | Aucun contexte précis déterminé |
LazyCommand — instanciation différée
Dans une application avec des centaines de commandes, instancier chaque Command
(et donc ses dépendances) au démarrage est coûteux. LazyCommand résout ce problème :
use Symfony\Component\Console\Command\LazyCommand; // Dans l'Application ou un CompilerPass : $application->add(new LazyCommand( name: 'app:heavy:process', aliases: ['app:process'], description: 'Traitement lourd avec beaucoup de dépendances', isHidden: false, commandFactory: fn() => new HeavyProcessCommand($dep1, $dep2), ));
name, aliases, description, isHidden sont
lus sans instancier la commande — ils servent pour la liste et le routing.
La commandFactory n'est appelée que lorsque la commande est effectivement exécutée.
console.command
sont automatiquement enveloppées dans LazyCommand par le framework. Tu bénéficies
du mécanisme sans rien configurer.
Sous le capot — la découverte des commandes taguées
Comment le framework connaît-il le nom et la description de centaines de commandes sans en instancier
une seule au démarrage ? Grâce à un compiler pass qui travaille à la compilation du conteneur,
bien avant le moindre bin/console.
En full-stack, toute classe étendant Command reçoit automatiquement le tag
console.command (autoconfiguration). Le AddConsoleCommandPass parcourt alors ces
services et, par réflexion seule, lit l'attribut #[AsCommand] pour en
extraire nom, alias, description et état caché — sans exécuter le constructeur :
// Ce que construit AddConsoleCommandPass, schématiquement : $lazyCommandMap = [ 'app:users:create' => 'App\Command\CreateUserCommand', // nom => id de service 'app:user:new' => 'App\Command\CreateUserCommand', // alias => même id ]; // Chaque service est enveloppé dans un LazyCommand (.id.lazy) // puis exposé via un ContainerCommandLoader : $loader = new ContainerCommandLoader($serviceLocator, $lazyCommandMap); $application->setCommandLoader($loader);
Un pont vers le conteneur PSR-11. Son
getNames() renvoie les clés de la map — donc tous les
noms connus — tandis que get($name) ne fait un $container->get($id)
(l'instanciation réelle) que lorsqu'une commande est effectivement demandée.
La boucle se ferme avec find() (vu dans la section Dispatch) : celui-ci interroge
commandLoader->getNames() pour lister et router, et n'appelle get() que sur la
commande gagnante. Résultat : bin/console list affiche des centaines de commandes en lisant
uniquement des métadonnées, sans démarrer une seule dépendance.
#[AsCommand] doit être déclaratif. Nom, description et alias
sont lus à la compilation, par réflexion, hors de toute instance. Y mettre une valeur calculée
dynamiquement au runtime n'aurait aucun effet sur le listing ni sur le routing.
Tester ses commandes
Symfony fournit CommandTester pour tester les commandes sans instancier l'Application — avec simulation d'inputs et capture d'output.
Tester une commande Console ne doit pas nécessiter de lancer un processus — CommandTester
capture la sortie et simule les entrées en mémoire, rendant les tests rapides et isolés.
Structure d'un test
use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Tester\CommandTester; class CreateUserCommandTest extends KernelTestCase { public function testExecute(): void { // Instancier la commande avec ses dépendances mockées $command = new CreateUserCommand( $this->createMock(UserRepository::class) ); $tester = new CommandTester($command); // Exécuter avec des arguments et options $tester->execute([ 'username' => 'alice', // argument '--format' => 'json', // option '--dry-run' => true, // flag ], [ 'decorated' => false, // désactive les couleurs ANSI ]); // Assertions $tester->assertCommandIsSuccessful(); $this->assertStringContainsString('alice', $tester->getDisplay()); } }
Tester les questions interactives
// setInputs() simule les réponses clavier dans l'ordre $tester->setInputs(['alice', 'motdepasse', 'yes']); $tester->execute([]);
API complète de CommandTester
| Méthode | Description |
|---|---|
execute(input, options) | Lance la commande, retourne le code de sortie |
getDisplay(normalize) | Sortie stdout (normalize = normalise les retours chariot) |
getErrorOutput() | Sortie stderr |
getStatusCode() | Code de retour (0, 1, 2) |
setInputs(array) | Simule les réponses aux questions interactives |
assertCommandIsSuccessful() | Assert que le code de retour est 0 |
Dans des cas avancés où la commande vérifie
$input->isInteractive(),
appelle $tester->execute([], ['interactive' => false]) pour simuler
un environnement non-interactif.
Si la commande doit résoudre des services du container, utilise
Application::find(name) sur une Application bootstrappée
depuis le Kernel de test — puis passe la commande trouvée à CommandTester.
Synthèse — guide de choix et anti-patterns
Le guide de choix et les anti-patterns de Console Symfony.
Après neuf sections, voici les guides de décision qui s'appliquent à la construction d'une interface CLI robuste avec Symfony Console.
Guide de choix : argument ou option ?
— La valeur est obligatoire et positionnelle (ex. : nom de fichier, username)
— L'ordre d'entrée est naturel pour l'utilisateur
— Il n'y a pas d'ambiguïté sémantique
— Le paramètre est optionnel ou modifie le comportement par défaut
— C'est un flag booléen (
--dry-run, --verbose)— Tu veux permettre
--no-maprefix (VALUE_NEGATABLE)— La valeur peut être omise avec un défaut implicite
Guide de choix : quelle question poser ?
| Besoin | Méthode |
|---|---|
| Texte libre, une réponse | ask() |
| Mot de passe / secret | askHidden() |
| Oui / Non | confirm() |
| Choix parmi une liste finie | choice() |
| Texte avec validation + autocomplétion | Question + setValidator() |
Guide de choix : ProgressBar
| Situation | Solution |
|---|---|
| Itérable connu, pas de logique complexe | progressIterate() |
| Boucle avec logique entre les steps | progressStart/Advance/Finish via $io |
| Format personnalisé, placeholders spécifiques | ProgressBar directe |
| Total inconnu | max = 0 → animation sans pourcentage |
Récapitulatif des modes
| InputArgument | Valeur |
|---|---|
REQUIRED | 1 |
OPTIONAL | 2 |
IS_ARRAY | 4 |
| InputOption | Valeur | Drapeau CLI |
|---|---|---|
VALUE_NONE | 1 | --flag |
VALUE_REQUIRED | 2 | --opt=val |
VALUE_OPTIONAL | 4 | --opt ou --opt=val |
VALUE_IS_ARRAY | 8 | --opt=a --opt=b |
VALUE_NEGATABLE | 16 | --opt / --no-opt |
| VERBOSITY_* | Valeur | Drapeau |
|---|---|---|
QUIET | 16 | -q |
NORMAL | 32 | (rien) |
VERBOSE | 64 | -v |
VERY_VERBOSE | 128 | -vv |
DEBUG | 256 | -vvv |
4 anti-patterns à éviter
La commande est une couche Infrastructure. Elle doit déléguer à un Handler ou Service de domaine — jamais implémenter elle-même les règles métier.
Injecter
OutputInterface ou SymfonyStyle dans un Handler de domaine
crée un couplage fort et rend les tests unitaires pénibles.
Utilise des events, des retours ou des callbacks.
Si
interact() pose des questions et que tu ne passes pas setInputs(),
le test se bloque indéfiniment en attendant une saisie clavier.
Vérifie toujours $input->isInteractive() ou passe ['interactive' => false].
Ne jamais terminer
execute() sans return Command::SUCCESS (ou FAILURE/INVALID).
Sans return explicite, PHP retourne null, qui vaut 0 — masquant les erreurs réelles.