- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Fibers shipped in PHP 8.1 in November 2021. The RFC was small, the API has four methods, and most Laravel codebases still don't touch them. Open vendor/ in a fresh Laravel project and you'll find Guzzle, Symfony HttpClient, and ReactPHP all using them under the hood. Your own code? Probably none.
That's fine for a CRUD app. The moment you fan out to five HTTP services, stream a multi-gigabyte file, or wire a webhook dispatcher, the queue-or-block dichotomy gets expensive. Fibers fix a specific class of problem that queues over-solve and curl_multi under-solves. Here are four cases where they earn their keep.
What Fibers actually are (60-second mental model)
A Fiber is a coroutine. Not a thread. Not a process. One stack of execution that can pause itself and resume later, all inside the same PHP process.
The whole API:
$fiber = new Fiber(function (): void {
echo "step 1\n";
$resumed = Fiber::suspend("paused"); // hands control back
echo "step 2 with: $resumed\n";
});
$out = $fiber->start(); // "step 1", returns "paused"
$fiber->resume("hello"); // "step 2 with: hello"
$fiber->isTerminated(); // true
Fiber::suspend() is the magic. It can only be called from inside a running Fiber. Call it from the main flow and you get Fiber\FiberError: Cannot call Fiber::suspend() outside of a Fiber. The caller of start() or resume() gets back whatever you suspended with.
Two things to internalise before we go further:
- Fibers are cooperative. Nothing preempts them. If a Fiber doesn't call
suspend(), it runs to completion and blocks the main flow. - They share the same process. No GIL, no memory copy, no IPC. Globals are shared. So is the PDO connection, which is the first gotcha most people hit (more below).
Case 1: Parallel HTTP fan-out
Your checkout endpoint calls the payment gateway, the fraud service, the loyalty API, the shipping calculator, and the warehouse availability service. Five calls, ~400ms each, sequential. That's 2 seconds on the happy path.
curl_multi_exec() solves this but the API is a 20-year-old polling loop with do {} while and select-style fd watching. It works. Reading it does not bring joy.
Here's the Fiber version. The pattern: each HTTP call lives in its own Fiber. A small scheduler pumps curl_multi and resumes Fibers as their handles finish.
final class ParallelHttp
{
/** @param array<string, string> $urls keyed by label */
public static function get(array $urls): array
{
$mh = curl_multi_init();
$handles = [];
$fibers = [];
foreach ($urls as $key => $url) {
$fibers[$key] = new Fiber(function () use ($url, $mh, &$handles, $key) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
curl_multi_add_handle($mh, $ch);
$handles[$key] = $ch;
// hand control back to the scheduler until curl says done
Fiber::suspend();
$body = curl_multi_getcontent($ch);
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
return $body;
});
$fibers[$key]->start();
}
// pump until every handle is finished
do {
curl_multi_exec($mh, $active);
curl_multi_select($mh, 0.05);
} while ($active > 0);
curl_multi_close($mh);
$results = [];
foreach ($fibers as $key => $fiber) {
$results[$key] = $fiber->resume();
}
return $results;
}
}
$out = ParallelHttp::get([
'payment' => 'https://api.example.com/payment/check',
'fraud' => 'https://api.example.com/fraud/score',
'loyalty' => 'https://api.example.com/loyalty/balance',
'shipping' => 'https://api.example.com/shipping/quote',
'stock' => 'https://api.example.com/warehouse/stock',
]);
Numbers from a synthetic benchmark against five endpoints with an artificial 400ms server-side delay: sequential cURL takes ~2,050ms wall-clock; the Fiber + curl_multi version finishes in ~440ms. That's the math you'd expect. Wall-clock collapses to roughly the slowest call plus a small scheduling overhead. The Guzzle async docs report similar shapes and ReactPHP's HTTP client benchmarks sit in the same range.
The Fiber version isn't faster than raw curl_multi. It's the same network primitive underneath. What changes is that each Fiber gets a normal-looking function body. No callbacks, no then(), no promise chains. That matters when the failure handling gets non-trivial. Try/catch works exactly the way you'd expect.
Case 2: Stream processing with backpressure
Reading a 10GB CSV with fopen + fgetcsv works fine until you batch-write to a database that can't keep up. The naive script reads as fast as PHP can parse and the downstream consumer either OOMs the queue or drops rows.
Fibers let the producer pause when the consumer is behind. Here's a pattern with a bounded buffer:
final class BoundedChannel
{
/** @var list<array<string, string>> */
private array $buffer = [];
private ?Fiber $waitingProducer = null;
private ?Fiber $waitingConsumer = null;
private bool $closed = false;
public function __construct(private readonly int $capacity = 100) {}
public function send(array $row): void
{
while (count($this->buffer) >= $this->capacity) {
$this->waitingProducer = Fiber::getCurrent();
Fiber::suspend(); // wait for the consumer to drain
}
$this->buffer[] = $row;
if ($this->waitingConsumer !== null) {
$c = $this->waitingConsumer;
$this->waitingConsumer = null;
$c->resume();
}
}
public function receive(): ?array
{
while ($this->buffer === [] && !$this->closed) {
$this->waitingConsumer = Fiber::getCurrent();
Fiber::suspend();
}
if ($this->buffer === []) return null;
$row = array_shift($this->buffer);
if ($this->waitingProducer !== null) {
$p = $this->waitingProducer;
$this->waitingProducer = null;
$p->resume();
}
return $row;
}
public function close(): void { $this->closed = true; }
}
A real ingestion pipeline wires a producer Fiber and a consumer Fiber against the channel:
$channel = new BoundedChannel(capacity: 500);
$producer = new Fiber(function () use ($channel): void {
$fp = fopen('orders-2026.csv', 'r');
$header = fgetcsv($fp);
while (($row = fgetcsv($fp)) !== false) {
$channel->send(array_combine($header, $row));
}
fclose($fp);
$channel->close();
});
$consumer = new Fiber(function () use ($channel): void {
$batch = [];
while (($row = $channel->receive()) !== null) {
$batch[] = $row;
if (count($batch) === 50) {
OrderImporter::insertBatch($batch); // blocking DB write
$batch = [];
}
}
if ($batch !== []) OrderImporter::insertBatch($batch);
});
$producer->start();
$consumer->start();
The producer parks itself once the buffer hits 500 rows. The consumer drains, the producer resumes. Memory stays flat. A 9GB CSV with this pattern keeps resident memory under 100MB across the run, because the buffer caps at 500 rows. The interesting comparison isn't speed (the DB is the bottleneck), it's that the script can't OOM regardless of how fast fgetcsv reads.
A generator gives you laziness, but generators can't yield from inside helper methods without polluting the entire call chain. Fibers can. That's the actual ergonomic win.
Case 3: Replacing generator coroutines
Before Fibers, people did this in PHP for async work:
function fetchUser(int $id): Generator {
$raw = yield $http->get("/users/$id"); // a Promise
$orders = yield $http->get("/orders?u=$id");
return ['user' => $raw, 'orders' => $orders];
}
You needed a "coroutine runner" that walked the generator, awaited each yielded promise, and pushed the result back in. The pattern works (Recoil, Amp v2 used it heavily, even Laravel Octane had flavors of it) but the signature : Generator was always wrong. The function returns array. The type system never agreed with reality.
Fibers fix this. Same logic, real return types:
public function fetchUser(int $id): UserBundle
{
$user = $this->client->get("/users/$id"); // suspends internally
$orders = $this->client->get("/orders?u=$id");
return new UserBundle($user, $orders);
}
The client suspends the current Fiber while waiting on the socket. The function reads as straight-line synchronous code. PHPStan, Psalm, IDE autocomplete: all of them get the right types. The Amp team rewrote everything around Fibers in v3 (release notes) for exactly this reason. ReactPHP shipped react/async 4.x doing the same: await() is a fiber-based primitive that lets a promise-returning library look synchronous.
If you've got a codebase with Generator-based "async" helpers from 2019, ripping them out for Fibers usually cuts 30-40% of the file in pure ceremony. No behaviour change.
Case 4: Inside ReactPHP / AMPHP without converting the whole codebase
You don't have to go all-in on an event loop to use Fibers. This is the point most "should I use Amp?" posts miss.
A Symfony Messenger handler that needs to fan out to three webhooks can pull in a Fiber-aware HTTP client locally, run a small await inside the handler, and stay otherwise vanilla:
use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;
use function Amp\async;
use function Amp\Future\awaitAll;
final class DispatchWebhooksHandler
{
public function __invoke(OrderPlaced $event): void
{
$client = HttpClientBuilder::buildDefault();
$urls = WebhookSubscriptions::for($event->orderId);
$futures = array_map(
fn(string $url) => async(fn() =>
$client->request(new Request($url, 'POST', json_encode($event)))
),
$urls,
);
[$errors, $responses] = awaitAll($futures);
foreach ($errors as $url => $err) {
FailedWebhook::record($url, $err->getMessage());
}
}
}
async() spawns a Fiber. awaitAll() blocks the handler until every Fiber finishes, but only this handler. The rest of your Symfony app keeps doing whatever it's doing. No event loop running globally. No "convert the whole framework to async."
The trick is that Amp's EventLoop initialises lazily on first use and shuts down cleanly when there's nothing pending. Same with react/async. You get coroutine ergonomics in one method without infecting the rest of the codebase.
Where Fibers don't help
PDO is blocking. Sticking a PDO::query() inside a Fiber doesn't make it async. The Fiber blocks the entire process until libpq returns. Same for mysqli in default mode. Same for file_get_contents() on a local file. Same for sleep().
CPU-bound work (image resizing, JSON encoding a 200MB structure, bcrypt) gets nothing from Fibers. They're for I/O wait. If the bottleneck is a tight for loop hashing things, you need pthreads, parallel, or pcntl_fork. Not coroutines.
The mental check: would this operation, in Node.js, return a Promise from a libuv-backed call? If yes, a Fiber-based wrapper will help. If no (your bottleneck is PHP itself), Fibers won't.
The big asterisk: there are async PDO replacements (amphp/postgres, amphp/mysql) that work cooperatively with Fibers. Worth knowing they exist. Worth knowing that swapping them in is rarely free. Eloquent and Doctrine both assume blocking drivers.
The error-handling gotcha
Exceptions inside Fibers behave almost like you'd expect. Almost.
$fiber = new Fiber(function (): void {
Fiber::suspend();
throw new RuntimeException("boom");
});
$fiber->start();
try {
$fiber->resume(); // exception propagates HERE, not where it was thrown
} catch (RuntimeException $e) {
// caught
}
The exception travels to whoever called resume(). That's usually fine. The trap is Fiber::throw():
$fiber = new Fiber(function (): void {
try {
Fiber::suspend();
} catch (DomainException $e) {
// the throw() call lands inside the suspend
}
});
$fiber->start();
$fiber->throw(new DomainException("cancelled"));
throw() injects an exception at the suspend() point. If the Fiber doesn't catch it, the exception bubbles up to the caller of throw(). People use this for cancellation, but it's easy to set up an exception that crosses three Fibers and lose track of who actually catches it.
Rule of thumb: keep Fiber bodies short. If a Fiber needs error handling more sophisticated than try/catch around its entry point, you probably want to be returning a Result-style object across suspend() instead of throwing through it.
What this means for your codebase
Fibers are a primitive, not a feature you bolt on. The right places to reach for them: parallel I/O fan-out, backpressure-aware streaming, replacing generator-based async helpers, and embedded use inside otherwise-blocking frameworks. The wrong places: CPU work, PDO, anywhere you don't have a clear suspend-point story.
If you already ship a Laravel or Symfony app, the lowest-risk first step is Case 4: a single handler that spawns a few Fibers via Amp or react/async, runs locally, and exits. No global event loop. No infrastructure change. Measure the win, then expand.
What's the slowest fan-out in your current app, and would Case 4 fit it without a framework rewrite?
If this was useful
Fibers are the runtime primitive. The bigger question is architectural: should this fan-out live in your domain layer or in an adapter? That's where most codebases get tangled. Decoupled PHP is the architectural layer your codebase reaches for once the framework defaults stop being enough; it covers how to keep async I/O on the edges and your domain code blissfully synchronous.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)