Symfony Process: Control and Monitor Shell Processes
AI generated
SF
{ }
Symfony · Process Component · Shell · PHP · DevOps
Symfony Process:
Control and Monitor Shell Processes

Running shell commands from PHP with exec() or shell_exec() is dangerous, hard to test and gives you no control over timeouts or output streaming. The Symfony Process Component solves all of these problems with a clear API for shell processes that is mockable in tests and reliable in production.

16 min read Process · Streaming · Timeouts · Parallelization · Testing Symfony 7.x · PHP 8.4 · Linux · macOS

1. Why exec() and shell_exec() are not an option

exec() and shell_exec() are the most obvious PHP functions when you want to run shell commands from PHP. Both, however, have fundamental problems in production applications. The most severe: exec() passes arguments to a shell that interprets them. If user input flows into the command string even partially, a classic command injection security hole opens up. Even with escapeshellarg() the handling is error-prone. The Symfony Process Component passes commands as an array, each argument is escaped automatically and correctly, without any shell interpolation.

A second problem: exec() waits synchronously for the process to finish with no timeout. A hanging process blocks the PHP worker permanently. The Symfony Process Component offers precise timeout control, both the total runtime and the maximum time without output (idle timeout) are configurable. Third, exec() calls are not mockable in PHPUnit tests, whoever writes their Symfony commands with a Process wrapper can inject a fake implementation in tests. The Symfony Process Component is the result of years of work on exactly these problems and the only sensible choice for shell integration in Symfony applications.

2. Process Component: basics and installation

The Symfony Process Component is a standalone package that works independently of the full Symfony framework stack. Installation happens via Composer with composer require symfony/process. In a full Symfony project it is already present as part of the framework bundle. The central class is Symfony\Component\Process\Process. The constructor accepts an array of strings, the command and its arguments. Never pass a single string with shell syntax, arrays are safe, strings are not.

For use cases where the command exists as a string, for example when users can configure commands, the Symfony Process Component offers the class Process::fromShellCommandline(). This variant explicitly uses the shell and is therefore more exposed, but sometimes unavoidable. The important difference: the array constructor uses execvp() directly (no shell intermediate), fromShellCommandline() uses /bin/sh. For all self-controlled commands, Git, Composer, image processing tools, Magento CLI, the array constructor is the right choice. The command is started with $process->run() and blocks until it completes.


<?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. Running processes: run(), start() and mustRun()

The Symfony Process Component offers three main methods for starting processes. run() is synchronous and blocks until completion. run() returns the exit code and does not throw an exception on failure, the caller checks the exit code itself with isSuccessful(). mustRun() works like run(), but automatically throws a ProcessFailedException if the exit code is not zero. That is the preferred variant for processes where a failure should raise an exception in the application, deployment steps, database migrations, build processes.

start() starts the process asynchronously and returns immediately. The calling code can keep working while the process runs in the background. With $process->isRunning() you check the status, with $process->wait() you wait for completion. For real-time output during execution you use $process->getIncrementalOutput() in a polling loop. This asynchronous approach with the Symfony Process Component is the foundation for parallel process execution and for progress bars in Symfony console commands that print long-running shell commands.

4. Streaming output in real time

A common use case for the Symfony Process Component is streaming process output in real time, instead of waiting until the process finishes and then processing the entire output at once. That is especially important for processes like composer install, npm run build or long-running database migrations, where the user or operator needs immediate feedback.

The Symfony Process Component offers two mechanisms for output streaming. First, the callback parameter in run() and start(), a closure is called for every output chunk, with the type (Process::OUT or Process::ERR) and the content as parameters. In a Symfony console command you call the OutputInterface methods from within the callback to forward the output directly to the terminal. Second, $process->getIncrementalOutput() and $process->getIncrementalErrorOutput(), which in asynchronous mode return only the output since the last call, ideal for a polling loop with 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. Controlling timeouts and idle timeouts

Timeout control is one of the most important advantages of the Symfony Process Component over native PHP functions. The default timeout is 60 seconds, once it expires the process is terminated with SIGKILL and a ProcessTimedOutException is thrown. The timeout is set in the constructor or via $process->setTimeout(). For processes without a predictable runtime, a batch job that might take 10 seconds or 10 minutes depending on data volume, you set null for an unlimited runtime.

Besides the overall timeout, the Symfony Process Component offers the idle timeout, $process->setIdleTimeout(30) terminates the process if it produces no output for 30 seconds. That is useful for processes that get stuck in an infinite loop on error and stop writing output. In asynchronous scenarios you have to call $process->checkTimeout() manually in the polling loop, the Symfony Process Component does not check timeouts automatically for asynchronous processes. For synchronous run() calls the component handles the timeout check internally.

6. Working directory and environment variables

The Symfony Process Component allows precise control over a process's working directory and environment variables. The working directory is passed as the second constructor parameter or via $process->setWorkingDirectory(). If no directory is given, the process inherits the working directory of the PHP process, which in Symfony applications is often the project directory, but is not reliably defined. Being explicit prevents surprises during deployments.

Environment variables are passed as an associative array and selectively override the parent process environment. The Symfony Process Component merges the passed variables with the current environment of the PHP process, so that only the explicitly given values are overridden. That matters for processes like composer install, which reads COMPOSER_HOME from the environment, or for build processes that need NODE_ENV=production. Setting PATH is often necessary when the Symfony process runs in a restricted environment, for example as a daemon or in a Docker container with a minimal PATH variable.

7. Running multiple processes in parallel

The Symfony Process Component does not offer a built-in abstraction layer for parallel processes, but the asynchronous API of start() makes the implementation straightforward. The basic pattern: start all processes, collect PIDs or Process objects in an array, then monitor all running processes in a polling loop until all are finished. The critical difference to sequential mustRun(): parallel processes reduce the total runtime to the duration of the slowest process instead of the sum of all processes.

For bounded concurrency, for example a maximum of four simultaneous processes to spare server resources, you implement a worker pool pattern. A queue holds all items to be processed. As long as the number of running processes is below the maximum and there are items in the queue, a new process is started. When a process finishes, the next item is taken from the queue. The Symfony Process Component makes exit codes and outputs available via getExitCode() and getOutput() after the process finishes, even for processes started asynchronously.


<?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. Error handling and exit codes

Correct error handling with the Symfony Process Component starts with understanding the difference between exit codes. Exit code 0 means success, all other values indicate an error, though the exact meaning is tool-dependent. isSuccessful() returns true when the exit code is 0. For processes that use exit code 1 for warnings and exit code 2 for errors, you check getExitCode() directly. The ProcessFailedException thrown by mustRun() contains the Process object and therefore access to stdout, stderr and the exit code for detailed error messages in logging.

Stderr and stdout of the Symfony Process Component are separated by default. getOutput() returns stdout, getErrorOutput() returns stderr. Some tools write progress messages to stderr and the actual result to stdout, git clone is a well-known example. The streaming callback distinguishes between Process::OUT and Process::ERR, so both streams can be processed separately. For monitoring with Monolog you log the command, exit code, stdout and stderr as a structured log entry on failure, that gives you all the necessary information immediately during production issues.

9. Process Component vs. proc_open and exec

A direct comparison shows why the Symfony Process Component is the right choice for all non-trivial shell integrations in PHP.

Criterion exec() / shell_exec() proc_open() Symfony Process
Injection safety Unsafe without careful escaping Manual, with care Automatic (array API)
Timeout control Not available Implement manually Built in (+ idle timeout)
Stdout/stderr separated No (mixed) Yes, via file descriptors Yes, separate methods
Testability Not mockable Not mockable Interface + dependency injection
Asynchronous execution Only with & in the shell string Yes, manual start() + isRunning()

The table makes it clear: exec() and shell_exec() no longer have a place in modern Symfony applications. proc_open() is the low-level alternative for special cases where you need direct access to all file descriptors, but for 95% of use cases the Symfony Process Component is the better abstraction. It combines safety, flexibility and testability in a well-thought-out API that is actively maintained and used in real Symfony projects.

Mironsoft

Symfony development, shell integration and deployment automation

Need to integrate shell processes safely in Symfony?

We implement shell integrations with the Symfony Process Component, safe, testable and with complete timeout and error handling for your deployment and automation processes.

Process services

Symfony services with the Process Component for Git, Composer, build tools and custom scripts

Parallelization

Worker pool implementations for parallel shell processes with concurrency control

Testing

PHPUnit tests for Process-dependent Symfony services with mocks and fake implementations

10. Summary

The Symfony Process Component is the right answer to the question of how to run shell commands from PHP safely, testably and with complete control. The array constructor prevents command injection without manual escaping. Timeout and idle timeout protect against hanging processes. Output streaming via callback enables real-time progress display in Symfony console commands. The asynchronous API with start() and isRunning() is the foundation for parallel process execution with concurrency control.

Switching from exec() to the Symfony Process Component is not an optional optimization, but a security and maintainability requirement for any PHP application that runs shell commands. Testability alone, process calls are swappable through dependency injection, justifies the switch. Combined with Symfony Messenger for asynchronous background processes and Monolog for structured logging, you get a shell integration that is reliable in production, transparent in monitoring and verifiable in tests.

Symfony Process Component, the essentials at a glance

Security

Array constructor instead of string, no shell intermediate, automatic escaping. Never exec() or shell_exec() with user input.

Timeout control

Overall timeout and idle timeout configurable separately. null for unlimited runtime. For async processes call checkTimeout() in the polling loop.

Streaming

Callback in run() for real-time output. Handle Process::OUT and Process::ERR separately. getIncrementalOutput() for asynchronous polling loops.

Parallelization

start() for asynchronous processes. Worker pool pattern for bounded concurrency. checkTimeout() in the polling loop. Collect results once isRunning() === false.

11. FAQ: Symfony Process Component

1Why is exec() unsafe in PHP?
Shell interpolation allows command injection. Symfony Process uses the array constructor and execvp() directly, no shell intermediate, automatic escaping.
2run() vs. mustRun()?
run() returns the exit code, no exception. mustRun() throws ProcessFailedException on exit code != 0. mustRun() for deployments, migrations.
3Stream output in real time?
Callback in run(): $process->run(function($type, $buffer) { }). Process::OUT for stdout, Process::ERR for stderr. getIncrementalOutput() for async polling loops.
4Set a timeout?
setTimeout(120) for overall timeout. setIdleTimeout(30) for inactivity timeout. Null = no timeout. For async processes call checkTimeout() in the polling loop.
5Run parallel processes?
start() for async start. Worker pool pattern: start a new process when a running one finishes. checkTimeout() in the polling loop. Collect results once isRunning() === false.
6Mock Process in PHPUnit?
Inject a ProcessInterface or your own wrapper via DI. Use a fake implementation with predefined outputs and exit codes in tests.
7Pass environment variables to a process?
Fourth constructor parameter: new Process(['cmd'], null, null, ['NODE_ENV' => 'prod']). Merged with the current environment. Only given values overridden.
8Use fromShellCommandline()?
Only when shell features like pipes are needed. Not with user input, command injection risk. For controlled commands always use the array constructor.
9Read stderr and stdout separately?
getOutput() for stdout. getErrorOutput() for stderr. Both available after run() or once isRunning() === false. Separate streams by default.
10Installation without the full framework?
composer require symfony/process, standalone package with no framework dependencies. Works in any PHP project from PHP 8.1 onward.