nunomaduro/pokio
Pokio is a dead simple asynchronous API for PHP: run closures concurrently via PCNTL forks, communicating through FFI shared memory. Use async() to start tasks and await() to collect results. Intended for internal use only, not production.
Set expectations first: this is not a Laravel package. There is no service provider, no config file, no facade, no container binding, nothing published. composer require nunomaduro/pokio installs two global functions — async() and await() — backed by PCNTL process forking and FFI shared memory. That means the entire integration question is "which of my existing CLI entrypoints can fan out safely," not "how do I wire this into the framework."
Install and verify the environment before writing any code:
composer require nunomaduro/pokio
php -v # must be 8.3+
php -m | grep -E '^(pcntl|FFI)$' # both needed for real parallelism
php -i | grep ffi.enable # must not be '0' or 'preload'
If PCNTL or FFI is missing, Pokio silently falls back to sequential execution — the code still runs and returns correct results, it just doesn't get faster. Bake that check into a helper so you know which mode you're in:
function can_run_in_parallel(): bool
{
return function_exists('pcntl_fork')
&& extension_loaded('FFI')
&& ! in_array(ini_get('ffi.enable'), ['', '0', 'false', 'preload'], true);
}
Where to look first. The README is short and complete — read it end to end, it takes five minutes. Then skim vendor/nunomaduro/pokio/src for the async/await function definitions and the promise/ForkFuture class; you want to see exactly where serialization happens across the fork boundary, because that is the constraint you'll design around. There is nothing else to read.
First use case: an Artisan command that does a sequential fan-out. The canonical shape is a loop of independent I/O-bound units. Find a command in your codebase that hits N endpoints, N tenants, or N files in a foreach. That's your pilot. Run the README's two-sleeps example once as a smoke test to confirm real parallelism on your machine, then time the real script:
$start = hrtime(true);
// ... existing sequential loop ...
$this->info(sprintf('%.2fs', (hrtime(true) - $start) / 1e9));
Record the number. Only convert the loop if waiting dominates the runtime.
Fan-out/fan-in with a chunked pool. Pokio has no concurrency limit — one async() call is one forked OS process. Never call it in an unbounded loop over a query result. Chunk, await, repeat:
$urls = Endpoint::pluck('url')->all();
$results = collect($urls)
->chunk(10) // 10 concurrent children max
->flatMap(function ($chunk) {
$chunk = array_values($chunk->all());
$promises = array_map(
fn (string $url) => async(fn () => Http::timeout(5)->get($url)->status()),
$chunk,
);
return array_combine($chunk, await($promises));
});
Chunk size is your only backpressure knob. For I/O-bound work it can exceed core count; for CPU-bound work, keep it at or below nproc.
Isolate failures per task. One throwing child should not abort the batch. Either attach catch() and return a result-shaped error value, or collect throwables by index:
$promises = array_map(
fn (int $id) => async(fn () => $this->syncTenant($id))
->catch(fn (Throwable $e) => ['tenant' => $id, 'error' => $e->getMessage()]),
$tenantIds,
);
$outcomes = await($promises);
catch() runs after resolution, so it composes with then() for post-processing. Use finally() for cleanup/telemetry that must fire either way.
Return small, serializable payloads only. Results cross an FFI-backed shared memory segment via serialization. Return scalars, arrays, or simple DTOs — never Eloquent models, closures, resources, or anything holding a live connection. If you need the full record, return the ID and re-query in the parent. This also keeps shared memory pressure low.
Wrap it behind one seam in your own code. Product code should never call async() directly. Put it in a small service (e.g. App\Support\Parallel) with a method that today uses Pokio and tomorrow could use Concurrency::run(), Http::pool(), or a plain foreach. That gives you a one-file rollback and a single place to add the capability check and logging.
final class Parallel
{
public static function map(array $items, Closure $work, int $chunk = 10): array
{
if (! can_run_in_parallel()) {
return array_map($work, $items);
}
// chunked async/await as above
}
}
Compose heterogeneous tasks with the array form of await(). Mixing an HTTP sweep with a file scan in one command is a single await([$a, $b]). If you return a promise from inside an async closure it is awaited automatically, so nesting works without ceremony. $promise() (invoking the promise) is shorthand for await($promise) when you have a single one.
Know when to reach for something else. Laravel already ships Concurrency::run() and Http::pool(); Http::pool() in particular is strictly better for HTTP fan-out because it multiplexes one client instead of forking. Use Pokio when the unit of work is not an HTTP call — parallel Artisan invocations, multi-tenant backfills, seeding, multi-region/multi-path CLI sweeps, smoke tests against many hosts. For anything needing retries, priorities, idempotency, or per-task telemetry, use the queue.
Keep it out of long-lived processes. Not in controllers, not in jobs, not in Octane workers, not in scheduled commands that share a process. Forking inside a booted worker clones PDO connections, Redis sockets, held locks and log handlers into a child that then writes to shared resources it no longer owns.
Fork safety is the real risk, and it's invisible in review. A child inherits the parent's entire heap: open database connections, Redis sockets, Guzzle clients, Monolog handlers, file handles. Two processes then use the same socket. Mitigate by purging connections at the top of the child closure — DB::purge() before the first query, or better, structure the unit of work so the child opens and closes its own connections. Never let a child write to a database the parent has an open transaction on.
Copy-on-write means every child pays the bootstrap cost. A full Laravel app boot is a few MB of heap; 50 children is 50 boots' worth of memory. If the unit of work only needs HTTP, don't boot the whole framework — consider a leaner script or fewer, larger chunks.
The silent fallback will hide a broken parallel path. Everything passes locally, CI runs on a hardened image without FFI, and the suite is green while being 6× slower than expected. Log the mode explicitly at command start ($this->info(can_run_in_parallel() ? 'parallel' : 'sequential')) and assert in CI that at least one job reports parallel mode.
Xdebug's debug mode disables forking as of v1.0.1. Stepping through a command and wondering why nothing is concurrent is expected behaviour, not a bug.
Windows and some shared hosts have no PCNTL. Your script must be correct without concurrency — never depend on parallelism for ordering, isolation, or timing.
There is no timeout. A hung child blocks await() indefinitely. Every unit of work needs its own bound: Http::timeout(), Process::timeout(), or explicit socket timeouts. Assume the batch can hang and wrap the command in a CI-level timeout.
Interactive prompts break in children. Forks inherit STDIN; $this->ask() inside an async closure will produce garbage. Resolve all input in the parent before fanning out, and pass it in via closure capture.
Child echo interleaves with parent output. Progress bars, $this->line() from children, and dump() all fight for the same TTY. Return values and let the parent render.
Watch for global function name collisions. async and await are defined in the global namespace. Grep your codebase and vendor tree for existing definitions before you ship — a collision is a fatal error, not a subtle bug.
No pooling, no cancellation, no result streaming. Results appear only when await() returns. Design commands to await once at the end of a chunk, not after each task.
Testing. Test the sequential path — PHPUnit is single-process, so the fallback branch is what your tests actually exercise, which is fine. Assert on results and on error isolation, never on wall-clock timing. Add one opt-in integration test that asserts can_run_in_parallel() and runs a two-task fan-out, skipped when extensions are absent.
Pin the version and watch releases. The 1.0.1 change to fork behaviour under Xdebug is exactly the kind of patch that alters semantics silently. The README itself says "don't use this in production" — treat that as a standing decision that needs an owner, a documented rationale, and a two-week pilot before it spreads beyond one command.
Extension points: there are none. No middleware, no driver interface, no config to override. Your wrapper class is the extension point. Build it first, and keep the blast radius to one file.
How can I help you explore Laravel packages today?