← 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 — 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.
08 — 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.
09 — 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.