Run an External Program from PHP Without a Fragile Shell Command
My original article used PHP’s exec() function to start a Perl script and print its output. The example explained the function’s output and return-code arguments, but it did not define a security boundary or handle failures.
Starting another program is sometimes the right solution. Building a command string from request data is not.
I First Check Whether I Need a Process
I prefer a library or service API when one exists. It gives me typed inputs, structured errors, and fewer operating-system differences.
An external process still makes sense when I need a mature command-line tool, an existing worker, or a program written in another language. In that case, I treat the executable as part of my application’s trusted configuration. A visitor never chooses which binary is run.
I Pass Arguments as an Array
For a Composer project, the Symfony Process component provides a clear interface around process execution:
1composer require symfony/process
Here is a complete command-line example. It requires PHP CLI and Composer. In the project directory, run.php contains:
1<?php
2
3use Symfony\Component\Process\Exception\ProcessTimedOutException;
4use Symfony\Component\Process\Exception\RuntimeException as ProcessRuntimeException;
5use Symfony\Component\Process\Process;
6
7require __DIR__ . '/vendor/autoload.php';
8
9if (PHP_SAPI !== 'cli') {
10 exit('Run this example from the command line.');
11}
12
13$mode = $argv[1] ?? 'preview';
14$allowedModes = ['preview', 'export'];
15
16if (!in_array($mode, $allowedModes, true)) {
17 fwrite(STDERR, "Unsupported mode.\n");
18 exit(2);
19}
20
21$phpBinary = PHP_BINARY;
22
23$process = new Process([
24 $phpBinary,
25 __DIR__ . '/worker.php',
26 '--mode',
27 $mode,
28], __DIR__);
29$process->setTimeout(30);
30
31try {
32 $process->mustRun();
33 echo $process->getOutput();
34} catch (ProcessTimedOutException $exception) {
35 fwrite(STDERR, "The worker exceeded its time limit.\n");
36 exit(1);
37} catch (ProcessRuntimeException $exception) {
38 fwrite(STDERR, "The worker could not complete.\n");
39 exit(1);
40}
Alongside it, worker.php contains a small demonstration that prints the accepted mode. It does not export any data:
1<?php
2
3$options = getopt('', ['mode:']);
4$mode = $options['mode'] ?? '';
5
6if (!in_array($mode, ['preview', 'export'], true)) {
7 fwrite(STDERR, "Unsupported mode.\n");
8 exit(2);
9}
10
11echo "Mode: {$mode}\n";
Running php run.php preview prints Mode: preview. An unsupported argument exits before starting the worker. A worker failure and a timeout produce separate, short error messages.
The executable and each argument are separate array elements. PHP_BINARY works here because the parent is explicitly a CLI script. In a web application, I configure and verify a PHP CLI executable separately. Argument arrays do not replace input validation or restrict what the chosen program can do.
This demonstration has a fixed, small output. A timeout alone does not limit output size, and Symfony inherits the parent’s environment by default. Before using a different worker, I review its output volume, inherited secrets, and permissions. The checks below describe that additional work.
Native PHP Can Also Avoid a Command String
When adding a dependency is not justified, proc_open() accepts the command as an array on PHP 7.4 and later. PHP can then start the process directly and manage its arguments without passing a command string through a shell.
proc_open() also exposes standard input, standard output, and standard error through descriptors. That control is useful, but it creates more code to close pipes, collect output, and release the process correctly. I use it when I need that control and test every exit path.
The older exec() function still returns the last output line and can populate an output array and result code. It accepts only a command string, so any variable argument requires careful shell handling. I avoid it when an argument-array API can do the job.
I Define the Operating Limits
The code that starts a process is only one part of the design. I also define:
- a fixed executable or a short allowlist;
- validated argument values;
- a dedicated working directory;
- the minimum environment variables needed;
- a timeout and output-size limit;
- permissions that do not exceed the task;
- separate handling for standard output, standard error, and the exit code;
- logs that do not expose secrets or personal data.
I never run the web application as root. I also avoid starting a long task inside an HTTP request. Work that can outlive the request belongs in a queue with retry rules, duplicate protection, and a visible result.
I Test Failure, Not Only Output
My original example proved that PHP could print the lines returned by a Perl script. A production test must also cover an unknown mode, a missing executable, a non-zero exit code, a timeout, large output, and a process that writes only to standard error.
The useful idea remains the same: PHP can coordinate an existing program. The current version adds the boundary that the first article lacked. The application chooses the program, validates each argument, limits execution, and treats every failure as data to handle.
Pierre-Henry Soria
#PHP #Symfony Process #Security #Subprocesses #Backend Development #Reliability