amphp/process
amphp/process is an asynchronous process dispatcher for PHP 8.1+ built on the Revolt event loop. It runs child processes on all major platforms, including Windows, with non-blocking access to stdout and stderr, plus support for custom working directories and environment variables.
Install and prerequisites
composer require amphp/process
Requires PHP 8.1+. The package brings in revolt/event-loop and amphp/byte-stream transitively. There is no Laravel service provider to register and no config file to publish — it's a library, not a Laravel package. You call it directly.
Where to look first
Process::start() → optionally pipe streams → join() for the exit code.Amp\Process\Process class itself: start(), getStdin(), getStdout(), getStderr(), join(), kill(), signal(), getPid(), isRunning().Amp\ByteStream — pipe(), buffer(), getStdout(), getStderr(). Almost everything you do with process output goes through this namespace.Amp\Future (async(), await(), awaitAll()) once you want more than one process at a time.First use case: a queued job that shells out to ffmpeg
The most natural Laravel entry point is a job, not a controller — you don't want a web request parked on a transcode. Put the call behind a small service class so it's testable and so binary paths come from config.
// app/Services/ProcessRunner.php
namespace App\Services;
use Amp\ByteStream;
use Amp\Process\Process;
class ProcessRunner
{
public function run(string $command, ?string $cwd = null, ?array $env = null): int
{
$process = Process::start($command, workingDirectory: $cwd, environment: $env);
Amp\async(fn () => ByteStream\pipe($process->getStdout(), ByteStream\getStdout()));
Amp\async(fn () => ByteStream\pipe($process->getStderr(), ByteStream\getStderr()));
return $process->join();
}
}
Then in a job:
public function handle(ProcessRunner $runner): void
{
$exit = $runner->run(
sprintf('%s -i %s -vf scale=1280:-1 %s',
config('services.ffmpeg.binary'),
Process::escapeArgument($this->inputPath),
Process::escapeArgument($this->outputPath),
)
);
if ($exit !== 0) {
throw new \RuntimeException("ffmpeg failed with exit code {$exit}");
}
}
That is the full "hello world" loop for a Laravel developer: start → consume streams → check join(). Everything below is variations on it.
First thing to decide: keep using Symfony's Process (via Laravel's Process facade) for short, sequential, one-at-a-time commands, and reach for amphp/process only when you need concurrency or streaming — that's where the blocking alternative actually costs you.
Concurrent fan-out (the main reason to be here)
Run N child processes without N queue workers. This is the pattern that changes capacity math:
use Amp\Future;
use Amp\Process\Process;
use function Amp\async;
$jobs = collect($files)->map(fn (string $file) => async(function () use ($file) {
$process = Process::start("ffmpeg -y -i {$this->arg($file)} {$this->arg($this->out($file))}");
$stdout = Amp\ByteStream\buffer($process->getStdout());
$stderr = Amp\ByteStream\buffer($process->getStderr());
$code = $process->join();
return ['file' => $file, 'code' => $code, 'stderr' => $stderr];
}))->all();
$results = Future\await($jobs); // throws on first uncaught exception
Future\awaitAll() is the variant you want when you need every result even if some fail.
Streaming output instead of buffering it
ByteStream\buffer() loads everything into memory — fine for a 40-line CLI message, wrong for a 2GB transcode log. Stream instead:
$logPath = storage_path('logs/ffmpeg-'.now()->timestamp.'.log');
$sink = Amp\File\openFile($logPath, 'w'); // amphp/file, or a plain resource sink
Amp\ByteStream\pipe($process->getStdout(), $sink);
For progress reporting, read in a loop rather than piping:
while (null !== $chunk = $process->getStdout()->read()) {
$this->progress->advance(); // Artisan progress bar
Log::channel('process')->debug(trim($chunk));
}
Pair stderr with stdout by draining them in separate async() fibers — see the gotcha about pipe-buffer deadlocks below.
Feeding stdin
$process = Process::start('pdftk - output out.pdf');
$process->getStdin()->write($pdfBytes);
$process->getStdin()->end(); // must close, or the child waits forever
$code = $process->join();
Use ByteStream\pipe($sourceStream, $process->getStdin()) for large inputs; it closes the destination for you.
Timeouts and cancellation
join() takes a Cancellation. Critically, cancelling join() only stops you waiting — the child keeps running.
use Amp\TimeoutCancellation;
try {
$code = $process->join(new TimeoutCancellation(300));
} catch (Amp\CancelledException) {
$process->kill();
throw new \RuntimeException('ffmpeg exceeded 300s and was killed');
}
kill() sends SIGKILL on *nix and taskkill on Windows. As of v2, a killed process does not throw from join() — you get the exit code, so always check it explicitly rather than relying on exceptions.
Signals
$process->signal(SIGTERM) gives the child a chance to shut down cleanly (useful for git, nginx reloads, long-running daemons you spawn from a supervisor-style command). v2.1.0 added Process::getSignalName(int $signal): ?string for readable logs; it returns null unless ext-pcntl is loaded.
Wrapping it for Laravel
Keep one ProcessRunner/BinaryRunner service, inject it, and let config supply binary, cwd, and env. Then:
WithoutOverlapping and a $timeout slightly above your TimeoutCancellation value.->withoutOverlapping() on the schedule entry, since a single invocation can now do a lot of work.Bounding concurrency
async() is unbounded; a 500-file batch will start 500 processes. Use amphp/sync's Semaphore or LocalMutex, or simply chunk:
foreach (collect($files)->chunk(8) as $chunk) {
Future\await($chunk->map(fn ($f) => async(fn () => $this->transcode($f)))->all());
}
Platform parity
The package genuinely works on Windows (via a bundled wrapper executable using sockets), which is the reason to prefer it over hand-rolled proc_open(). Write commands portably: use DIRECTORY_SEPARATOR checks for shell builtins (echo isn't an executable on Windows — you need cmd /c), and always route user-supplied arguments through Process::escapeArgument(). For anything non-trivial, prefer invoking an explicit .exe/binary over a shell string, which sidesteps quoting differences entirely.
Testing
Don't mock Process. Test the surrounding job/service with a fake runner interface, and smoke-test the real runner with cheap commands (php -r "usleep(10000);", echo) asserting exit codes and captured stdout. On Windows CI, keep the commands .exe-based, not shell builtins.
Unread pipes deadlock the child. If a process writes more than the OS pipe buffer (~64KB) to stdout and nobody reads it, the child blocks on write(), never exits, and your join() hangs forever. Always drain both stdout and stderr concurrently — even if you just discard one — before or during join(). This is the single most common way to hang this library.
environment: replaces, it does not merge. Passing ['PATH' => '/usr/local/bin'] gives the child a one-variable environment. Merge explicitly:
$env = [...getenv(), 'APP_ENV' => 'production', 'PATH' => '/usr/local/bin:'.getenv('PATH')];
Same trap with workingDirectory — relative paths in the command resolve against it, and a nonexistent directory fails at start.
You're inside the Revolt event loop now — blocking calls stall everything. Any synchronous I/O in the same fiber (Eloquent/PDO queries, file_get_contents, sleep, curl_exec, Log:: to a slow disk handler) freezes every other concurrent process you launched. Keep the critical section to subprocess work; use amphp/mysql, amphp/redis, amphp/file, Amp\delay() if you need real I/O concurrency. This bites hardest when you fan out 10 processes and then log each result with a DB write inside the loop.
join() and kill() semantics. kill() doesn't throw, and a killed process reports a non-zero/signal exit code — never assume "no exception means success". join() is idempotent-ish in practice but store the exit code once. Don't call kill() inside a destructor-ish context expecting the process to be reaped cleanly; v2 deliberately avoids suspending in kill().
Zombie processes need ext-pcntl. On *nix, ended children are only reliably reaped via pcntl (an optional dependency). Without it you can accumulate zombies in long-running workers, and the shutdown-time cleanup added in v2.0.2 matters more than you'd think in PHP-FPM. Install ext-pcntl on queue hosts.
Long-running workers + PHP-FPM. v2.0.2 changed shutdown behavior to synchronously wait for children — good for correctness, but it means a FPM worker shutdown can momentarily stall. The bigger rule: never leave a job's children running after the job returns; kill() in a finally block.
Version trap: v1 vs v2. Nearly every blog snippet and Stack Overflow answer is v1. In v2, Process::__construct() is private, start() is static and no longer returns a PID (use getPid()), getEnv() became getEnvironment(), escapeArguments() became escapeArgument() (singular), and all the Promise<...> returns are now plain values thanks to fibers. If a snippet uses yield $process->start(), it's v1 — don't paste it.
Memory. ByteStream\buffer() on stdout of a chatty process is a fast route to an OOM in a worker with a 128M limit. Default to piping to a file or a ring buffer for anything you don't control.
Logging exit codes is not optional. Children that fail often emit nothing but a code. Always capture and log stdout/stderr alongside the code, or you'll be debugging blind at 2am:
Log::channel('process')->info('child exited', [
'command' => $process->getCommand(),
'code' => $exit,
'stderr' => ByteStream\buffer($process->getStderr()),
]);
Extension points. There aren't formal ones — Process is final. Customization happens at the edges: wrap it in your own runner class (for fakes, retries, logging, config-driven binaries), feed it your own ReadableStream/WritableStream implementations from amphp/byte-stream, and hand it a Cancellation you control (e.g., one cancelled by a queue-worker stop signal). That's the whole extension surface — design your wrapper accordingly, because you can't subclass your way out of a problem.
When not to use it. A single git status in an Artisan command, a one-shot pdftk in a controller — Symfony Process is simpler, has a nicer API, and is already a Laravel dependency. Adopt amphp/process when you need (a) genuine parallelism, (b) incremental streaming of output, or (c) Windows parity you don't want to maintain yourself. Otherwise you're paying the event-loop mental overhead for nothing.
How can I help you explore Laravel packages today?