PHP Fibers: Async PHP Without a Framework

#php fibers
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

An aggregation endpoint that calls six internal services one after another spends most of its life doing nothing. Six calls averaging 400 ms each add up to roughly 2.4 seconds of wall time, and nearly all of it is a PHP process sitting idle on a socket. PHP Fibers, added in 8.1, let you reclaim that idle time without adopting ReactPHP, Swoole, or rewriting your application around an event loop.

That is the promise. The reality has more edges to it, and the version of this topic that circulates in conference talks tends to skip them. Below is what Fibers actually give you, what they leave for you to build, and the one Symfony use case where we think the extra machinery pays for itself.

What PHP Fibers actually are

A Fiber is a function whose execution can be paused by itself and resumed by whoever started it. It carries its own call stack, so the pause can happen five levels deep inside nested calls, and when you resume it, execution continues from exactly that point with the stack intact.

They are not threads. Only one Fiber runs at a time, in the same process, on the same core. Nothing about Fibers gives you parallel CPU work. They are not processes either: same memory, same request lifecycle, same connections.

The comparison that matters is with generators. PHP has had cooperative multitasking through yield since 5.5, and people built schedulers on it. The limitation was always that only the generator function body can yield. If your HTTP client is three calls down the stack, you had to make every intermediate function a generator and forward the yields by hand. A Fiber suspends the whole stack below it. Your repository method can call a client method that suspends, and neither the repository nor the caller needs to know.

That single difference is why Fibers exist. Everything else is bookkeeping.

The entire API

There is not much to learn.

$fiber = new Fiber(function (string $url): string {
    $handle = openConnection($url);
    $payload = Fiber::suspend($handle);

    return process($payload);
});

$handle = $fiber->start('https://api.example.com/orders');
// The socket is open. Control is back here. Do something else.

$fiber->resume($rawPayload);
$result = $fiber->getReturn();

start() runs the function until the first Fiber::suspend() and returns whatever that suspend passed out. resume() sends a value back in, and that value becomes the return value of Fiber::suspend() inside the Fiber. getReturn() gives you the function's return value once isTerminated() is true.

The rest of the surface is isStarted(), isSuspended(), isRunning(), isTerminated(), throw() for injecting an exception at the suspension point, and the static Fiber::getCurrent(), which returns the running Fiber or null. That is the whole feature.

The part the tutorials skip

Fibers do not make blocking calls non-blocking. Put file_get_contents() inside a Fiber and the entire PHP process stops until the response arrives, exactly as before. Suspension is voluntary. Something has to notice that a socket is not ready yet and choose to suspend, which means you need a transport that can report readiness and a scheduler that decides who runs next. PHP gives you neither.

There is also a hard limit worth knowing before you design around it: you cannot suspend across an internal function's callback. Call Fiber::suspend() from inside a closure passed to array_map(), usort(), or preg_replace_callback() and PHP throws a FiberError, because the C stack frame of the internal function sits between the Fiber and its suspension point. In practice this means awaiting inside a mapping callback does not work, and you restructure to a foreach instead.

A scheduler short enough to read

The minimum viable scheduler is round-robin over a queue.

final class Scheduler
{
    /** @var list<Fiber> */
    private array $queue = [];

    public function spawn(callable $task): void
    {
        $this->queue[] = new Fiber($task);
    }

    public function run(): void
    {
        while ($this->queue !== []) {
            $fiber = array_shift($this->queue);

            if (!$fiber->isStarted()) {
                $fiber->start();
            } elseif ($fiber->isSuspended()) {
                $fiber->resume();
            }

            if (!$fiber->isTerminated()) {
                $this->queue[] = $fiber;
            }
        }
    }
}

Thirty lines, and you can hold all of it in your head. That is a real advantage over an event loop you did not write. The cooperative part is also the dangerous part: a task that never suspends starves every other task in the queue, and there is no preemption to save you. A busy loop in one Fiber hangs the request.

Concurrent HTTP calls in Symfony without Swoole

Here is where Fibers earn their place in a normal Symfony application. Symfony's HttpClient is already asynchronous underneath. $client->request() returns immediately with a lazy ResponseInterface; the request only completes when you call getContent() or toArray(), and $client->stream() multiplexes many responses over one curl multi handle.

So you can already get concurrency with a plain stream() loop. The problem is what your code looks like afterwards. Each response arrives as a chunk event, and any per-request logic beyond "collect the body" turns into a switch statement over response identity, which is a state machine you now maintain by hand.

Fibers let each task keep its linear shape while the scheduler drives the multiplexed transport.

final class HttpScheduler
{
    /** @var array<int, Fiber> */
    private array $fibers = [];

    /** @var array<int, ResponseInterface> */
    private array $waiting = [];

    public function __construct(private readonly HttpClientInterface $client)
    {
    }

    public function spawn(callable $task): void
    {
        $fiber = new Fiber($task);
        $this->fibers[spl_object_id($fiber)] = $fiber;
    }

    public function await(ResponseInterface $response): ResponseInterface
    {
        $fiber = Fiber::getCurrent()
            ?? throw new LogicException('await() must run inside a fiber');

        $this->waiting[spl_object_id($fiber)] = $response;
        Fiber::suspend();

        return $response;
    }

    public function run(): void
    {
        foreach ($this->fibers as $fiber) {
            $fiber->start();
        }

        while ($this->waiting !== []) {
            $completed = [];

            foreach ($this->client->stream($this->waiting, 0.05) as $response => $chunk) {
                if ($chunk->isTimeout()) {
                    break;
                }

                if ($chunk->isLast()) {
                    $completed[] = array_search($response, $this->waiting, true);
                }
            }

            foreach ($completed as $id) {
                unset($this->waiting[$id]);
                $this->fibers[$id]->resume();
            }
        }
    }
}

Resuming happens after the stream() iteration finishes rather than inside it, because a resumed Fiber can call await() again and mutate $waiting while you are iterating over it. That ordering is the one subtlety in the class.

The calling code reads top to bottom:

$scheduler = new HttpScheduler($client);
$orders = $inventory = [];

$scheduler->spawn(function () use ($scheduler, $client, &$orders): void {
    $response = $scheduler->await($client->request('GET', '/api/orders'));
    $orders = $response->toArray();
});

$scheduler->spawn(function () use ($scheduler, $client, &$inventory): void {
    $response = $scheduler->await($client->request('GET', '/api/inventory'));
    $inventory = $response->toArray();
});

$scheduler->run();

Two requests go out together. Each task keeps its own sequential logic, including any follow-up call it needs to make based on the first response.

What it costs and what it buys

For an aggregation endpoint hitting six services, the arithmetic is straightforward:

ApproachWall time, six calls at ~400 msShape of the code
Sequential toArray()~2.4 sLinear, obvious
Raw stream() loop~0.5 sHand-written state machine
Fibers over stream()~0.5 sLinear per task

Read that table carefully, because it contains the honest conclusion. The latency win comes from curl multiplexing, not from Fibers. A stream() loop gets you the same wall time with zero new abstractions. What Fibers buy is the second column: readable per-task logic when a task does more than fetch one URL and stop.

Two costs offset that. CPU work stays serial, so decoding six large JSON payloads still happens one after another and shows up in your profile. And stack traces get worse, because an exception thrown inside a Fiber unwinds to the Fiber boundary and a hung Fiber produces no exception at all, just an endpoint that never returns. Budget time for that the first time something goes wrong in production.

When to skip this entirely

If your endpoint makes one external call, there is nothing to interleave and no reason to add a scheduler. If the slow part is a database query rather than an HTTP round trip, Fibers do not help, since PDO and Doctrine block the process regardless of what wraps them.

If you already run AMPHP, note that version 3 is built on Fibers with the Revolt event loop underneath. Use it rather than hand-rolling a scheduler, and reach for the code above only when you want to understand what the library is doing or when adding the dependency is not an option.

A few rules keep this boring once it is in a production codebase. Own the scheduler in exactly one class at the edge of the request rather than scattering Fiber::suspend() through your services. Never suspend inside a Doctrine transaction, since another Fiber may run and use the same connection while the transaction is open. Put a timeout on every awaited operation, because a task waiting forever is the failure mode you will actually hit. And cap how many tasks you spawn per request, since eight concurrent outbound calls per user request can saturate a downstream service faster than sequential code ever would. The same reasoning we apply to Symfony HttpClient in production applies here with more force.

One more caveat for anyone on a long-running runtime. Under FrankenPHP worker mode or Symfony Runtime, a Fiber that is suspended when the request ends does not disappear. It holds its stack, its closures, and everything they captured. Terminate your schedulers explicitly at the end of each request or you will debug a memory leak that only appears after a few thousand requests.

Common questions

Are Fibers faster than generators for coroutines?

Not measurably in throughput terms. The difference is structural: a Fiber suspends the entire stack below it, so intermediate functions need no changes, while a generator-based scheduler forces every function in the chain to become a generator and forward yields manually.

Can I use Fibers with PHP-FPM?

Yes. Fibers are a language feature and need no special SAPI. They live and die inside a single request, which is exactly the model PHP-FPM gives you. Worker-mode runtimes are where lifecycle management needs attention.

Do Fibers replace Symfony Messenger?

No. Messenger moves work out of the request into a separate process. Fibers interleave work inside one request. Offload anything the user does not need to wait for; use Fibers for calls whose results you have to return in the same response.

Where this fits

Fibers are worth adding when you have a specific endpoint whose latency is dominated by several independent I/O calls and whose per-call logic is too branchy for a flat stream() loop. That is a narrow target, and outside it the sequential version is the better engineering decision.

If you are looking at an aggregation endpoint and cannot tell whether the latency is I/O wait, serial CPU work, or an N+1 query hiding behind an ORM call, measuring first is cheaper than restructuring. That kind of profiling is part of our code quality consulting work, and concurrency design shows up regularly in custom software development projects where an API has to aggregate from services nobody controls.

Happy to look at a specific endpoint with you. Send the details to hello@wolf-tech.io, or read more about how we work at wolf-tech.io.