SF
{ }
Symfony · Process Component · Shell · PHP · DevOps
Symfony Process:
Shell-Prozesse steuern und überwachen

Shell-Befehle aus PHP mit exec() oder shell_exec() auszuführen ist gefährlich, schwer zu testen und bietet keine Kontrolle über Timeouts oder Ausgabe-Streaming. Die Symfony Process Component löst alle diese Probleme – mit einer klaren API für Shell-Prozesse, die in Tests mockbar ist und in der Produktion zuverlässig läuft.

16 Min. Lesezeit Process · Streaming · Timeouts · Parallelisierung · Testing Symfony 7.x · PHP 8.4 · Linux · macOS

1. Warum exec() und shell_exec() keine Option sind

exec() und shell_exec() sind die naheliegendsten PHP-Funktionen, wenn man Shell-Befehle aus PHP ausführen möchte. Beide haben jedoch fundamentale Probleme in Produktionsanwendungen. Der gravierendste: exec() übergibt Argumente an eine Shell, die sie interpretiert. Wenn Benutzereingaben auch nur teilweise in den Befehlsstring einfließen, entsteht ein klassisches Command-Injection-Sicherheitsloch. Selbst mit escapeshellarg() ist die Handhabung fehleranfällig. Die Symfony Process Component übergib Befehle als Array — jedes Argument wird automatisch und korrekt escaped, ohne Shell-Interpolation.

Ein zweites Problem: exec() wartet synchron auf den Abschluss des Prozesses ohne Timeout. Ein hängender Prozess blockiert den PHP-Worker dauerhaft. Die Symfony Process Component bietet präzise Timeout-Kontrolle: Sowohl die Gesamtlaufzeit als auch die maximale Zeit ohne Ausgabe (Idle-Timeout) sind konfigurierbar. Drittens sind exec()-Aufrufe in PHPUnit-Tests nicht mockbar — wer seine Symfony-Commands mit einem Process-Wrapper schreibt, kann in Tests eine Fake-Implementierung injizieren. Die Symfony Process Component ist das Ergebnis jahrelanger Arbeit an genau diesen Problemen und die einzige vernünftige Wahl für Shell-Integration in Symfony-Anwendungen.

2. Process Component: Grundlagen und Installation

Die Symfony Process Component ist ein standalone-Paket, das unabhängig vom Symfony-Full-Stack-Framework funktioniert. Die Installation erfolgt via Composer mit composer require symfony/process. In einem vollständigen Symfony-Projekt ist sie bereits als Teil des Framework-Bundles vorhanden. Die zentrale Klasse ist Symfony\Component\Process\Process. Der Konstruktor akzeptiert ein Array von Strings — den Befehl und seine Argumente. Niemals einen einzigen String mit Shell-Syntax übergeben: Arrays sind sicher, Strings sind unsicher.

Für Anwendungsfälle, bei denen der Befehl als String vorliegt — etwa wenn Benutzer Befehle konfigurieren können — bietet die Symfony Process Component die Klasse Process::fromShellCommandline(). Diese Variante nutzt die Shell explizit und ist damit anfälliger, aber manchmal unvermeidbar. Der wichtige Unterschied: Der Array-Konstruktor nutzt execvp() direkt (kein Shell-Intermediate), fromShellCommandline() nutzt /bin/sh. Für alle selbst kontrollierten Befehle — Git, Composer, Image-Processing-Tools, Magento-CLI — ist der Array-Konstruktor die richtige Wahl. Der Befehl wird mit $process->run() gestartet und blockiert bis zum Abschluss.


<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

/**
 * Service for running Git commands safely using the Symfony Process Component.
 */
final class GitService
{
    public function __construct(
        private readonly string $repositoryPath,
    ) {}

    /**
     * Run git pull in the repository directory.
     * Arguments as array — no shell interpolation, injection-safe.
     *
     * @throws ProcessFailedException if git pull fails
     */
    public function pull(string $remote = 'origin', string $branch = 'main'): string
    {
        // Array constructor — each argument escaped automatically, no shell involved
        $process = new Process(
            command: ['git', 'pull', $remote, $branch],
            cwd:     $this->repositoryPath,
            timeout: 120, // 2 minutes max for git pull
        );

        $process->mustRun(); // Throws ProcessFailedException on non-zero exit code

        return $process->getOutput();
    }

    /**
     * Get the current git log — non-fatal, returns null on failure.
     */
    public function getLog(int $lines = 10): ?string
    {
        $process = new Process(['git', 'log', '--oneline', "-{$lines}"], $this->repositoryPath);
        $process->run();

        return $process->isSuccessful() ? $process->getOutput() : null;
    }
}

3. Prozesse starten: run(), start() und mustRun()

Die Symfony Process Component bietet drei Hauptmethoden zum Starten von Prozessen. run() ist synchron und blockiert bis zum Abschluss. run() gibt den Exit-Code zurück und wirft keine Exception bei Fehlern — der Aufrufer prüft den Exit-Code selbst mit isSuccessful(). mustRun() ist wie run(), wirft aber automatisch eine ProcessFailedException, wenn der Exit-Code nicht Null ist. Das ist die bevorzugte Variante für Prozesse, bei denen ein Fehler eine Ausnahme in der Anwendung auslösen soll — Deployment-Schritte, Datenbankmigrationen, Build-Prozesse.

start() startet den Prozess asynchron und kehrt sofort zurück. Der rufende Code kann weiterarbeiten, während der Prozess im Hintergrund läuft. Mit $process->isRunning() prüft man den Status, mit $process->wait() wartet man auf den Abschluss. Für Echtzeit-Ausgabe während der Ausführung nutzt man $process->getIncrementalOutput() in einer Polling-Schleife. Dieser asynchrone Ansatz mit der Symfony Process Component ist die Grundlage für parallele Prozessausführung und für Fortschrittsbalken in Symfony-Console-Commands, die lange laufende Shell-Befehle ausgeben.

4. Ausgabe in Echtzeit streamen

Ein häufiger Anwendungsfall der Symfony Process Component ist das Streaming der Prozessausgabe in Echtzeit — statt zu warten, bis der Prozess abgeschlossen ist und dann die gesamte Ausgabe auf einmal zu verarbeiten. Das ist besonders wichtig für Prozesse wie composer install, npm run build oder lange laufende Datenbankmigrationen, bei denen der Nutzer oder Operator sofortiges Feedback braucht.

Die Symfony Process Component bietet zwei Mechanismen für Output-Streaming. Erstens den Callback-Parameter in run() und start(): Eine Closure wird für jeden Ausgabe-Chunk aufgerufen, mit dem Typ (Process::OUT oder Process::ERR) und dem Inhalt als Parametern. In einem Symfony-Console-Command ruft man aus dem Callback heraus die OutputInterface-Methoden auf, um die Ausgabe direkt an das Terminal weiterzuleiten. Zweitens $process->getIncrementalOutput() und $process->getIncrementalErrorOutput(), die im asynchronen Modus nur die Ausgabe seit dem letzten Aufruf zurückgeben — ideal für eine Polling-Schleife mit Progress-Tracking.


<?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\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

/**
 * Deploys the application with real-time output streaming.
 */
#[AsCommand(name: 'app:deploy', description: 'Deploy the application')]
final class DeployCommand extends Command
{
    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln('<info>Starting deployment...</info>');

        // Streaming via callback — each output chunk is forwarded immediately
        $process = new Process(
            command: ['composer', 'install', '--no-dev', '--optimize-autoloader'],
            cwd:     '/var/www/app',
            timeout: 300,
        );

        $process->run(function (string $type, string $buffer) use ($output): void {
            if ($type === Process::ERR) {
                // Write stderr as error (red in most terminals)
                $output->write("<error>{$buffer}</error>");
            } else {
                // Write stdout directly — preserves line breaks and formatting
                $output->write($buffer);
            }
        });

        if (!$process->isSuccessful()) {
            $output->writeln('<error>Deployment failed.</error>');
            return Command::FAILURE;
        }

        $output->writeln('<info>Deployment complete.</info>');
        return Command::SUCCESS;
    }
}

5. Timeouts und Idle-Timeouts kontrollieren

Timeout-Kontrolle ist einer der wichtigsten Vorteile der Symfony Process Component gegenüber nativen PHP-Funktionen. Der Standard-Timeout beträgt 60 Sekunden — nach Ablauf wird der Prozess mit SIGKILL beendet und eine ProcessTimedOutException geworfen. Der Timeout wird im Konstruktor oder über $process->setTimeout() gesetzt. Für Prozesse ohne vorhersagbare Laufzeit — ein Batchjob, der je nach Datenmenge 10 Sekunden oder 10 Minuten braucht — setzt man null für unbegrenzte Laufzeit.

Neben dem Gesamttimeout bietet die Symfony Process Component den Idle-Timeout: $process->setIdleTimeout(30) beendet den Prozess, wenn er für 30 Sekunden keine Ausgabe produziert. Das ist nützlich für Prozesse, die bei Fehlern in einer Endlosschleife stecken bleiben und keine Ausgabe mehr schreiben. In asynchronen Szenarien muss man $process->checkTimeout() manuell in der Polling-Schleife aufrufen — die Symfony Process Component prüft Timeouts nicht automatisch für asynchrone Prozesse. Für synchrone run()-Aufrufe übernimmt die Komponente die Timeout-Prüfung intern.

6. Arbeitsverzeichnis und Umgebungsvariablen

Die Symfony Process Component ermöglicht präzise Kontrolle über das Arbeitsverzeichnis und die Umgebungsvariablen eines Prozesses. Das Arbeitsverzeichnis wird als zweiter Parameter im Konstruktor übergeben oder über $process->setWorkingDirectory(). Wenn kein Verzeichnis angegeben wird, erbt der Prozess das Arbeitsverzeichnis des PHP-Prozesses — das ist in Symfony-Anwendungen oft das Projektverzeichnis, aber nicht zuverlässig definiert. Explizit zu sein verhindert Überraschungen bei Deployments.

Umgebungsvariablen werden als assoziatives Array übergeben und überschreiben die Elternprozess-Umgebung selektiv. Die Symfony Process Component mergt die übergebenen Variablen mit der aktuellen Umgebung des PHP-Prozesses, sodass nur die explizit angegebenen Werte überschrieben werden. Das ist wichtig für Prozesse wie composer install, die COMPOSER_HOME aus der Umgebung lesen, oder für Build-Prozesse, die NODE_ENV=production brauchen. Das Setzen von PATH ist häufig notwendig, wenn der Symfony-Prozess in einer eingeschränkten Umgebung läuft — etwa als Daemon oder in einem Docker-Container mit minimaler PATH-Variable.

7. Mehrere Prozesse parallel ausführen

Die Symfony Process Component bietet keine eingebaute Abstraktionsschicht für parallele Prozesse, aber das asynchrone API von start() macht die Implementierung geradlinig. Das Grundmuster: Alle Prozesse starten, PIDs oder Process-Objekte in einem Array sammeln, dann in einer Polling-Schleife alle laufenden Prozesse überwachen bis alle abgeschlossen sind. Der kritische Unterschied zu sequentiellem mustRun(): Parallele Prozesse reduzieren die Gesamtlaufzeit auf die Dauer des langsamsten Prozesses statt auf die Summe aller Prozesse.

Für begrenzte Parallelität — etwa maximal vier gleichzeitige Prozesse, um Server-Ressourcen zu schonen — implementiert man ein Worker-Pool-Muster. Eine Queue enthält alle zu verarbeitenden Items. Solange die Anzahl laufender Prozesse unter dem Maximum liegt und Items in der Queue sind, wird ein neuer Prozess gestartet. Wenn ein Prozess beendet ist, wird das nächste Item aus der Queue genommen. Die Symfony Process Component macht Exit-Codes und Ausgaben nach dem Abschluss des Prozesses über getExitCode() und getOutput() verfügbar — auch für asynchron gestartete Prozesse.


<?php

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process;

/**
 * Runs multiple shell processes in parallel with a configurable concurrency limit.
 */
final class ParallelProcessRunner
{
    /**
     * Run commands in parallel — max $concurrency processes at once.
     *
     * @param list<list<string>> $commands Array of command arrays
     * @return array<int, array{output: string, error: string, exitCode: int}>
     */
    public function run(array $commands, int $concurrency = 4, int $timeout = 300): array
    {
        $results  = [];
        $running  = [];  // Currently executing processes: [index => Process]
        $queue    = $commands;
        $index    = 0;

        while ($queue || $running) {
            // Fill up to concurrency limit
            while (count($running) < $concurrency && $queue) {
                $cmd = array_shift($queue);
                $process = new Process($cmd, timeout: $timeout);
                $process->start();
                $running[$index++] = $process;
            }

            // Poll all running processes — non-blocking check
            foreach ($running as $i => $process) {
                $process->checkTimeout(); // Triggers ProcessTimedOutException if needed

                if (!$process->isRunning()) {
                    // Process finished — collect result
                    $results[$i] = [
                        'output'   => $process->getOutput(),
                        'error'    => $process->getErrorOutput(),
                        'exitCode' => $process->getExitCode() ?? 1,
                    ];
                    unset($running[$i]);
                }
            }

            // Avoid busy-waiting — short sleep between polls
            if ($running) {
                usleep(100_000); // 100ms
            }
        }

        return $results;
    }
}

8. Fehlerbehandlung und Exit-Codes

Korrekte Fehlerbehandlung mit der Symfony Process Component beginnt damit, den Unterschied zwischen Exit-Codes zu verstehen. Exit-Code 0 bedeutet Erfolg, alle anderen Werte zeigen Fehler an — wobei die genaue Bedeutung tool-abhängig ist. isSuccessful() gibt true zurück, wenn der Exit-Code 0 ist. Für Prozesse, die Exit-Code 1 bei Warnungen und Exit-Code 2 bei Fehlern nutzen, prüft man getExitCode() direkt. Die ProcessFailedException, die mustRun() wirft, enthält das Process-Objekt und damit Zugriff auf Stdout, Stderr und den Exit-Code für detaillierte Fehlermeldungen im Logging.

Stderr und Stdout der Symfony Process Component sind standardmäßig getrennt. getOutput() gibt Stdout zurück, getErrorOutput() gibt Stderr zurück. Manche Tools schreiben Fortschrittsmeldungen nach Stderr und das eigentliche Ergebnis nach Stdout — git clone ist ein bekanntes Beispiel. Der Streaming-Callback unterscheidet zwischen Process::OUT und Process::ERR, sodass beide Streams separat verarbeitet werden können. Für Monitoring mit Monolog loggt man im Fehlerfall Befehl, Exit-Code, Stdout und Stderr als strukturierten Log-Eintrag — das gibt bei Produktionsproblemen sofort alle nötigen Informationen.

9. Process Component vs. proc_open und exec

Ein direkter Vergleich zeigt, warum die Symfony Process Component die richtige Wahl für alle nicht-trivialen Shell-Integrationen in PHP ist.

Kriterium exec() / shell_exec() proc_open() Symfony Process
Injection-Sicherheit Unsicher ohne sorgfältiges Escaping Manuell mit Sorgfalt Automatisch (Array-API)
Timeout-Kontrolle Nicht vorhanden Manuell implementieren Eingebaut (+ Idle-Timeout)
Stdout/Stderr getrennt Nein (gemischt) Ja, über file descriptors Ja, getrennte Methoden
Testbarkeit Nicht mockbar Nicht mockbar Interface + Dependency Injection
Asynchrone Ausführung Nur mit & im Shell-String Ja, manuell start() + isRunning()

Die Tabelle macht deutlich: exec() und shell_exec() haben in modernen Symfony-Anwendungen keinen Platz mehr. proc_open() ist die Low-Level-Alternative für Spezialfälle, bei denen man direkten Zugriff auf alle File-Descriptors braucht — aber für 95% der Use-Cases ist die Symfony Process Component die bessere Abstraktion. Sie vereint Sicherheit, Flexibilität und Testbarkeit in einer durchdachten API, die aktiv gepflegt und in echten Symfony-Projekten eingesetzt wird.

Mironsoft

Symfony-Entwicklung, Shell-Integration und Deployment-Automatisierung

Shell-Prozesse sicher in Symfony integrieren?

Wir implementieren Shell-Integrationen mit der Symfony Process Component – sicher, testbar und mit vollständiger Timeout- und Fehlerbehandlung für eure Deployment- und Automatisierungsprozesse.

Process-Services

Symfony-Services mit Process Component für Git, Composer, Build-Tools und Custom Scripts

Parallelisierung

Worker-Pool-Implementierungen für parallele Shell-Prozesse mit Concurrency-Kontrolle

Testing

PHPUnit-Tests für Process-abhängige Symfony-Services mit Mocks und Fake-Implementierungen

10. Zusammenfassung

Die Symfony Process Component ist die richtige Antwort auf die Frage, wie man Shell-Befehle aus PHP sicher, testbar und mit vollständiger Kontrolle ausführt. Der Array-Konstruktor verhindert Command-Injection ohne manuelles Escaping. Timeout und Idle-Timeout schützen vor hängenden Prozessen. Output-Streaming via Callback ermöglicht Echtzeit-Fortschrittsanzeige in Symfony-Console-Commands. Das asynchrone API mit start() und isRunning() ist die Grundlage für parallele Prozessausführung mit Concurrency-Kontrolle.

Der Wechsel von exec() zur Symfony Process Component ist keine optionale Optimierung, sondern eine Sicherheits- und Wartbarkeitsanforderung für jede PHP-Anwendung, die Shell-Befehle ausführt. Die Testbarkeit allein — Process-Aufrufe sind durch Dependency Injection austauschbar — rechtfertigt den Wechsel. In Kombination mit Symfony Messenger für asynchrone Hintergrundprozesse und Monolog für strukturiertes Logging entsteht eine Shell-Integration, die in der Produktion zuverlässig, im Monitoring transparent und in Tests verifizierbar ist.

Symfony Process Component — Das Wichtigste auf einen Blick

Sicherheit

Array-Konstruktor statt String — kein Shell-Intermediate, automatisches Escaping. Niemals exec() oder shell_exec() mit Benutzereingaben.

Timeout-Kontrolle

Gesamttimeout und Idle-Timeout separat konfigurierbar. null für unbegrenzte Laufzeit. Bei async Prozessen checkTimeout() in der Polling-Schleife aufrufen.

Streaming

Callback in run() für Echtzeit-Ausgabe. Process::OUT und Process::ERR getrennt verarbeiten. getIncrementalOutput() für asynchrone Polling-Loops.

Parallelisierung

start() für asynchrone Prozesse. Worker-Pool-Muster für begrenzte Parallelität. checkTimeout() in der Polling-Schleife. Ergebnisse nach isRunning() === false abrufen.

11. FAQ: Symfony Process Component

1Warum exec() in PHP unsicher?
Shell-Interpolation ermöglicht Command-Injection. Symfony Process nutzt Array-Konstruktor und execvp() direkt — kein Shell-Intermediate, automatisches Escaping.
2run() vs. mustRun()?
run() gibt Exit-Code zurück, keine Exception. mustRun() wirft ProcessFailedException bei Exit-Code != 0. mustRun() für Deployments, Migrationen.
3Ausgabe in Echtzeit streamen?
Callback in run(): $process->run(function($type, $buffer) { }). Process::OUT für Stdout, Process::ERR für Stderr. getIncrementalOutput() für async Polling-Loops.
4Timeout setzen?
setTimeout(120) für Gesamttimeout. setIdleTimeout(30) für Inaktivitäts-Timeout. Null = kein Timeout. Bei async checkTimeout() in der Polling-Schleife aufrufen.
5Parallele Prozesse ausführen?
start() für async Start. Worker-Pool-Muster: neuen Prozess starten wenn ein laufender fertig ist. checkTimeout() in der Polling-Schleife. Ergebnisse nach isRunning() === false abrufen.
6Process in PHPUnit mocken?
ProcessInterface oder eigenen Wrapper per DI injizieren. Fake-Implementierung mit vordefinierten Ausgaben und Exit-Codes in Tests einsetzen.
7Umgebungsvariablen an Prozess übergeben?
Vierter Konstruktorparameter: new Process(['cmd'], null, null, ['NODE_ENV' => 'prod']). Wird mit aktueller Umgebung gemergt. Nur angegebene Werte überschrieben.
8fromShellCommandline() verwenden?
Nur wenn Shell-Features wie Pipes benötigt werden. Nicht mit Benutzereingaben — Command-Injection-Risiko. Für kontrollierte Befehle immer Array-Konstruktor.
9Stderr und Stdout getrennt lesen?
getOutput() für Stdout. getErrorOutput() für Stderr. Beide nach run() oder nach isRunning() === false verfügbar. Standardmäßig getrennte Streams.
10Installation ohne volles Framework?
composer require symfony/process — standalone Paket ohne Framework-Abhängigkeiten. Funktioniert in jedem PHP-Projekt ab PHP 8.1.