symfony/process
Symfony Process executes commands in sub-processes, giving PHP apps a clean, portable API for running external programs. It handles input/output streams, timeouts, environment variables, working directories, and exit codes, so you can run shell commands safely and reliably across platforms.
Installation — pin to the security-fixed line:
composer require symfony/process:^5.4.54
v5.4.54 is a security release. Every project that shells out (deployments, queue workers, backups, git, rsync, mysqldump, Node/Python tooling) should be on >=5.4.54, or on a patched 6.4/7.x equivalent. No service provider, no config file — Composer autoload is all you need.
First use case — run a command and fail loudly:
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
$process = new Process(['ls', '-la']);
$process->run();
if (!$process->isSuccessful()) {
throw new ProcessFailedException($process);
}
echo $process->getOutput();
Where to look first
Process class: run(), mustRun(), start(), wait(), getOutput(), getErrorOutput(), isSuccessful(), getExitCode().Process::setEnv() / inheritEnvironmentVariables() — the area that changed in this release.['git', 'status']), never a single string with interpolation. The array form bypasses the shell entirely, which is your primary injection defence.// Blocking, buffered
$process = new Process(['php', 'artisan', 'migrate', '--force']);
$process->setTimeout(300);
$process->mustRun(); // throws ProcessFailedException on non-zero exit
// Streaming with the built-in callback — preferred over busy-looping on getOutput()
$process->run(function (string $type, string $buffer) {
$type === Process::ERR
? Log::channel('deploy')->error($buffer)
: Log::channel('deploy')->info($buffer);
});
Prefer run(callable) or start() + wait(callable) over while ($process->isRunning()) { echo $process->getOutput(); }. The latter re-prints accumulated output and burns CPU; incremental output is delivered via getIncrementalOutput() at best.
$process->setEnv([
'APP_ENV' => 'staging',
'DB_HOST' => config('database.connections.mysql.host'),
'CONFIG' => json_encode(['key' => 'value']), // complex data must be pre-encoded
]);
Since 5.4.54, invalid env var names and non-scalar values are silently ignored rather than propagated or stringified. That means:
array value no longer becomes "Array" in the child process — it just vanishes. json_encode() explicitly, always.// Scrub inherited context when spawning from a web request
$process->inheritEnvironmentVariables(false);
$process->setEnv($allowListedEnvsOnly);
try {
$process->mustRun();
} catch (ProcessFailedException $e) {
report($e);
$this->error($process->getErrorOutput());
} catch (ProcessTimedOutException $e) {
// child was SIGKILLed — assume partial side effects, clean up
}
ProcessFailedException's message embeds full command line and stdout/stderr. If you pass secrets via env or arguments, sanitise before report() — getCommandLine() is a config-leak waiting to happen in Sentry/Bugsnag.
class DeployCommand extends Command
{
protected $signature = 'deploy:run';
public function handle(): int
{
$process = new Process([base_path('vendor/bin/deployer'), 'deploy']);
$process->setTimeout(1800);
$process->setWorkingDirectory(base_path());
$process->run(fn ($type, $buffer) => $this->output->write($buffer));
return $process->isSuccessful() ? self::SUCCESS : self::FAILURE;
}
}
Wrap it in a small service (ProcessRunner) bound in the container so tests can fake it; otherwise every feature test that touches a console command shells out for real. Inject config (timeout, binary paths) rather than calling env() inside the class — env() returns null once config:cache has run.
$pool = collect($queues)->map(function (string $queue) {
$p = new Process(['php', 'artisan', 'queue:work', "--queue={$queue}", '--stop-when-empty']);
$p->setTimeout(300);
$p->start();
return $p;
});
foreach ($pool as $p) {
$p->wait(); // collect exit codes afterwards
}
Cap concurrency explicitly. Process does not pool threads; five start() calls are five OS processes. In Octane/Swoole workers, also cap by config('app.max_parallel') or you'll fork-bomb the box under load.
$process = PHP_OS_FAMILY === 'Windows'
? new Process(['cmd', '/c', 'dir']) // cmd.exe is now resolved by absolute path (5.4.54)
: new Process(['ls', '-la']);
5.4.54 fixes the cmd.exe fallback to use an absolute path, so Windows no longer picks up a shadowed cmd.exe from PATH/cwd — relevant on shared CI runners and Windows hosts with a poisoned working directory.
Upgrade is not optional if you run PHP-FPM/CGI. #65738 Stop leaking CGI/FastCGI request-context vars to subprocesses fixes a real information leak: HTTP_*, REQUEST_URI, auth headers, cookie-derived values previously flowed into child processes. If you were relying on the old inheritEnvironmentVariables(false) + array_filter($_ENV, ...) workaround, you can drop it — but only after pinning ^5.4.54. On older versions, keep the workaround.
Env filtering is now silent. #65739 Ignore invalid env var names and non-scalar env values removes the "Array to string conversion" warning and the malformed-name crash — and with them your only signal that something was wrong. Validate your own env map before setEnv(); a typo'd key means a child process running with defaults.
Windows cmd.exe resolution changed. If you shell out via Process::fromShellCommandline() on Windows, the command wrapper is now resolved from an absolute path. Tests asserting on getCommandLine() output, or scripts that prepended their own cmd.exe to PATH, may behave differently.
Timeouts kill, they don't ask. setTimeout() sends SIGKILL after SIGTERM on POSIX. A mysqldump or rsync killed mid-flight leaves temp files and locks. Catch ProcessTimedOutException and clean up explicitly; don't assume idempotency.
Never build a command string from user input. Process::fromShellCommandline() re-enables shell metacharacters (;, &&, $(...)). The array constructor does not. If you must interpolate, run values through escapeshellarg() and treat it as a last resort.
getOutput() is cumulative. Calling it in a while ($process->isRunning()) loop re-emits the entire buffer each tick. Use the run(fn ($type, $buffer) => ...) callback or getIncrementalOutput()/getIncrementalErrorOutput() with disableOutput() off.
PTY mode (usePty(true)) merges stdout and stderr and is Linux/macOS-only. Don't enable it for commands whose exit semantics you parse from stderr; do enable it for anything expecting a TTY (ssh, rsync progress bars, interactive prompts).
Working directory defaults to the current process cwd. Under queue:work or Octane that's public/, not the app root. Always setWorkingDirectory(base_path()) for Artisan invocations, or you'll get confusing "command not found" for relative binaries.
env() inside a process-building class breaks under config:cache. Resolve env-dependent values in a config file and inject them; Process wrappers are prime offenders because they're usually built lazily at runtime.
Extension points worth knowing: setInput() for stdin piping, checkTimeout() for long-lived processes you self-poll, Process::setIdleTimeout() (6.x+) to kill hung-but-alive children, and custom Process subclasses when you need a shared command prefix (e.g. php artisan).
How can I help you explore Laravel packages today?