spatie/async: running PHP closures in parallel with PCNTL
Easily run code asynchronously
At a glance
- What is it?
- spatie/async is a thin wrapper around PHP's PCNTL extension that lets you queue closures into a pool and collect their results. It is for PHP engineers who need parallel work inside a CLI script, not for web requests.
- Who is it for?
- Adopt spatie/async when you have a long-running PHP CLI script that forks independent work and you want then/catch/timeout hooks instead of hand-written pcntl_fork calls. Do not adopt it for web request handling, for anything that needs a shared connection or in-memory state across children, or where you cannot install the PCNTL extension.
- 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 120 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 September 24, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What spatie/async actually solves for PHP engineers
PHP runs one request in one process. When a CLI script has to do a hundred independent things (resize images, call a slow API, hash files), doing them in sequence wastes wall-clock time. The usual answers are a queue system with workers, or calling pcntl_fork yourself and managing pipes, exit codes and result serialisation by hand.
spatie/async sits in the middle. The README describes it as "a small and easy wrapper around PHP's PCNTL extension" that allows "running of different processes in parallel, with an easy-to-use API." You hand it closures, it forks, and it gives you back a pool object with per-process success, failure and timeout callbacks. The audience is the engineer writing a one-off script or a long-running console command who does not want to stand up a broker just to fan work out across cores.
That scope matters. Because it is built on PCNTL, it inherits PCNTL's constraints. It is a Unix process-forking tool, not a concurrency runtime, and the README does not claim otherwise.
How the pool, the queue and the child processes fit together
You create a Pool, add callables to it, and call wait(). Each added closure becomes a queued process. The pool enforces a concurrency limit, so with concurrency(20) no more than twenty child processes run at once; the rest wait their turn.
The README's first example shows the shape: $pool->add(function () use ($thing) { ... }) returns an object you can chain ->then() and ->catch() onto, and $pool->wait() blocks until the queue drains. Because the child process is "always bootstrapped from nothing," as the README puts it, you do not get your parent's dependency container or config for free. That is why the library offers a Task class with a configure() method for setup and a run() method for the work, plus support for plain invokable objects when a full Task subclass is overkill.
By default child processes are executed with the php binary. Pool::create()->withBinary('/path/to/php') points the pool at a different interpreter, which matters when the parent runs under a version or build you do not want the children to inherit.
Error routing is explicit. A thrown Exception or Error is caught per process by ->catch(). If no handler exists, it surfaces in the parent when you call await() or $pool->wait(). And if a child dies without throwing a Throwable at all, the README says the output written to stderr is wrapped and thrown as Spatie\Async\ParallelError in the parent. That last detail is the one people miss: a segfaulting child does not vanish silently, it becomes a ParallelError.
Installing spatie/async and running a first pool
Installation is a single Composer command. The package name is spatie/async, as published on Packagist.
composer require spatie/asyncThe README's usage example is the shortest path to a working pool. It creates a pool, adds one closure per item, and attaches success and failure handlers.
use Spatie\Async\Pool;
$pool = Pool::create();
foreach ($things as $thing) {
$pool->add(function () use ($thing) {
// Do a thing
})->then(function ($output) {
// Handle success
})->catch(function (Throwable $exception) {
// Handle exception
});
}
$pool->wait();After $pool->wait() returns, every queued closure has either run its then handler or its catch handler. The $output passed to then is whatever the closure returned.
If you prefer a functional style, the README documents async() and await() helpers. The example below queues five closures, each sleeping for a random number of microseconds before returning 2, and accumulates the results.
use Spatie\Async\Pool;
$pool = Pool::create();
foreach (range(1, 5) as $i) {
$pool[] = async(function () {
usleep(random_int(10, 1000));
return 2;
})->then(function (int $output) {
$this->counter += $output;
});
}
await($pool);Note the use of $pool[] rather than $pool->add(). Both forms appear in the README. If you want to cap simultaneous children, chain ->concurrency(20) onto Pool::create() before adding work.
Handling typed exceptions and the handler that never fires
The catch() callback can be type-hinted, and the README shows stacking several of them so that different exception classes get different handling. The behaviour to internalise is the short-circuit: "as soon as an exception is handled, it won't trigger any other handlers."
The README demonstrates this with a MyException handler followed by an Exception handler. Because MyException extends Exception, you might expect the second handler to also run. It does not. The first matching handler consumes the exception and the chain stops. If you write a broad catch-all after specific handlers, the catch-all only sees exceptions the earlier handlers did not match. Order matters, and the README is explicit that this is intentional rather than a bug.
This is a reasonable design, but it is a trap for anyone who assumes PHP's usual catch-block semantics where the first matching block also wins. The difference is that in a try/catch you see the blocks together in one place; here the handlers are chained method calls and it is easy to append a new one at the end without noticing it will never be reached for a subclass.
Stopping a pool early and why the pool is then spent
The stop() method exists for the case where one child's result makes the rest of the work pointless. The README's example queues ten thousand closures that each return a random number, and the then handler calls $pool->stop() when a child returns 100.
use Spatie\Async\Pool;
$pool = Pool::create();
for($i = 0; $i < 10000; $i++) {
$pool->add(function() use ($i) {
return rand(0, 100);
})->then(function($output) use ($pool) {
if ($output === 100) {
$pool->stop();
}
});
}
$pool->wait();stop() "will prevent the pool from starting any additional processes." It does not kill the children already running; it prevents new ones from starting. The README adds that "a pool will be rendered useless after being stopped, and a new pool should be created if needed." That is a hard boundary. Code that reuses the same pool object after a stop will not behave the way it does before, and the README does not document a way to reset it.
Where spatie/async is the wrong tool
The PCNTL dependency is the first wall. PCNTL is not available in every PHP build, and it is generally disabled or unusable in web server SAPIs. If your code path is a normal HTTP request handled by php-fpm, forking is not the model you want, and this library will not help you there. The README does not present a web-request story, and it should not be read as one.
Memory and state are the second wall. Each child is bootstrapped fresh, which is precisely why the Task class exists. Anything you want available inside the child (a database connection, a container, a config array) has to be rebuilt in configure() or captured in the closure. Shared mutable state across children is not something the pool provides; the README's own examples accumulate results in the parent via then handlers, not in the children.
There is also no documented rollback. The README describes then, catch and timeout hooks and a stop() that halts new work, but it does not describe cancelling or reverting work a child already performed. If a task writes to a database and a later task fails, nothing in this library undoes the first write. That is your responsibility.
Finally, scale. A pool with a concurrency limit is a fine fit for dozens or hundreds of short tasks. If you need durable retries, persistence across process restarts, or work distributed across machines, a queue with workers is the right shape and this library is not competing for that job.
How it differs from a queue worker like Laravel Horizon
The cleanest comparison is with a message-queue setup: a broker (Redis, SQS, a database table), a set of long-lived worker processes, and jobs that are serialised, dispatched and retried. Laravel's queue system with Horizon as the dashboard is a common shape of that.
The difference in approach is where the parallelism lives. A queue decouples the producer from the consumer. Work survives the producer process exiting, retries are a first-class concept, and you scale by adding workers on other machines. spatie/async does none of that. The pool lives inside the process that created it. When that process exits, the queue is gone. There is no persistence, no retry policy, and no cross-machine distribution, because the mechanism is fork, not dispatch.
What you get in exchange is immediacy. There is no broker to run, no worker to supervise, no serialisation step for the payload, and no deployment of a separate process type. For a script that needs to process a batch and exit, that is a meaningful reduction in moving parts. For a system that needs work to survive a crash, it is the wrong layer entirely.
Maintenance, licence and upgrade cost
The repository is not archived, and the last push was on 2026-06-02. Recent releases are 1.8.2 on 2026-02-09, 1.8.1 on 2025-11-28 and 1.8.0 on 2025-08-05. The version numbering is still on the 1.x line, and the repository carries an UPGRADING.md file at its top level alongside CHANGELOG.md, which is where the project records what changed between releases. Anyone moving from an older 1.x to the current one should read that file rather than assume the API is unchanged.
The licence is MIT. In practical terms that means you can use the package in closed-source and commercial projects, keep the copyright notice, and are not required to publish your own source. This is not legal advice; if your organisation has specific obligations around third-party dependencies, route the LICENSE.md file through whoever handles that.
Operationally, the upgrade cost is low as long as you are on 1.x. The API surface shown in the README (Pool::create, add, then, catch, timeout, wait, stop, async, await, withBinary, concurrency) is small, and there is no configuration file to migrate. The real maintenance burden is environmental, not code: PCNTL must be present in whatever runtime executes the parent script, and the PHP binary used for children is a separate decision that withBinary() lets you control.
Editorial conclusion
Adopt spatie/async when you have a long-running PHP CLI script that forks independent work and you want then/catch/timeout hooks instead of hand-written pcntl_fork calls. Do not adopt it for web request handling, for anything that needs a shared connection or in-memory state across children, or where you cannot install the PCNTL extension. Before committing, verify that PCNTL is available in your target runtime, decide whether you need the default Pool::create() or a custom ->withBinary() path, and check the UPGRADING.md file in the repository for the changes between the 1.x releases.
Frequently asked questions
What does spatie/async do?
It wraps PHP's PCNTL extension so you can queue closures into a pool and have them run in parallel child processes. The README describes it as a small, easy wrapper that allows running different processes in parallel with an easy-to-use API.
How do I install spatie/async?
Install it with Composer using the package name spatie/async, as shown in the README's installation section. There is no separate extension or binary to download beyond the PCNTL extension the library depends on.
How do I use spatie/async with then and catch handlers?
Create a pool with Pool::create(), add closures with add(), and chain then() for success and catch() for exceptions before calling wait(). The README notes that once an exception is handled by one catch handler, no other handlers are triggered.
Can I limit how many spatie/async processes run at once?
Yes. The pool is configurable with concurrency(), which the README describes as the maximum amount of processes which can run simultaneously. You chain it onto Pool::create() before adding work.
What happens if a spatie/async child process dies without throwing an exception?
The README states that if a child process unexpectedly stops without throwing a Throwable, the output written to stderr is wrapped and thrown as Spatie\Async\ParallelError in the parent process.
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-async)