Executing external scripts securely in PHP
Use Symfony Process with an argument array to run a trusted external script without turning its arguments into shell commands. This CLI walkthrough builds a project, captures a child’s greeting, and shows how to report a failed exit or stop a slow child. The child still runs with your account’s permissions, so safe argument handling is not a sandbox.
Prerequisites
The examples below were reproduced on Linux with PHP CLI 8.5.10, Composer 2.10.3, Bash 5.3.15, and Symfony Process 7.4.19. Use this profile to reproduce the walkthrough; other runtime versions and Windows behavior are not covered here. PHP 8.5 is an actively supported PHP branch.
You need php and composer on a trusted PATH, a writable parent directory, and PHP’s
proc_open function enabled. Symfony Process uses proc_open
to start the child. Composer also needs its normal PHP extensions and network access to download
the dependency.
The runner and child use PHP’s -n flag,
which ignores php.ini. This keeps the CLI demonstration
independent of local PHP configuration; it is not a recommended configuration for an existing
application. Composer runs with its normal PHP configuration instead.
Setting up the environment
Paste this block into Bash from the directory where you want to create the project:
(
if [ -n "${COMPOSER:-}" ] || [ -n "${COMPOSER_VENDOR_DIR:-}" ]; then
printf '%s\n' 'Use the default Composer manifest and vendor directory for this example.' >&2
exit 1
fi
mkdir -- php-external-scripts &&
cd -- php-external-scripts &&
export COMPOSER_HOME="$PWD/.composer-home" &&
composer --no-plugins --no-scripts init \
--name=example/php-external-scripts \
--description='PHP child process example' \
--require='symfony/process:7.4.19' \
--no-interaction &&
composer --no-plugins --no-scripts install --no-interaction
)
The parentheses keep navigation and environment changes in a subshell. Each dependent command is
chained with &&, so a failed mkdir, cd, or Composer command stops the setup without running
later steps in the wrong directory. Your shell stays in the parent directory on success and failure.
If php-external-scripts already exists, choose a different parent directory; do not delete an
existing project to make this command succeed. A failed installation can leave the newly created
directory behind, so inspect it before deciding whether to retry elsewhere.
The local Composer home avoids inheriting global project settings or plugins, and the guard rejects
manifest and vendor-directory overrides. Composer’s install command
creates composer.lock and vendor/ in this new project. The exact Process version keeps this
example reproducible; review dependency updates separately before using it in an application.
Writing a custom script to execute
Save this as php-external-scripts/hello.php:
<?php
$name = $argv[1] ?? 'World';
$mode = $argv[2] ?? 'hello';
if ($mode === 'fail') {
fwrite(STDERR, "Example child failed.\n");
exit(23);
}
if ($mode === 'slow') {
sleep(5);
}
echo "Hello, {$name}!\n";
The default mode prints a greeting. The other two modes give us real subprocess failures to check:
fail exits with status 23, while slow waits five seconds before printing anything.
Executing the script from PHP
Save this as php-external-scripts/run-script.php:
<?php
use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
require __DIR__ . '/vendor/autoload.php';
$nameArgument = $argv[1] ?? 'Developer';
$mode = $argv[2] ?? 'hello';
if (!in_array($mode, ['hello', 'fail', 'slow'], true)) {
fwrite(STDERR, "Usage: run-script.php [name] [hello|fail|slow]\n");
exit(64);
}
$process = new Process(
[PHP_BINARY, '-n', __DIR__ . '/hello.php', $nameArgument, $mode],
__DIR__
);
$process->setTimeout(1);
try {
$process->mustRun();
echo $process->getOutput();
} catch (ProcessTimedOutException $exception) {
fwrite(STDERR, "Child exceeded the 1-second timeout.\n");
exit(124);
} catch (ProcessFailedException $exception) {
fwrite(STDERR, sprintf("Child exited with status %d.\n", $process->getExitCode()));
exit(1);
}
The PHP_BINARY constant
selects the PHP executable running this CLI script rather than looking up a second php on PATH.
The child script’s absolute path and working directory come from __DIR__, not caller input.
Composer’s local autoloader supplies the Process classes.
mustRun() throws ProcessFailedException
when the child returns a nonzero exit code. We print the captured stdout only after it succeeds.
A total timeout raises a separate ProcessTimedOutException; one second is deliberately short for
this demonstration, so choose a realistic deadline for your actual task.
Still in the parent directory, run:
php -n php-external-scripts/run-script.php
Expected stdout, with exit status 0:
Hello, Developer!
To see why separate arguments matter, pass a name containing shell syntax:
php -n php-external-scripts/run-script.php 'Developer; touch SHOULD_NOT_EXIST'
Expected stdout:
Hello, Developer; touch SHOULD_NOT_EXIST!
The semicolon is part of the name, not a command separator. This invocation does not create
SHOULD_NOT_EXIST. Keep that distinction when adapting the example: do not concatenate a name into
a command string or switch to Process::fromShellCommandline() to pass data.
Practical examples
Check a child failure
Run the same saved files with the failure mode:
php -n php-external-scripts/run-script.php Developer fail
The runner writes this to stderr and exits with status 1, without printing a greeting:
Child exited with status 23.
The child’s status and the runner’s status are different deliberately. This CLI wrapper reports any nonzero child exit as its own status 1; it does not pretend the task succeeded or forward the child’s error output to its caller.
Check a timeout
php -n php-external-scripts/run-script.php Developer slow
The runner writes this to stderr and exits with status 124:
Child exceeded the 1-second timeout.
Symfony checks the total execution timeout while waiting and stops this child before its five-second sleep finishes. There is no greeting. We do not set an idle timeout: a quiet child is not necessarily broken.
Error handling and best practices
Security considerations
An argument array is Symfony's recommended command form.
On this Linux path, its arguments are passed without shell interpolation. You do not need
escapeshellarg() around individual array elements.
That solves shell injection at this boundary, not every command-execution risk:
- Keep the executable and script under application control. This example accepts a greeting and a small allowlist of modes, not a command or script path.
- Validate arguments according to the child program. Another tool might interpret a leading hyphen
as an option or recognize special filename syntax even when the shell never sees it. Use
--only where that tool documents it as an end-of-options marker. - Run trusted code with appropriate permissions. Process does not isolate filesystem access, networking, or credentials. Do not use this example to run uploaded or otherwise untrusted code.
Comprehensive error handling
The two catches distinguish a failed child from a deadline. Invalid modes are rejected before
starting a child with usage text and status 64. Missing dependencies, a missing script, or an
unavailable proc_open indicate a setup problem, not a successful operation.
For a web application, return a fixed, sanitized error instead of exposing exception messages, stack traces, or captured child output. Keep any server-side diagnostics redacted. This tutorial is a local CLI example and does not cover PHP-FPM request handling.
Resource management
The total timeout bounds how long the runner waits for this simple child; it is not a memory limit, an output-size limit, or a guarantee that arbitrary programs leave no descendants or partial files. The greeting produces only a small amount of output. A program with large output needs a separate streaming or output-limiting design.
For durable background work, use a job queue or a service supervisor. Starting a process asynchronously is not the same as making it survive its parent, as the Process documentation explains. Image conversion and scheduled jobs need their own input, output, and deployment policies; this walkthrough does not supply those workflows.
Conclusion
Before replacing hello.php with a real task, decide which arguments it accepts, what its success
means, and what should happen after failure or timeout. Keep those checks alongside the argument
array rather than treating safe quoting as a complete security policy.
