spatie/fork runs PHP closures in parallel by forking the process
A lightweight solution for running code concurrently in PHP
At a glance
- What is it?
- spatie/fork is a PHP package that runs closures concurrently by forking the main process into child tasks, with hooks for per-child setup, serialised return values and a configurable concurrency limit. It needs PHP 8 and the pcntl extension, so it only works in CLI processes.
- Who is it for?
- spatie/fork suits a CLI job that needs to call several slow services at once and does not want to reach for an event loop or a queue, and it is the wrong tool for a web request, since the README states pcntl only works in CLI processes and not in a web context. Before using it, check that your closures are safe in a forked child, because a fork copies the process and any open database connection in the parent is shared with the child until you reconnect.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 2 days ago.
- What is it written in?
- Mainly PHP, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on November 7, 2026, and from our analysis. They are not legal advice.
Star history
- spatie/fork1,050 ★ · +1
| Repository | 2026-10-21 | 2026-11-07 |
|---|---|---|
| spatie/fork | 1k | 1.1k |
1 Stars in the last 17 days, measured from the daily GitHub snapshot. Compare with other projects
Editorial analysis
Fork, not a scheduler
spatie/fork makes it easy to run PHP concurrently, and the mechanism is stated plainly: behind the scenes, concurrency is achieved by forking the main PHP process to one or more child tasks.
That choice is the whole design, and it explains both the appeal and the limits. pcntl_fork is a Unix primitive, so forking is nearly free: the child gets a copy-on-write copy of the parent's memory, and the two then run independently. There is no serialisation of the closure, no message passing and no event loop. You write ordinary closures and they run at the same time.
The API is three calls:
use Spatie\Fork\Fork;
$results = Fork::new()
->run(
fn () => (new Api)->fetchData(userId: 1),
fn () => (new Api)->fetchData(userId: 2),
fn () => (new Api)->fetchData(userId: 3),
);
$results[0]; // fetched data of user 1
$results[1]; // fetched data of user 2
$results[2]; // fetched data of user 3The run method accepts as many closures as you want, and returns an array with the return values in the same order as the arguments. The result is available only once all children are done, so the parent blocks at the end of run while the work happens in parallel.
The package is MIT licensed, published on Packagist as spatie/fork, and the current release is 1.2.8 from 2026-09-14, with the last push the same day. The 1.2 series has had a slow but steady cadence, with 1.2.6 and 1.2.7 both landing on 2026-03-27.
The database connection has to be reconnected in the child
The most important thing in the README is the before and after section, because the fork model creates a problem that every user hits.
If you need to execute some code before or after each callable passed to run, you can pass a callable to the before or after methods. This callable is executed in the child process right before or after the execution of the callable passed to run.
The example makes the reason concrete. It gets a value from the database using a Laravel Eloquent model, and in order to let the child task use the DB it is necessary to reconnect to the DB. The closure passed to before will run in both child tasks created for the closures passed to run:
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Spatie\Fork\Fork;
Fork::new()
->before(fn () => DB::connection('mysql')->reconnect())
->run(
fn () => User::find(1)->someLongRunningFunction(),
fn () => User::find(2)->someLongRunningFunction(),
);The reason is that the child inherits the parent's open socket to the database server. Two processes then share one connection, and the protocol on that connection interleaves in ways the driver cannot expect. Reconnecting in the child replaces the inherited handle with a fresh one, and each child gets its own.
The parallel construction of the API is worth noting. before and after accept a parent argument to run the callable in the parent task instead, and they accept both child and parent together to run different closures in each. The after callback in the child case is where cleanup happens, and after callbacks run whenever a child is done rather than only at the very end, so they are the place to collect incremental results.
If you need to let the callable passed to before or after run in the parent task, then you need to pass that callable to the parent argument, and the example shows both the parent-only form and the child-plus-parent form.
Results cross the process boundary through serialize
The data path between parent and child is PHP's own serialisation, and the README is precise about what that means.
All output data is gathered in an array and available as soon as all children are done. The output is also available in the after callbacks, which are called whenever a child is done and not at the very end:
$results = Fork::new()
->after(
child: fn (int $i) => echo $i, // 1, 2 and 3
parent: fn (int $i) => echo $i, // 1, 2 and 3
)
->run(
fn () => 1,
fn () => 2,
fn () => 3,
);The callbacks receive the index of the child, which is how you correlate an incremental callback with a position in the final array.
Then the key constraint. Return values from child tasks are serialised using PHP's built-in serialize method, which means that you can return anything you can normally serialize in PHP, including objects:
$result = Fork::new()
->run(
fn () => new DateTime('2021-01-01'),
fn () => new DateTime('2021-01-02'),
);So the boundary is PHP's serialisation format, not an object graph. A closure cannot be returned, a resource cannot be returned, and a database connection certainly cannot. A DateTime works because it is serialisable. Anything that holds a file handle or a live connection will fail at the boundary, and the failure surfaces at the point of collection rather than inside the child, which is a slightly confusing place to discover it.
That also means the payload is copied, not shared. Returning a large array from a child costs a serialise and an unserialize, and the memory in the child is not the same memory as the memory in the parent.
Concurrency is unlimited by default and capped when you ask
The default behaviour is worth stating because it is the one that surprises people. By default, all callables will be run in parallel.
There is no queue, no worker pool and no throttling unless you configure it. The concurrent method sets a maximum:
$results = Fork::new()
->concurrent(2)
->run(
fn () => 1,
fn () => 2,
fn () => 3,
);In this case the first two functions run immediately and as soon as one of them finishes, the last one starts as well. So the limit is a pool size, not a batch size, which is the right semantic for the use case: you are capping how many simultaneous outbound requests you make, which is usually about not overwhelming a remote API or exhausting your own file descriptors.
The default of no cap is the other half of the story. If you pass fifty closures to run, you get fifty forked processes. Each one carries a copy-on-write image of the parent, so the memory cost of the parent is multiplied by the process count until the children start diverging. For a handful of API calls that is nothing. For a few hundred it is a problem, and concurrent is the answer.
The requirements section explains where the package can run at all. It requires PHP 8 and the pcntl extensions which is installed in many Unix and Mac systems by default, pcntl only works in CLI processes and not in a web context, and posix is required for correct handling of process termination for Alpine Linux.
That last note is the one that catches people. The package is built on pcntl_fork and pcntl_waitpid, and on Alpine the process termination handling needs the posix extension, so an Alpine image without it will behave incorrectly at the point where the parent collects the children.
Where spatie/fork is the wrong choice
The first limit is the web context, and the README states it as a hard requirement rather than a caveat. pcntl only works in CLI processes, not in a web context, so a Laravel queue worker is fine and an HTTP request handler is not. If you are reaching for this in a controller, you want an HTTP client with a concurrent request capability instead.
The second is the fork semantics themselves. A forked child inherits the parent's memory, its open sockets, its file descriptors and its signal handlers. Any state the parent had at the moment of the fork is visible in the child, and any state the child changes does not come back. The reconnect example in the README is the database case, and the same applies to anything else the parent holds open, including a Redis connection, an S3 client with a persistent HTTP agent, or a logger writing to the same file handle.
The third is that there is no timeout, no retry and no cancellation. The README does not describe any of them. A child that hangs holds the parent at the end of run, and there is no documented way to kill it, so a task that depends on a third party that never responds can stall the job indefinitely.
The fourth is error handling. The README does not say what happens when a child throws an exception. Since the child is a separate process, an uncaught exception in it does not propagate to the parent as a catchable throw, and the returned value for that slot is not described. Code that runs in a child and is expected to fail needs its own error handling inside the closure.
The fifth is scale. Forking is cheap and this package is genuinely lightweight, which is why it works well for a handful of blocking calls. It is not a work queue, it has no visibility into child state other than the return values, and it does not survive the parent being killed. For anything longer running, a proper queue with workers is the right structure.
Editorial conclusion
spatie/fork suits a CLI job that needs to call several slow services at once and does not want to reach for an event loop or a queue, and it is the wrong tool for a web request, since the README states pcntl only works in CLI processes and not in a web context. Before using it, check that your closures are safe in a forked child, because a fork copies the process and any open database connection in the parent is shared with the child until you reconnect. Verify first that pcntl is available in your target environment, since it is not present on every PHP build, and that posix is present if you run on Alpine Linux.
Frequently asked questions
What is spatie/fork?
It is a lightweight PHP package that runs code concurrently by forking the main PHP process into one or more child tasks. You pass as many closures as you want to run, and the run method returns an array with the return values in the same order.
How do I install spatie/fork?
With composer require spatie/fork. The package requires PHP 8 and the pcntl extension, which is installed by default in many Unix and Mac systems, and posix is required for correct handling of process termination on Alpine Linux.
Why do I need a before callback in spatie/fork?
Because a forked child inherits the parent's open database connection, and two processes sharing one connection misbehave. The before callback runs in each child process, so you can reconnect there, for example with DB::connection('mysql')->reconnect(), before the child touches the database.
Can spatie/fork run in a web request?
No. The README states that pcntl only works in CLI processes and not in a web context, so the package is for command line scripts and queue workers rather than request handlers.
How do I limit how many tasks spatie/fork runs at once?
Use the concurrent method with a maximum, for example ->concurrent(2). The first two closures run immediately and as soon as one finishes the last one starts, so the limit behaves as a pool size rather than a batch size.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/spatie-fork)Star history chart for your README
[](https://hysenlabs.com/projects/spatie-fork)Was this useful?