Why prefer proc_open() with an array command in PHP, and how do you read stdout and stderr and get the exit code without deadlocking?
answer
- no shell in between
- array form since PHP 7.4
- descriptor_spec: pipe, file, redirect, null
- pipe buffers fill and block
- proc_close returns the exit code
basics
~20 sWith an array command, proc_open() runs the program without a shell, so each element is one argument and nothing needs escaping. Drain stdout and stderr together with stream_select() or redirect one, close the pipes, then proc_close() returns the exit code.
solid answer
~50 sSince PHP 7.4, `proc_open()` accepts the command as an array such as `['pdfthumb', '--width', '320', '--', $pdf, $png]`. PHP then executes the program directly instead of via `/bin/sh -c`, so there is no shell parsing and no escaping. `descriptor_spec` maps descriptors 0, 1 and 2 to `['pipe', 'r']`, `['pipe', 'w']`, a file, `['redirect', 1]` or `['null']`, and `$pipes` receives PHP's ends. The deadlock trap: pipes have a fixed OS buffer. If I read stdout to the end while the child is blocked writing a lot to stderr, both processes wait on each other forever. So I either read both together, with `stream_set_blocking(false)` and a `stream_select()` loop, or redirect stderr to a file or `['null']`. Then I close every pipe and call `proc_close()`, which waits for the child and returns its exit code, `-1` on error.
code
php · 36 lines<?php
declare(strict_types=1);
$pdf = '/var/app/uploads/report.pdf';
$png = '/var/app/thumbs/report.png';
$process = proc_open(
['pdfthumb', '--width', '320', '--', $pdf, $png], // no shell, no escaping
[1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
$pipes,
);
if ($process === false) {
throw new RuntimeException('Could not start the thumbnail tool');
}
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$out = $err = '';
while (!feof($pipes[1]) || !feof($pipes[2])) {
$read = array_filter([$pipes[1], $pipes[2]], fn ($p) => !feof($p));
$write = $except = null;
if (stream_select($read, $write, $except, 5) === false) {
break;
}
foreach ($read as $p) {
$chunk = (string) fread($p, 8192);
if ($p === $pipes[1]) {
$out .= $chunk;
} else {
$err .= $chunk;
}
}
}
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process); // waits and returns the exit statusgo deeper
Know that proc_open() starts a process with separate pipes and that passing the command as an array avoids the shell.
Explain descriptor_spec entries, the array form's escaping guarantee, and the order: write stdin, read output, close pipes, then proc_close() for the exit code.
Diagnose pipe-buffer deadlocks, read both streams with stream_select(), handle the PHP 8.3 exit-code caching change, and keep option-injection rules for array arguments.
Decide which external tools may run in web requests at all, and standardise a process wrapper with limits, logging and no shell, rather than ad-hoc calls.
## What proc_open() adds `proc_open(array|string $command, array $descriptor_spec, &$pipes, ?string $cwd = null, ?array $env_vars = null, ?array $options = null)` starts a process and hands you its standard streams as separate PHP streams. Compared with the `exec()` family you get: - **separate** stdout and stderr, plus stdin you can write to; - a working directory (`$cwd`) and an explicit environment (`$env_vars`) for the child; - a process resource you can inspect with `proc_get_status()` and stop with `proc_terminate()`; - the exit code from `proc_close()`; - and, most importantly, the **array command**. ## The array command removes the shell With a string command, PHP runs `/bin/sh -c "<string>"` on POSIX, and the shell parses it, so every dynamic part needs `escapeshellarg()`. With an array (PHP 7.4+), the manual says the process is opened directly, without going through a shell, and PHP takes care of any argument escaping. Each array element arrives in the child as exactly one argument. If the first element is a bare name, it is looked up in `PATH`. Consequences: 1. No command injection through shell syntax: `;`, `$(...)` and quotes are just bytes. 2. No escaping code to get wrong. 3. No shell features either: no pipes, globs or `2>&1`. Use descriptors instead. 4. The program still parses its own options, so a user value beginning with `-` still needs `--` or validation. Since PHP 8.3, an array without at least one non-empty element throws `ValueError`. ## Describing the streams `$descriptor_spec` is an array indexed by descriptor number (0 = stdin, 1 = stdout, 2 = stderr). Each value can be: | Spec | Meaning | |---|---| | `['pipe', 'r']` | a pipe the child reads from (use for stdin) | | `['pipe', 'w']` | a pipe the child writes to (use for stdout and stderr) | | `['file', '/path', 'a']` | a file opened with that `fopen()` mode | | `['redirect', 1]` | send this descriptor where descriptor 1 goes, like `2>&1` (PHP 7.4+) | | `['null']` | discard, like `2>/dev/null` (PHP 7.4+) | | an open stream resource | pass an existing descriptor, such as `STDERR` | After the call, `$pipes[0]` is writable and `$pipes[1]` and `$pipes[2]` are readable. ## The deadlock and how to avoid it A pipe is a small kernel buffer (often 64 KiB on Linux). When it is full, the writer blocks until the reader drains it. The classic bug: 1. PHP calls `stream_get_contents($pipes[1])` and waits for stdout to reach end-of-file. 2. The child writes a large amount to stderr, fills that pipe, and blocks. 3. The child never closes stdout, because it is stuck; PHP never reads stderr, because it is stuck. Nothing moves. Ways out: - **Read both at once**: `stream_set_blocking($pipes[1], false)` and the same for `$pipes[2]`, then loop on `stream_select()` and read whichever is ready until both reach end-of-file. - **Do not pipe what you do not need**: send stderr to a file, `['null']`, or `['redirect', 1]`, and read the one remaining pipe. - **Feed stdin first, and close it**: write input to `$pipes[0]` and `fclose()` it, so a child waiting for end-of-input can continue. ## Collecting the result Close every pipe, then call `proc_close($process)`. It waits for the child to exit and returns its exit status, or `-1` on error. The manual stresses closing pipes first, since a child may not exit while its pipes are open. `proc_get_status()` gives a non-blocking view: `running`, `pid`, `exitcode`, `signaled`, `termsig`. Before PHP 8.3, only the first call reported the real `exitcode` and `proc_close()` then returned `-1`; since 8.3 the code is cached and both agree, as the `cached` key shows. ## Writing to stdin Some tools read their input from stdin instead of a file. Give descriptor 0 as `['pipe', 'r']`, write the data to `$pipes[0]` with `fwrite()`, and `fclose($pipes[0])` so the child sees end-of-input. For large input the same buffer rule applies in the other direction: if the child writes output while you are still writing input, and nobody reads that output, both sides block. Put stdin in the `stream_select()` write set too, or write the input to a temporary file and pass its path instead. `proc_open()` itself returns `false` if the process cannot be created; in PHP 8.5 the process handle is still a resource, not an object.
- Why does reading stdout to the end with stream_get_contents() sometimes hang forever?If the child writes more to stderr than the pipe buffer holds, it blocks on that write and never finishes stdout. PHP is waiting for stdout's end-of-file, the child is waiting for someone to drain stderr, and neither can proceed. Read both pipes together with `stream_select()`, or redirect stderr to a file or `['null']`.
- What changed in PHP 8.3 about proc_get_status() and proc_close()?Before 8.3, only the first `proc_get_status()` call returned the real `exitcode`; later calls returned -1, and `proc_close()` also returned -1 after a status call. Since 8.3 the exit code is cached, both report the real value, and the status array has a `cached` key.
- Does an array command in proc_open() support shell features like pipes or 2>&1?No. The array form executes the program directly with no shell, so `|`, `>`, globs and `2>&1` are passed as literal arguments. Use descriptor specs instead: `['redirect', 1]` for `2>&1`, `['null']` to discard, `['file', ...]` to write to a file.
saying these in an interview costs you the question
- proc_open() with an array still runs the command through /bin/sh.
- Reading stdout fully before stderr is always safe.
- proc_close() returns true when the process succeeded.
- You should call proc_close() before closing the pipes.
- Array elements must each be wrapped in escapeshellarg().