Async (Fibers)
Lift\Async\Concurrent is a tiny cooperative-concurrency helper built on PHP 8.1 Fibers. It lets you run several blocking I/O calls in parallel within a single PHP process, without ext-amphp / ext-react / ext-swoole.
Mental model: a
Fiberis a function that can pause itself (suspend) and resume later.Concurrent::all([...])starts many tasks as fibers, round-robins them until they all finish, and collects results.
Important: this is not real parallelism — PHP still runs one statement at a time. Fibers help when tasks spend most of their time waiting for I/O (HTTP calls, DB queries, sleeps). For CPU-bound work they don't help; use a queue worker pool instead.
When (and when not) to use it
✅ Good fits
- Hit 5 third-party APIs and merge their results — sequential = 5×latency, concurrent ≈ 1×latency.
- Batch up many
curl_multicalls behind a clean interface. - Pre-warm caches by issuing several reads at once.
- Run a few "best-effort" operations after a write without blocking the response.
❌ Bad fits
- CPU-bound work (image resizing, parsing) — fibers don't get more cores.
- Anything you can move to a queue — async-in-request is harder to reason about than async-via-worker.
- Long-running streams — use Server-Sent Events or a real event loop.
30-second example
use Lift\Async\Concurrent;
use Lift\Http\HttpClient;
[$github, $weather, $stocks] = Concurrent::all([
fn() => HttpClient::new()->get('https://api.github.com/repos/malinichevvv/lift-php')->json(),
fn() => HttpClient::new()->get('https://api.weather.gov/...')->json(),
fn() => HttpClient::new()->get('https://api.iexcloud.io/...')->json(),
]);
return Response::json([
'github' => $github,
'weather' => $weather,
'stocks' => $stocks,
]);
If each call takes ~200 ms blocking, the sequential version takes ~600 ms and the concurrent version takes ~200 ms — if the underlying client yields during I/O. Otherwise, this is still equivalent to Concurrent::sequential(...). See "When fibers actually help" below.
API
Concurrent::all(array $tasks): array
Starts each callable as a fiber, round-robins until all complete, returns results in the same order. Re-throws the first exception from any task.
$results = Concurrent::all([
'github' => fn() => fetchGithub(),
'weather' => fn() => fetchWeather(),
]);
// $results['github'], $results['weather']
Keys are preserved.
Concurrent::suspend(): void
Inside a task callable, yield control to the next fiber:
Concurrent::all([
function () {
$data = openSocketRead(); // blocking-ish
Concurrent::suspend(); // let other tasks run
$more = openSocketRead();
return $data . $more;
},
function () { /* … */ },
]);
Calling suspend() outside a fiber is a no-op — safe to use everywhere.
Concurrent::sequential(array $tasks): array
Identical signature to all(), but runs tasks one after another. Useful as a drop-in for environments that prohibit fibers (e.g. PHP < 8.1, or test suites that don't tolerate fiber lifecycle quirks):
$tasks = [/* … */];
$results = $useFibers ? Concurrent::all($tasks) : Concurrent::sequential($tasks);
Concurrent::run(callable $task): mixed
Wrap a single callable in a fiber and run it to completion. Mainly useful for stress-testing a function's fiber-safety.
When fibers actually help
A fiber only yields when something inside it calls Fiber::suspend(). PHP doesn't auto-yield during native I/O. So:
| Library / call | Auto-yields? |
|---|---|
curl_exec, plain file_get_contents |
❌ blocks the whole process |
curl_multi_* with manual select |
✅ if you wrap select in suspend() |
ReactPHP / amphp async clients |
✅ — they integrate with the fiber scheduler |
sleep() / usleep() |
❌ — blocks |
Concurrent::suspend() explicit yield |
✅ |
In short: Concurrent::all([HttpClient::new()->get(...), ...]) does not speed things up because cURL blocks the whole PHP process. Use it with libraries that integrate with PHP fibers, or with curl_multi_* where you can sprinkle suspend() between selects.
For an actual speedup with HTTP, drop down to curl_multi:
function parallelGet(array $urls): array
{
$mh = curl_multi_init();
$handles = [];
foreach ($urls as $i => $url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($mh, $ch);
$handles[$i] = $ch;
}
$running = null;
do {
curl_multi_exec($mh, $running);
curl_multi_select($mh, 0.1);
} while ($running > 0);
$out = [];
foreach ($handles as $i => $ch) {
$out[$i] = curl_multi_getcontent($ch);
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
curl_multi_close($mh);
return $out;
}
$pages = parallelGet([
'https://api.example.com/a',
'https://api.example.com/b',
'https://api.example.com/c',
]);
curl_multi_select($mh, 0.1) is the natural place to Concurrent::suspend() if you want to weave this into a larger fiber group.
Real-world pattern — fan-out with timeout
use Lift\Async\Concurrent;
$timeout = 2.0;
$start = microtime(true);
$results = Concurrent::all([
'inventory' => fn() => $this->safe(fn() => $this->inventory->lookup($sku), $timeout, $start),
'pricing' => fn() => $this->safe(fn() => $this->pricing->fetch($sku), $timeout, $start),
'reviews' => fn() => $this->safe(fn() => $this->reviews->latest($sku), $timeout, $start),
]);
return Response::json($results);
private function safe(callable $work, float $timeout, float $start): mixed
{
if (microtime(true) - $start > $timeout) {
return null; // bail out — we're past the deadline
}
try {
return $work();
} catch (\Throwable) {
return null; // best-effort: degrade gracefully
}
}
Each task tracks the global deadline and bails out gracefully — the response never takes longer than ~timeout seconds, even if one upstream is slow.
Error handling
Concurrent::all() re-throws the first exception from any task after all fibers complete. If you need per-task error isolation, wrap each callable in its own try/catch:
$results = Concurrent::all([
fn() => safelyCall(fn() => $a->fetch()),
fn() => safelyCall(fn() => $b->fetch()),
]);
function safelyCall(callable $work): array
{
try { return ['ok' => true, 'value' => $work()]; }
catch (\Throwable $e) { return ['ok' => false, 'error' => $e->getMessage()]; }
}
This is what most production code looks like — bubble individual failures, never let one bad task kill the batch.
Testing
Fibers are deterministic when all tasks are pure. Treat them like normal functions in tests:
public function testParallelFetchMergesResults(): void
{
$service = new ProductPage(/* mocked clients */);
$result = $service->show('SKU-1');
self::assertSame('Widget', $result['name']);
self::assertArrayHasKey('reviews', $result);
}
Avoid asserting "they actually ran in parallel" — that's an implementation detail. Assert the outcome.
Limitations
- No event loop —
Concurrent::allis a busy-resume loop. Real-life async needs ext-event / ReactPHP / amphp. - Globals leak across fibers. PHP's
$_SERVER, error handlers, and many extensions don't expect fiber switches. Stay within pure callables, don't fiber across third-party code that fiddles with global state. - Tasks share the request — they all see the same
Request,Connection, container. No isolation. Mind your transactions. - Cancellation isn't supported. Once a task is started, it runs to completion (or throws). Use deadlines inside the task.
For more serious async workloads, reach for ReactPHP, amphp, FrankenPHP, or RoadRunner. Concurrent is the 50-line answer for the simple "fan out N HTTP calls" case.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Concurrent::all([...]) is the same speed as sequential |
Tasks don't yield at I/O wait points | Use libraries that integrate with PHP fibers, or curl_multi_*. |
Fiber::suspend called outside a fiber |
suspend() from non-fiber context |
The helper makes this a no-op; check you're inside a task callable. |
| One bad task throws and others were dropped | all() re-throws the first error |
Wrap each task in try/catch for per-task isolation. |
| Memory grows linearly with fiber count | You created N fibers but never let them finish | Don't create thousands of fibers per request — batch in groups. |
| Transactions interleave weirdly | Two fibers share one DB connection | Don't open a transaction inside a fiber unless you also open a separate connection per fiber. |
$_SESSION writes lost |
PHP session save handlers are not fiber-aware | Use Sessions (driver-backed) instead. |
Cheat sheet
use Lift\Async\Concurrent;
// Fan-out
$results = Concurrent::all([
'a' => fn() => callA(),
'b' => fn() => callB(),
'c' => fn() => callC(),
]);
// Yield inside a task
Concurrent::suspend();
// Sequential fallback (no-fiber environments)
$results = Concurrent::sequential($tasks);
// Single fiber wrapper
$value = Concurrent::run(fn() => doSomething());
// First-error semantics
try {
Concurrent::all($tasks);
} catch (\Throwable $e) {
// …first task to throw bubbles up here
}