The Process Facade: Shelling Out to System Commands Safely and Testably
Sooner or later a Laravel app needs to shell out: converting a video, running ffmpeg, invoking a CLI tool, calling a script another team owns. The Process facade gives you a clean, fluent wrapper around Symfony's Process component, with a testing story that makes it painless to fake.
Running a command
use Illuminate\Support\Facades\Process;
$result = Process::run('git status');
$result->successful(); // bool
$result->output(); // stdout
$result->errorOutput(); // stderr
$result->exitCode();
Passing arguments safely
Never string-concatenate user input into a shell command. Pass an array instead and let Laravel handle escaping:
$result = Process::run(['convert', $inputPath, '-resize', '800x600', $outputPath]);
Timeouts and working directory
$result = Process::path(storage_path('app/exports'))
->timeout(120)
->run('php artisan export:generate');
If a command exceeds its timeout, Laravel throws a Symfony\Component\Process\Exception\ProcessTimedOutException, which you can catch to clean up partial output.
Streaming output in real time
For long-running commands where you want to react to output as it comes rather than waiting for completion:
Process::run('npm run build', function (string $type, string $output) {
if ($type === 'err') {
Log::warning($output);
} else {
Log::info($output);
}
});
Running commands concurrently
Just like the HTTP client's connection pool, Process supports running multiple commands in parallel and waiting on all of them:
$results = Process::pool(function (\Illuminate\Process\Pool $pool) {
$pool->as('thumbnails')->command('php artisan images:thumbnail 1');
$pool->as('metadata')->command('php artisan images:extract-metadata 1');
})->start()->wait();
$results['thumbnails']->successful();
$results['metadata']->successful();
Throwing on failure
Process::run('composer validate')->throw();
throw() raises a ProcessFailedException if the command exits non-zero, which is convenient in deployment scripts and Artisan commands where a failed step should halt everything downstream.
Testing without actually running anything
This is where Process earns its keep over calling shell_exec() or Symfony's Process component directly. You can fake process execution entirely in tests:
use Illuminate\Support\Facades\Process;
Process::fake([
'git status' => Process::result(output: 'nothing to commit'),
'ffmpeg*' => Process::result(exitCode: 1, errorOutput: 'invalid codec'),
]);
Process::run('git status')->output(); // 'nothing to commit'
Process::assertRan('git status');
No test suite should actually be invoking ffmpeg or git on a CI runner if it can avoid it, and Process::fake() means you can test the branching logic around a command's success or failure without ever touching a real shell.
Why reach for this instead of exec()
The core value is consistency: one API for running, streaming, pooling, and testing shell commands, instead of a patchwork of shell_exec(), proc_open(), and ad hoc mocking. If your app talks to the command line at all, even just for one wkhtmltopdf call, Process is worth using instead of PHP's raw process functions.