Fork is a library for running jobs concurrently in PHP. It works by forking the main process into separate tasks using PHP's pcntl and sockets extensions. So, it should go without saying that this library will not work on Windows.
There is an existing library for forking processes, spatie/fork. This library on its surface is very similar, but internally it's quite a bit different. Unlike spatie/fork, mensbeam/fork does not return an array of returned values from all tasks after all of them have finished. Instead, it uses callbacks to handle output as each task completes. This design prevents potential memory exhaustion when running a large number of tasks, as we encountered when using spatie/fork. Handling output immediately as tasks finish is more scalable and efficient.
- PHP >= 8.1
- ext-pcntl
- ext-sockets
- mensbeam/self-sealing-callable ^1.0
Install using Composer:
composer require mensbeam/forkHere is a simple example. Fork->run() can accept an array or an \Iterator of callables to run concurrently and will execute them. This means it can also accept a generator to continuously run tasks concurrently.
use MensBeam\Fork;
function gen(): \Generator {
foreach (range(1, 5) as $n) {
yield function () use ($n) {
$delay = rand(1, 5);
sleep($delay);
return [ $n, $delay ];
};
}
}
(new Fork())->after(function(array $output) {
echo "{$output['data'][0]}: {$output['data'][1]}\n";
})->run(gen());Example output:
4: 2
3: 2
2: 3
5: 3
1: 5
You can use before() and after() to register callbacks to run before or after each task. You can register different callbacks for the parent and child processes.
You can limit how many tasks run concurrently using concurrent().
use MensBeam\Fork;
(new Fork())->concurrent(2)->run([
fn() => sleep(1),
fn() => sleep(1),
fn() => sleep(1)
]);You can set a timeout (in seconds) for each child process:
use MensBeam\Fork;
(new Fork())->timeout(5)->run([
fn() => sleep(10), // This will timeout
fn() => sleep(2)
]);When a task times out, a TimeoutException is thrown inside the child process, captured as a ThrowableContext, and — as with any fatal — thrown directly in the parent by default, or left in $output['errors'] instead if Fork::$throwFatalErrors is set to false.
You can stop all currently running and queued tasks from within an after() callback:
use MensBeam\Fork;
$f = new Fork();
$f->after(function(array $output) use ($f) {
if ($output['data'] === 'stop') {
$f->stop();
}
})->run([
fn() => 'continue',
fn() => 'stop',
fn() => 'never runs'
]);When a task throws, or triggers a non-fatal error (E_WARNING, trigger_error(), etc.), it's captured as a ThrowableContext and sent back to the parent — never as $output['data'], which is always either the task's real return value or null. See Errors and exceptions inside the fork below for where it actually ends up.
- Error code
- File and line where the throwable was thrown
- Message
- Class type
- Optional stack trace (enabled via
Fork::$tracesInThrowableContexts) - Any previous throwable chain
By default:
- Non-fatal errors are automatically re-triggered in the parent process, through whatever error handler is active there, preserving their original severity, message, file, and line — as if they'd happened directly in the parent.
- A fatal (the throwable that actually stopped the task) is thrown directly in the parent, inside
Fork::run()— so a failing task behaves like an ordinary, synchronous failure would.
use MensBeam\Fork;
try {
(new Fork())->after(function(array $output) {
echo "Child succeeded with: " . $output['data'] . "\n";
})->run([
fn() => throw new \RuntimeException("Something went wrong!"),
]);
} catch (\RuntimeException $e) {
echo "A task failed: " . $e->getMessage() . "\n";
}Since $throwFatalErrors throws inside Fork::run() itself, a fatal from any one task halts the entire run immediately — with multiple tasks running concurrently, whichever one fails first stops the rest: their own after() callbacks may never fire at all, and which task "fails first" isn't guaranteed to be consistent between runs. If you're running several tasks together and want every one of them to complete regardless of individual failures, set $throwFatalErrors = false and check $output['success']/$output['errors'] in after() instead.
Two static flags control this default behavior:
use MensBeam\Fork;
Fork::$reraiseNonFatalErrors = false; // default: true
Fork::$throwFatalErrors = false; // default: true$reraiseNonFatalErrors— whenfalse, non-fatal errors are not re-triggered; they're left in$output['errors']instead.$throwFatalErrors— whenfalse, a fatal is not thrown; it's left in$output['errors']instead, for you to handle yourself in anafter()callback.
When both are false, $output['errors'] holds everything that wasn't auto-handled — non-fatal errors first, and the fatal last, if there was one:
use MensBeam\Fork;
Fork::$reraiseNonFatalErrors = false;
Fork::$throwFatalErrors = false;
(new Fork())->after(function(array $output) {
foreach ($output['errors'] as $context) {
echo $context->getMessage() . "\n";
}
})->run([
function () {
trigger_error('a warning', \E_USER_WARNING);
throw new \RuntimeException('Eek!');
},
]);A non-fatal error Fork itself captures always wraps a MensBeam\Fork\Error (which extends \ErrorException). If a task's own code happens to throw a plain \ErrorException itself, you can still tell the two apart — here with $reraiseNonFatalErrors off, so the captured warning actually ends up in errors to demonstrate this:
use MensBeam\Fork;
use MensBeam\Fork\Error;
Fork::$reraiseNonFatalErrors = false;
(new Fork())->after(function(array $output) {
foreach ($output['errors'] as $context) {
if ($context->getThrowable() instanceof Error) {
echo "Fork captured a non-fatal error: {$context->getMessage()}\n";
} else {
echo "The task threw this itself: {$context->getMessage()}\n";
}
}
})->run([
function () {
trigger_error('a warning', \E_USER_WARNING);
return 'done';
},
]);You can enable including stack traces in ThrowableContext objects:
use MensBeam\Fork;
Fork::$tracesInThrowableContexts = true;Keep in mind there are some minor limitations, however. Anything that can't be serialized such as Generators, Closures, etc. are all sanitized to strings denoting what they were before being replaced.
MIT License. See LICENSE.md and AUTHORS.md for details.