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.
Table of Contents
- 1. Why exec() and shell_exec() are not an option
- 2. Process Component: basics and installation
- 3. Running processes: run(), start() and mustRun()
- 4. Streaming output in real time
- 5. Controlling timeouts and idle timeouts
- 6. Working directory and environment variables
- 7. Running multiple processes in parallel
- 8. Error handling and exit codes
- 9. Process Component vs. proc_open and exec
- 10. Summary
- 11. FAQ
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.