Laravel 10 processes: valid JSON can still mean failure

A real CSV subprocess shows why exit status must be checked before decoding output, with timeout tests and deliberately broken variants.
Valid output can accompany a failed process
A CSV checker accepts its first row, encounters an invalid amount in the next row, and prints {"rows":1}. That JSON parses successfully. The process also exits with status 2 and writes an error to stderr. Returning the parsed count would conceal the rejected record. The wrapper below checks the exit before decoding output, then checks the decoded value before returning it.
This is a maintenance explainer for engineers reading an older Laravel application. The executable fixture uses Laravel 10.50.3 and PHP 8.3.35. Laravel 10 security support ended on 4 February 2025. The accompanying dependency audit, captured on 13 October 2026, contains four records covering three distinct framework advisories. Two records identify the same email-validation advisory. Treat this as an isolated historical example, not a production dependency recommendation.
Keep the command fixed and send CSV through stdin
CsvCheck receives an Illuminate Process Factory and a trusted validator path. PHP_BINARY selects the PHP executable running this fixture. The command is an argument array, while the CSV is passed separately through input(). In the pinned implementation, arrays construct a Symfony Process directly. Strings take the shell-command path. The example never interpolates CSV into command text.
The wrapper rejects input larger than 65,536 bytes before starting a child. It sets a one-second process timeout and calls throw() before reading the result. A nonzero exit raises ProcessFailedException. A timeout raises ProcessTimedOutException from run(). If the child exits successfully, JSON_THROW_ON_ERROR rejects malformed JSON. The final shape check requires exactly one nonnegative integer field named rows.
<?php
declare(strict_types=1);
use Illuminate\Process\Factory;
final class CsvCheck
{
public function __construct(
private Factory $processes,
private string $validator = __DIR__.'/validate-csv.php',
) {}
public function rows(string $csv): int
{
if (strlen($csv) > 65536) {
throw new LengthException('CSV exceeds the example input limit');
}
$result = $this->processes->input($csv)->timeout(1)
->run([PHP_BINARY, $this->validator])->throw();
$data = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data) || array_keys($data) !== ['rows'] || !is_int($data['rows']) || $data['rows'] < 0) {
throw new UnexpectedValueException('Invalid validator response');
}
return $data['rows'];
}
}
Give the child a small, explicit contract
The child expects an id,total_cents header and two fields per record. It accepts a nonempty identifier and decimal digits for the amount. On an invalid record it prints the partial count, writes a diagnostic to stderr and exits with status 2. That behavior is intentional: it makes the difference between output and successful completion observable.
This validator only counts rows. It does not import records, enforce unique identifiers, restrict the amount range or authorize an upload. Blank records are rejected. The input limit belongs to the parent. Invoking the child directly bypasses it. Applications accepting files need their own upload limits and business validation.
<?php
declare(strict_types=1);
function finish(int $rows, ?string $error = null): never
{
echo json_encode(['rows' => $rows], JSON_THROW_ON_ERROR)."\n";
if ($error !== null) {
fwrite(STDERR, $error."\n");
exit(2);
}
exit(0);
}
$header = fgetcsv(STDIN, 0, ',', '"', '');
if ($header !== ['id', 'total_cents']) {
finish(0, 'Invalid CSV header');
}
$rows = 0;
while (($record = fgetcsv(STDIN, 0, ',', '"', '')) !== false) {
if (count($record) !== 2 || $record[0] === '' || !ctype_digit($record[1])) {
finish($rows, 'Invalid CSV record');
}
$rows++;
}
finish($rows);
Test real children, then break the wrapper
The normal probe accepts two valid rows. Its invalid-amount case captures exit 2, stdout {"rows":1} followed by a newline, and stderr Invalid CSV record followed by a newline. A separate child sleeps for three seconds and triggers ProcessTimedOutException under the one-second deadline. Two more children return malformed JSON and a negative row count. Both responses are rejected.
The probe also submits a CSV identifier containing $(touch /tmp/csv-side-effect). It remains literal input: the result is one row and the sentinel file is absent. An oversized input is tested with a nonexistent validator path, proving that rejection happens before attempting to start that child. These are specific boundary checks, not a claim that arbitrary commands are safe.
Three altered copies remove throw(), timeout(), or response validation. Each must fail the unchanged probe with its intended marker: NONZERO_EXIT_WAS_ACCEPTED, MISSING_TIMEOUT, or INVALID_RESPONSE_ACCEPTED. The runner checks the preceding observations and the hash of the altered source. A build failure or unrelated exception cannot count as a successful control.
Reproduce the result and keep its limits visible
Download and extract the linked source bundle, then run python3 verify.py in its directory with Docker available. The first build downloads the pinned images and locked Composer dependencies. Expected output is a JSON record with status pass and four results: the original wrapper exits 0, and each deliberately broken wrapper exits 1. Those failures are required evidence, so the overall verification command still exits 0.
Each execution runs without external networking, as an unprivileged user, with a read-only root filesystem and bounded resources. The host runner limits captured output to 64 KiB and each case to thirty seconds. Those outer limits belong to the test harness. CsvCheck itself does not cap child output or provide an operating-system sandbox. Its executable and script must remain trusted.
The first development attempt used Laravel 10.0.0 and failed because its PendingProcess class has no input() method. The corrected example pins 10.50.3, whose tagged source includes it. Moving 10.x documentation is not evidence that every method existed in the initial release. The dependency lock includes later compatible packages, rather than reconstructing the whole dependency graph from release day.
I recommend preserving these separate failures when integrating the wrapper: invalid user data, a broken child process, a timeout and an invalid child response need different handling. This fixture has no HTTP controller, queue retry policy or database writes. Retrying a real importer requires evidence about its partial writes. A read-only row counter cannot establish that retrying is safe.
The original Laravel 10 release also added types to generated scaffolding and introduced Pennant as a separate feature-flag package. Neither changes the process result contract demonstrated here. This fixture makes no claim about persisted flag decisions. The separate Pennant article covers that topic.
Reproduce the examples
Exact CSV wrapper and child, real subprocess probe, three deliberate defects, pinned dependency lock, runtime recipe and recorded historical advisories. Requires Docker and Python 3. This is an unsupported historical fixture.
Download the source and tests (.zip)
