← Tous les cours / Symfony Console avancé Cours
00 — ARCHITECTURE CONSOLE

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.

Application
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éthodeRôleI/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
🔑 Règle d'or : 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
{
    // ...
}
💡 Le paramètre 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.
Simulateur — cycle de vie
01 — ARGUMENTS ET OPTIONS

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.

Arguments
  • Positionnels — l'ordre compte
  • Syntaxe : command valeur
  • Modes : REQUIRED, OPTIONAL, IS_ARRAY
Options
  • Nommées — l'ordre ne compte pas
  • Syntaxe : --nom=valeur ou --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']
ConstanteValeurComportement
REQUIRED1Doit être fourni — erreur sinon
OPTIONAL2Peut être omis — retourne la valeur par défaut
IS_ARRAY4Capture 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);
ConstanteValeurSyntaxe CLIRetour getOption()
VALUE_NONE1--flagtrue / false
VALUE_REQUIRED2--opt=valstring
VALUE_OPTIONAL4--opt ou --opt=valstring ou null
VALUE_IS_ARRAY8--opt=a --opt=bstring[]
VALUE_NEGATABLE16--opt / --no-opttrue / false / null
⚠️ IS_ARRAY + REQUIRED : l'argument doit recevoir au moins une valeur. Combinaison : IS_ARRAY | REQUIRED (opérateur bitwise PHP).
Simulateur — modes argument / option

Change le type et le mode pour voir le comportement correspondant.

02 — OUTPUTINTERFACE

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 :

ConstanteValeurDrapeauUsage
VERBOSITY_QUIET16-qAucune sortie sauf erreurs fatales
VERBOSITY_NORMAL32(rien)Sorties utilisateur standard
VERBOSITY_VERBOSE64-vDétails d'exécution
VERBOSITY_VERY_VERBOSE128-vvInformations de debug de haut niveau
VERBOSITY_DEBUG256-vvvTout — 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() vs writeln()
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.
NullOutput
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 vs StreamOutput : 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).
03 — SYMFONYSTYLE

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é'],
);
🔑 SymfonyStyle hérite de OutputInterface. Tu peux la passer partout où un OutputInterface est attendu — pratique pour transmettre l'objet $io à des méthodes privées qui écrivent elles aussi.
04 — QUESTIONS INTERACTIVES

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.

Où placer les questions : interact()
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);
💡 Mode non-interactif : appelle $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.
⚠️ setMaxAttempts(null) signifie un nombre illimité de tentatives. Par défaut, il est à null — l'utilisateur peut boucler indéfiniment. Toujours fixer une limite dans les scripts automatisés.
05 — PROGRESSBAR

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

FormatRendu 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%');
PlaceholderDescription
%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…
}
🔑 max = 0 : si tu ne connais pas le nombre total d'éléments, passe 0 — la barre affichera une animation en cours sans pourcentage.
Simulateur — ProgressBar
06 — TABLE

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)
StyleCaractèresCas d'usage
defaultASCII + - |Compatibilité maximale
boxUnicode box-drawingTerminaux modernes
box-doubleDouble trait UnicodeTitres importants
compactEspaces seulsLogs, sortie parsée
symfony-style-guideMixteOutil 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);
addRow() vs setRows()
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().
💡 TableCell avec colspan / rowspan : pour fusionner des cellules, utilise new TableCell('texte', ['colspan' => 2]). Utile pour les en-têtes de groupe couvrant plusieurs colonnes.
07 — DISPATCH DE L'APPLICATION

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.

Le pipeline de dispatch
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.
run()
doRun()
find(nom)
doRunCommand()

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.

🔑 Abréviation ≠ recherche floue. L'abréviation ne fonctionne que sur des préfixes de segments. app:create ne trouvera pas app:users:create : le segment users est absent, pas tronqué.

Les trois issues possibles

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

💡 Les suggestions « Did you mean » viennent de 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.

Simulateur — résolution de nom

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.

08 — LES ÉVÉNEMENTS CONSOLE

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.

ConsoleEvents
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

COMMAND
execute()
ERROR ?
TERMINATE
ÉvénementClasseQuandPouvoir
console.commandConsoleCommandEventAvant execute(), après le binding des entréesDésactiver la commande
console.errorConsoleErrorEventUniquement si une exception est levéeRemplacer l'erreur et le code de sortie
console.terminateConsoleTerminateEventToujours, même après une erreurRéé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
        }
    }
}
🔑 113, pas 0. Une commande désactivée renvoie 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.

💡 Ne mets pas ta logique métier dans un listener. Ces événements servent aux préoccupations transverses — logs, métriques, mode maintenance. La logique de la commande reste dans execute() : un listener qui « fait le travail » rend le flux impossible à suivre.
09 — SIGNAUX UNIX

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.

SignalableCommandInterface
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;
    }
}
🔑 La valeur de retour de 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
}
Deux niveaux, un même signal
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.

⚠️ CLI et pcntl uniquement. La gestion des signaux exige le SAPI CLI et l'extension 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.
10 — AUTOCOMPLÉTION

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 CompletionInputContexte
TYPE_ARGUMENT_VALUEL'utilisateur tape la valeur d'un argument
TYPE_OPTION_VALUEL'utilisateur tape la valeur d'une option (--opt=…)
TYPE_OPTION_NAMEL'utilisateur tape un nom d'option (--…)
TYPE_NONEAucun 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),
));
Ce que LazyCommand optimise
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.
💡 Dans Symfony full-stack, toutes les commandes taguées 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);
ContainerCommandLoader
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.

🔑 C'est pourquoi #[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.
11 — TESTER SES COMMANDES

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éthodeDescription
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
💡 Désactiver l'interactivité dans initialize() :
Dans des cas avancés où la commande vérifie $input->isInteractive(), appelle $tester->execute([], ['interactive' => false]) pour simuler un environnement non-interactif.
Tests d'intégration avec l'Application complète
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.
12 — SYNTHÈSE

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 ?

Utilise un argument quand :
— 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
Utilise une option quand :
— 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 ?

BesoinMéthode
Texte libre, une réponseask()
Mot de passe / secretaskHidden()
Oui / Nonconfirm()
Choix parmi une liste finiechoice()
Texte avec validation + autocomplétionQuestion + setValidator()

Guide de choix : ProgressBar

SituationSolution
Itérable connu, pas de logique complexeprogressIterate()
Boucle avec logique entre les stepsprogressStart/Advance/Finish via $io
Format personnalisé, placeholders spécifiquesProgressBar directe
Total inconnumax = 0 → animation sans pourcentage

Récapitulatif des modes

InputArgumentValeur
REQUIRED1
OPTIONAL2
IS_ARRAY4
InputOptionValeurDrapeau CLI
VALUE_NONE1--flag
VALUE_REQUIRED2--opt=val
VALUE_OPTIONAL4--opt ou --opt=val
VALUE_IS_ARRAY8--opt=a --opt=b
VALUE_NEGATABLE16--opt / --no-opt
VERBOSITY_*ValeurDrapeau
QUIET16-q
NORMAL32(rien)
VERBOSE64-v
VERY_VERBOSE128-vv
DEBUG256-vvv

4 anti-patterns à éviter

🚫 Logique métier dans execute()
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.
🚫 Affichage dans les services du domaine
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.
🚫 Oublier isInteractive() dans les tests
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].
🚫 Ignorer le code de retour
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.