SF
{ }
Symfony · Console · CLI · PHP-Attribute · Testing
Symfony Console:
Typsichere Commands mit Attributen

Symfony Console Commands sind oft die ersten Helfer, die in Produktionssystemen direkt auf Daten zugreifen. Sie verdienen dieselbe Sorgfalt wie HTTP-Controller: typsichere Input-Verarbeitung, klare Output-Strukturierung, aussagekräftige Exit-Codes und vollständige Testbarkeit.

15 Min. Lesezeit #[AsCommand] · Argumente · Optionen · Progress Bar · Testing Symfony 6.x / 7.x · PHP 8.2+

1. Warum typsichere Console Commands wichtig sind

Ein Symfony Console Command greift direkt auf die Produktionsdatenbank zu, versendet E-Mails in Masse oder importiert Tausende Datensätze. Ein Fehler in der Eingabeverarbeitung — eine falsch interpretierte Option, ein übersehenes Pflichtargument oder ein falscher Exit-Code — kann zu Datenverlust oder schwer diagnoszierbarem Fehlverhalten führen. Im Gegensatz zu HTTP-Requests gibt es keinen Browser, der einen lesbaren Fehler anzeigt, und keinen HTTP-Status-Code, den Monitoring-Systeme abfragen können. Symfony Console bietet alle notwendigen Mittel für robuste CLI-Tools — sie werden nur selten konsequent genutzt.

Typsichere Input-Verarbeitung in Symfony Console bedeutet: Argumente und Optionen explizit mit Typen deklarieren, Eingaben im initialize()-Hook validieren bevor die eigentliche Logik startet, Exit-Codes konsequent nutzen (0 für Erfolg, 1 für Fehler, 2 für Missbrauch des Commands) und Output-Sektionen für übersichtliche strukturierte Ausgabe verwenden. Diese Praktiken machen Commands testbar — ein Command, der seine Inputs klar deklariert und Outputs über SymfonyStyle schreibt, lässt sich mit CommandTester vollständig isoliert testen, ohne einen Terminal zu öffnen.

2. #[AsCommand]: Deklarativer Command-Name

Das #[AsCommand]-Attribut in Symfony Console ist seit Symfony 5.3 der empfohlene Weg, Commands zu registrieren. Es ersetzt die protected static $defaultName-Property und die Konfiguration in configure(). Der Attribute-Ansatz ist cleaner und vom Service-Container direkt lesbar ohne den Command zu instanziieren — das verbessert die Performance des bin/console list-Befehls, weil nicht alle Command-Klassen instanziiert werden müssen, um ihre Namen zu ermitteln. Mit aliases lassen sich Kurzformen definieren, hidden: true verbirgt einen Command aus der Befehlsliste ohne ihn zu deaktivieren.

Das description-Argument des #[AsCommand]-Attributs wird direkt in der Symfony Console-Hilfe angezeigt. Es sollte kurz und präzise den Zweck des Commands beschreiben — kein Satz-Ende-Punkt, maximal eine Zeile. Eine vollständige, mehrzeilige Beschreibung mit Verwendungsbeispielen gehört in den $this->setHelp()-Aufruf in configure(). Der Namespace in Command-Namen — der Teil vor dem Doppelpunkt, also app in app:user:sync — gruppiert verwandte Commands in der Ausgabe von bin/console list app. Das fördert Auffindbarkeit in Projekten mit vielen Commands.


<?php

declare(strict_types=1);

namespace App\Command;

use App\Service\UserSyncService;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

/**
 * Synchronizes users from an external CRM system into the local database.
 * Supports dry-run mode and batch size configuration.
 */
#[AsCommand(
    name: 'app:user:sync',
    description: 'Sync users from external CRM to local database',
    aliases: ['app:sync-users'],
    hidden: false,
)]
final class UserSyncCommand extends Command
{
    // Inject services via constructor — Command is a regular Symfony service
    public function __construct(
        private readonly UserSyncService $syncService,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addArgument('source', InputArgument::REQUIRED, 'CRM source identifier (e.g. crm-de, crm-at)')
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulate without writing to database')
            ->addOption('batch-size', 'b', InputOption::VALUE_REQUIRED, 'Records per batch', 100)
            ->addOption('since', null, InputOption::VALUE_OPTIONAL, 'Sync records changed since date (Y-m-d)')
            ->setHelp(<<<'HELP'
The <info>app:user:sync</info> command synchronizes users from the configured CRM.

  <info>php bin/console app:user:sync crm-de</info>
  <info>php bin/console app:user:sync crm-de --dry-run</info>
  <info>php bin/console app:user:sync crm-de --batch-size=50 --since=2026-01-01</info>
HELP
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        // ... implementation
        return Command::SUCCESS;
    }
}

3. Argumente und Optionen typsicher deklarieren

Symfony Console unterscheidet zwischen Argumenten und Optionen. Argumente sind positionelle Pflicht- oder optionale Werte (command arg1 arg2), Optionen sind benannte Parameter mit oder ohne Wert (--option oder --option=value). Argumente werden mit addArgument() deklariert, mit einem Modus (REQUIRED, OPTIONAL, IS_ARRAY) und einer Beschreibung. Optionen werden mit addOption() deklariert, mit Kurzform (-b), Modus (VALUE_NONE, VALUE_REQUIRED, VALUE_OPTIONAL, VALUE_IS_ARRAY), Beschreibung und Standardwert.

Die Herausforderung bei Symfony Console-Input ist die fehlende Typisierung: $input->getArgument('batch-size') gibt immer einen String zurück, auch wenn ein Integer erwartet wird. Das korrekte Pattern: alle Inputs im initialize()-Hook lesen, in die richtigen PHP-Typen konvertieren und als Klassenproperties speichern. Die execute()-Methode arbeitet dann ausschließlich mit den typisierten Properties, nicht mit dem rohen InputInterface-Objekt. Das macht die Typisierung explizit und die Tests einfacher, weil man Properties direkt prüfen kann.

4. Input lesen und Output strukturieren

SymfonyStyle ist die wichtigste Output-Abstraktion in Symfony Console. Es bietet strukturierte Methoden für alle gebräuchlichen Output-Typen: $io->title() für die Command-Überschrift, $io->section() für Zwischenüberschriften, $io->success() für grüne Erfolgsmeldungen, $io->warning() für gelbe Warnungen, $io->error() für rote Fehlermeldungen. Listing-Daten gibt man mit $io->listing() aus, tabellarische Daten mit $io->table(['Spalte 1', 'Spalte 2'], $rows). Diese Methoden passen ihre Ausgabe automatisch an den Verbosity-Level an.

Das Verbosity-System in Symfony Console ermöglicht mehrstufige Ausgaben. -v aktiviert VERBOSITY_VERBOSE, -vv aktiviert VERBOSITY_VERY_VERBOSE und -vvv aktiviert VERBOSITY_DEBUG. Im Command prüft man $output->isVerbose() und $output->isVeryVerbose(), bevor ausführliche Debugging-Informationen ausgegeben werden. Die Standardausgabe ist dabei immer sauber und minimal — Entwickler und Monitoring-Systeme können mit -v mehr Details abrufen, ohne den Command-Code zu ändern. Das ist das CLI-Äquivalent zum Log-Level in HTTP-Applikationen.


<?php

declare(strict_types=1);

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:user:sync', description: 'Sync users from external CRM')]
final class UserSyncCommand extends Command
{
    // Typed properties — populated in initialize(), used in execute()
    private string $source = '';
    private bool $isDryRun = false;
    private int $batchSize = 100;
    private ?\DateTimeImmutable $since = null;

    protected function configure(): void
    {
        $this
            ->addArgument('source', InputArgument::REQUIRED, 'CRM source identifier')
            ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Simulate without writing')
            ->addOption('batch-size', 'b', InputOption::VALUE_REQUIRED, 'Records per batch', '100')
            ->addOption('since', null, InputOption::VALUE_OPTIONAL, 'Changed since date (Y-m-d)');
    }

    protected function initialize(InputInterface $input, OutputInterface $output): void
    {
        // Read and cast all inputs here — execute() only uses typed properties
        $this->source = (string) $input->getArgument('source');
        $this->isDryRun = (bool) $input->getOption('dry-run');
        $this->batchSize = (int) $input->getOption('batch-size');

        $sinceStr = $input->getOption('since');
        if ($sinceStr !== null) {
            $this->since = new \DateTimeImmutable($sinceStr);
        }
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $io->title(sprintf('User Sync: %s', $this->source));

        if ($this->isDryRun) {
            $io->warning('DRY-RUN mode active — no changes will be written to the database.');
        }

        // Verbose output: only shown with -v flag
        if ($output->isVerbose()) {
            $io->comment(sprintf('Batch size: %d | Since: %s',
                $this->batchSize,
                $this->since?->format('Y-m-d') ?? 'all time'
            ));
        }

        $io->success('Sync completed successfully.');
        return Command::SUCCESS;
    }
}

5. Eingaben validieren und Exit-Codes richtig nutzen

Die Validierung von Command-Inputs sollte im interact()- oder initialize()-Hook passieren, nicht in execute(). Der initialize()-Hook eignet sich für programmatische Validierung: Prüfen ob eine Datei existiert, ob ein Datenbankverbindungsparameter valide ist, ob ein Enum-Wert korrekt ist. Bei Validierungsfehlern wirft man eine \InvalidArgumentException, die Symfony Console abfängt und als Fehlermeldung mit dem korrekt roten Fehler-Format ausgibt. Das verhindert, dass execute() mit invaliden Inputs gestartet wird und mitten in einer Datenbankoperation fehlschlägt.

Exit-Codes sind das Rückkanal-System für Shell-Skripting und Monitoring. Symfony Console definiert drei Konstanten: Command::SUCCESS (0) für erfolgreiche Ausführung, Command::FAILURE (1) für Fehler während der Ausführung, Command::INVALID (2) für falsche Argumente oder Konfiguration. CI/CD-Pipelines und Monitoring-Systeme werten Exit-Codes aus — ein Command, der bei Fehlern immer 0 zurückgibt, verbirgt Probleme. Cronjob-Monitoring-Tools wie Cronitor oder Healthchecks.io werten Exit-Codes aus und alarmieren bei Nicht-Null-Rückgaben.

6. Progress Bar und Tabellen für lange Operationen

Lange laufende Symfony Console-Commands ohne Feedback wirken wie eingefroren. Die SymfonyStyle-Progress-Bar bietet eine einfache API: $io->progressStart($total) initialisiert die Bar, $io->progressAdvance() inkrementiert sie, $io->progressFinish() beendet sie sauber. Für komplexere Anpassungen — Farben, Placeholder, mehrstufige Progress — verwendet man direkt die ProgressBar-Klasse, die Zugriff auf Format-Strings und Setzen beliebiger Schrittgrößen bietet.

Tabellen in Symfony Console formatieren strukturierte Daten übersichtlich. $io->table(['ID', 'Name', 'Status'], $rows) erzeugt automatisch spaltenbreite Tabellenränder. Für sehr große Datensätze sind Tabellen ungeeignet — hier genügen strukturierte Einzeilen-Ausgaben mit Verbosity-Kontrolle. Der Unterschied zwischen einer guten und einer schlechten CLI-Erfahrung liegt oft in diesen Details: ein informatives Summary nach der Verarbeitung ($io->info("Processed 1.247 records in 3.2s")), eine Progress Bar bei langen Batch-Jobs und eine klare Fehlermeldung mit dem genauen Record der fehlgeschlagen ist.

7. Interaktive Commands mit Bestätigungen und Prompts

Destruktive Symfony Console-Commands — Datenbank-Truncates, Massen-Deletes, Produktionsdaten-Modifikationen — sollten eine Bestätigung erfordern, bevor sie ausgeführt werden. $io->confirm('Wirklich alle 5.000 Datensätze löschen?', false) gibt einen Boolean zurück: Der zweite Parameter ist der Standardwert, der bei direktem Enter oder bei --no-interaction genutzt wird. Mit --no-interaction-Flag laufen Commands nicht-interaktiv in CI/CD-Pipelines, wo keine Tastatureingabe möglich ist — der Standardwert entscheidet dann automatisch.

Interaktive Auswahl aus einer Liste bietet $io->choice('Welche Umgebung?', ['dev', 'staging', 'prod'], 'dev'). Freitext-Eingaben mit Validierung ermöglicht $io->ask('E-Mail-Adresse?', null, function(string $answer) { ... }) — der dritte Parameter ist eine Validierungsclosure, die bei invalider Eingabe eine Exception wirft und den Prompt wiederholt. Symfony Console unterscheidet dabei zwischen ask() für Freitext, askHidden() für Passwörter (kein Echo im Terminal) und choice() für Auswahllisten. Interaktive Commands sollten immer auch mit --no-interaction sinnvoll konfigurierbar sein.

8. Console Commands vollständig testen

Der CommandTester aus symfony/console ist das Werkzeug für isolierte Symfony Console-Tests. Er simuliert Input und fängt Output ohne Terminal ab. $commandTester->execute(['argument' => 'wert', '--option' => 'value']) führt den Command programmatisch aus. Der Exit-Code ist über $commandTester->getStatusCode() abrufbar, die Ausgabe über $commandTester->getDisplay(). Das ermöglicht präzise Assertions: assertStringContainsString('Sync completed', $commandTester->getDisplay()) und assertSame(Command::SUCCESS, $commandTester->getStatusCode()).

In Symfony-Integrationstests holt man den Command aus dem DI-Container via $this->getContainer()->get(UserSyncCommand::class) — der Command ist ein vollständig instanziierter Service mit allen Abhängigkeiten. Das ermöglicht Tests, die gegen die echte Datenbank oder gegen Mock-Services laufen. Für Unit-Tests mockt man alle Abhängigkeiten und testet ausschließlich die Command-Logik. Eine bewährte Faustregel: Ein Integrationstest für den Happy Path, Unit-Tests für Fehler- und Grenzfälle. Symfony Console-Commands sind oft der letzte ungetestete Teil in PHP-Projekten — dabei sind sie dank CommandTester ähnlich einfach zu testen wie HTTP-Controller.


<?php

declare(strict_types=1);

namespace App\Tests\Command;

use App\Command\UserSyncCommand;
use App\Service\UserSyncService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester;

/**
 * Unit tests for UserSyncCommand — no database, no external services.
 */
final class UserSyncCommandTest extends TestCase
{
    private MockObject&UserSyncService $syncService;
    private CommandTester $commandTester;

    protected function setUp(): void
    {
        // Mock all external dependencies for unit testing
        $this->syncService = $this->createMock(UserSyncService::class);

        $command = new UserSyncCommand($this->syncService);
        $this->commandTester = new CommandTester($command);
    }

    public function testSuccessfulSync(): void
    {
        $this->syncService
            ->expects($this->once())
            ->method('sync')
            ->with('crm-de', false, 100);

        $exitCode = $this->commandTester->execute([
            'source' => 'crm-de',
        ]);

        self::assertSame(Command::SUCCESS, $exitCode);
        self::assertStringContainsString('Sync completed', $this->commandTester->getDisplay());
    }

    public function testDryRunDoesNotPersist(): void
    {
        $this->syncService
            ->expects($this->once())
            ->method('sync')
            ->with('crm-de', true, 50);  // isDryRun = true

        $this->commandTester->execute([
            'source' => 'crm-de',
            '--dry-run' => true,
            '--batch-size' => '50',
        ]);

        self::assertStringContainsString('DRY-RUN', $this->commandTester->getDisplay());
    }

    public function testInvalidBatchSizeReturnsFailure(): void
    {
        $exitCode = $this->commandTester->execute([
            'source' => 'crm-de',
            '--batch-size' => '-1',  // invalid negative batch size
        ]);

        self::assertSame(Command::FAILURE, $exitCode);
    }
}
Pattern Falsch Richtig Grund
Command-Name static $defaultName #[AsCommand] Kein Instanziieren für Liste nötig
Input lesen In execute() mit Cast In initialize() als Property Typsicherheit, Testbarkeit
Fehler signalisieren return 0 + echo Command::FAILURE Monitoring, Shell-Skripting
Destruktive Ops Direkt ausführen $io->confirm() Bestätigung schützt vor Unfällen
Output echo direkt SymfonyStyle Strukturiert, testbar, verbosity-aware

9. Command-Patterns im Vergleich

Die Tabelle zeigt häufige Anti-Patterns bei Symfony Console-Commands und ihre korrekten Gegenstücke. Das wichtigste: Commands sind Services und profitieren von Dependency Injection. Ein Command, der direkt auf new SomeService() zugreift oder statische Methoden aufruft, ist nicht testbar. Commands, die ihre Dependencies über den Constructor injiziert bekommen, sind isoliert testbar und profitieren von Symfony's Service-Container-Management.

Ein häufig übersehenes Detail: Symfony Console-Commands werden in der Produktion oft per Cron ausgeführt. Das bedeutet, der Command muss idempotent sein — mehrfache Ausführung mit denselben Parametern darf keine Duplikate oder inkonsistenten Zustand erzeugen. Idempotenz lässt sich durch Upserts statt Inserts, durch Checksum-Vergleiche vor Updates und durch klare State-Tracking in der Datenbank erreichen. Ein Command, der beim Neustart nach einem Abbruch zuverlässig weitermacht wo er aufgehört hat, ist ein Command, dem man auch in der Produktion vertrauen kann.

Mironsoft

Symfony Backend-Entwicklung, CLI-Tooling und Batch-Verarbeitung

Robuste Symfony Console Commands entwickeln?

Wir entwickeln typsichere, getestete und idempotente Symfony Console Commands für Datenmigration, Datenimport, Synchronisation und Batch-Verarbeitung — mit vollständiger Test-Suite und Monitoring-Integration.

CLI-Entwicklung

Typsichere Commands mit Validierung, Progress Bar und strukturiertem Output

Batch-Verarbeitung

Idempotente Import- und Sync-Commands mit Fehlerbehandlung und Restart-Logik

Command-Tests

Vollständige Test-Suite mit CommandTester für alle Pfade und Exit-Codes

10. Zusammenfassung

Symfony Console-Commands mit PHP-Attributen sind präzise, wartbar und vollständig testbar. Das #[AsCommand]-Attribut registriert Commands ohne Boilerplate-Properties. Die Trennung von Input-Lesen (initialize()) und Business-Logik (execute()) erzwingt typsichere Verarbeitung. SymfonyStyle liefert strukturierten, verbosity-gesteuerten Output. Exit-Codes kommunizieren Erfolg und Fehler an Shell-Skripte und Monitoring. Der CommandTester macht alle Pfade — Erfolg, Validierungsfehler, Serviceausfälle — isoliert testbar ohne Terminal oder echte Infrastruktur.

Commands, die Produktionsdaten manipulieren, verdienen denselben Qualitätsanspruch wie HTTP-Controller. Idempotenz, klare Fehlerbehandlung, strukturierter Output und vollständige Tests sind keine Luxus-Features, sondern Grundvoraussetzungen für CLI-Tools, die zuverlässig im Cronjob und in CI/CD-Pipelines laufen. Symfony Console bietet alle notwendigen Bausteine — die konsequente Nutzung dieser APIs entscheidet, ob ein Command ein zuverlässiges Werkzeug oder eine Blackbox in der Produktion wird.

Symfony Console Commands — Das Wichtigste auf einen Blick

#[AsCommand]

Deklarativer Command-Name ohne static $defaultName. Symfony liest das Attribut ohne Instanziierung — schnelleres bin/console list.

initialize() für Input

Alle Argumente und Optionen in initialize() lesen, casten und als typisierte Properties speichern. execute() arbeitet nur mit Properties.

Exit-Codes

Command::SUCCESS (0), Command::FAILURE (1), Command::INVALID (2). Monitoring und Shell-Skripting werten Exit-Codes aus.

CommandTester

Vollständige Tests ohne Terminal: execute(), getStatusCode(), getDisplay(). Unit-Tests mit gemockten Services, Integrationstests mit echtem Container.

11. FAQ: Symfony Console Commands

1Was ist #[AsCommand]?
PHP-Attribut zur Registrierung von Console Commands ohne static $defaultName. Symfony liest das Attribut ohne Instanziierung — schnelleres bin/console list.
2initialize() vs. execute()?
initialize() für Input-Lesen, -Casten und -Validieren. execute() für Geschäftslogik ausschließlich mit typisierten Properties — keine rohen InputInterface-Zugriffe.
3Console Commands testen?
Mit CommandTester: execute(['arg', '--opt']), getStatusCode() für Exit-Code, getDisplay() für Ausgabe. Unit-Tests mit gemockten Services, keine echte Infrastruktur nötig.
4Exit-Codes in Symfony?
Command::SUCCESS (0), Command::FAILURE (1), Command::INVALID (2). Monitoring und Shell-Skripting werten Exit-Codes aus — nie 0 bei Fehlern zurückgeben.
5Was ist SymfonyStyle?
Output-Abstraktion mit title(), success(), warning(), error(), table(), progressStart(). Verbosity-aware, strukturiert, vollständig testbar via CommandTester.
6Verbosity in Symfony Console?
-v (VERBOSE), -vv (VERY_VERBOSE), -vvv (DEBUG). Im Command $output->isVerbose() prüfen. Standard-Output minimal, Details nur bei aktivierter Verbosity.
7Interaktive Commands?
$io->confirm() für Bestätigungen. $io->choice() für Auswahllisten. $io->ask() mit Validierungsclosure für Freitexteingaben. Immer --no-interaction-kompatibel halten.
8Services in Commands injizieren?
Constructor Injection mit readonly-Properties und parent::__construct(). Commands sind reguläre Symfony-Services — der Container verwaltet alle Dependencies.
9Idempotenz bei Commands?
Mehrfache Ausführung mit gleichen Parametern erzeugt dasselbe Ergebnis. Upserts statt Inserts, Checksums vor Updates, State-Tracking für sicheren Restart nach Abbruch.
10Progress Bar anzeigen?
$io->progressStart($total) → $io->progressAdvance() → $io->progressFinish(). Für eigene Formate: new ProgressBar($output, $total) mit setFormat() und setMessage().