PHP 8.4 Fibers: Async Patterns Without a Framework
Every time I need to run a handful of slow operations "at the same time", I reach for a third-party event loop. ReactPHP, Amp, Revolt — they're all solid. But they also pull in a significant dependency tree and a mental model shift. PHP has had Fibers since 8.1, and PHP 8.4 quietly improved them. Before you composer require anything, it's worth understanding what Fibers actually give you.
What are PHP Fibers for async programming?
A Fiber is a pausable unit of execution with its own call stack. Not a thread — there's no OS-level parallelism here — but a lightweight cooperative coroutine that you start, suspend, and resume from your own code.
The key word is cooperative. A Fiber pauses itself by calling Fiber::suspend(). Nothing preempts it. Your scheduler (or your main execution flow) decides when to run each Fiber next. That's both the power and the limitation.
$fiber = new Fiber(function (): void {
echo "Fiber started\n";
$resumeValue = Fiber::suspend('waiting');
echo "Resumed with: {$resumeValue}\n";
});
$suspendedWith = $fiber->start(); // "Fiber started", returns 'waiting'
echo "Fiber suspended with: {$suspendedWith}\n";
$fiber->resume('hello'); // "Resumed with: hello"
The value passed to Fiber::suspend() travels back to the caller via start() or resume(). The value passed to resume() becomes the return value of Fiber::suspend() inside the fiber. That two-way channel is what makes Fibers more powerful than generators.
Creating and running a Fiber
Here's the full API you'll use day-to-day:
// Create
$fiber = new Fiber(function (): string {
$first = Fiber::suspend('first pause');
$second = Fiber::suspend('second pause');
return "done: {$first}, {$second}";
});
// Start — runs until first Fiber::suspend() or completion
$value = $fiber->start(); // returns 'first pause'
// Resume — runs until next suspend() or completion
$value = $fiber->resume('a'); // returns 'second pause'
// Final resume — fiber completes
$fiber->resume('b'); // fiber terminates
// Get the return value (only after termination)
echo $fiber->getReturn(); // "done: a, b"
State checks let you inspect what a fiber is doing:
$fiber->isStarted(); // true after start()
$fiber->isSuspended(); // true when paused at Fiber::suspend()
$fiber->isRunning(); // true only while inside the fiber callback
$fiber->isTerminated(); // true once the callback has returned
You'll use isTerminated() and isSuspended() most in scheduler loops.
A practical cooperative scheduler
The real value of Fibers shows up when you run several of them in a loop, interleaving their execution. Here's a minimal round-robin scheduler:
class FiberScheduler
{
/** @var SplQueue<Fiber> */
private SplQueue $queue;
public function __construct()
{
$this->queue = new SplQueue();
}
public function add(Fiber $fiber): void
{
$this->queue->enqueue($fiber);
}
public function run(): void
{
while (! $this->queue->isEmpty()) {
$fiber = $this->queue->dequeue();
if (! $fiber->isStarted()) {
$fiber->start();
} elseif ($fiber->isSuspended()) {
$fiber->resume();
}
// Re-queue if there's more work to do
if (! $fiber->isTerminated()) {
$this->queue->enqueue($fiber);
}
}
}
}
Now wire up two tasks that simulate slow work:
function slowTask(string $name, int $steps): void
{
for ($i = 1; $i <= $steps; $i++) {
echo "[{$name}] step {$i}\n";
Fiber::suspend(); // yield control back to the scheduler
}
}
$scheduler = new FiberScheduler();
$scheduler->add(new Fiber(fn () => slowTask('Alpha', 3)));
$scheduler->add(new Fiber(fn () => slowTask('Beta', 3)));
$scheduler->run();
Output:
[Alpha] step 1
[Beta] step 1
[Alpha] step 2
[Beta] step 2
[Alpha] step 3
[Beta] step 3
Alpha and Beta interleave cleanly. No threads, no event loop library, no async/await syntax. This is cooperative multitasking in plain PHP.
Fibers vs Generators vs ReactPHP
A common question is whether Generators can do the same thing. They can do some of it, but there's a meaningful difference.
// Generator — stackless, can only yield from the top-level function
function generatorTask(): Generator
{
echo "step 1\n";
yield;
echo "step 2\n";
}
// Fiber — full call stack, can suspend from a nested function call
function nestedHelper(): void
{
echo "inside helper\n";
Fiber::suspend(); // this works, even though we're not in the fiber callback directly
}
$fiber = new Fiber(function (): void {
echo "step 1\n";
nestedHelper(); // suspend happens inside a nested call
echo "step 3\n";
});
With a Generator, yield can only appear in the generator function itself. With a Fiber, Fiber::suspend() can be called anywhere in the call stack — a helper function, a method, a recursive call. That's what "full-stack" means, and it's what makes Fibers suitable as the primitive underneath a real event loop.
Revolt and Amp are built on Fibers. They handle the actual I/O integration — watching sockets, timers, and signals — and let Fibers do the suspension bookkeeping. If you need real non-blocking I/O, that's still the right tool. Fibers alone don't make file_get_contents() non-blocking.
I prefer Fibers for simple cooperative patterns where I control the scheduler. I reach for Revolt when I need real I/O multiplexing.
PHP 8.4 Fiber improvement
PHP 8.4 relaxed one restriction that bit me more than once: prior to 8.4, you couldn't switch fibers during object destructor execution. If a Fiber-heavy scheduler triggered GC at an awkward time, you'd hit a FiberError. PHP 8.4 removes that constraint, making Fiber-based schedulers more reliable in long-running processes.
There's no new syntax — just a rough edge filed down. If you're running anything Fiber-based in a daemon or queue worker, upgrading to PHP 8.4 is worth it for stability alone.
Gotchas and Edge Cases
Calling Fiber::suspend() outside a fiber throws immediately:
// FiberError: Cannot call Fiber::suspend() when not in a fiber
Fiber::suspend();
Always guard with Fiber::getCurrent() !== null if you're writing code that might run in both fiber and non-fiber contexts.
getReturn() before termination throws:
$fiber = new Fiber(fn () => 'result');
$fiber->start();
// FiberError: Cannot get fiber return value before it returns
$fiber->getReturn();
Check $fiber->isTerminated() first.
Resuming a non-suspended fiber throws:
$fiber = new Fiber(fn () => null);
// FiberError: Cannot resume a running fiber
$fiber->resume(); // not started yet
Always call start() first, then resume() only when isSuspended() is true.
Exceptions inside a fiber bubble to the caller:
$fiber = new Fiber(function (): void {
throw new RuntimeException('something broke');
});
try {
$fiber->start(); // exception propagates here
} catch (RuntimeException $e) {
echo $e->getMessage(); // "something broke"
}
You can also inject exceptions into a fiber from outside using $fiber->throw(new RuntimeException(...)), which resumes the fiber by throwing at the Fiber::suspend() call site.
Fibers are not threads — no true parallelism:
Two fibers sharing a slow curl_exec() call won't speed it up. Both still block. Fibers let you interleave CPU work and application logic cooperatively, not overlap blocking I/O in parallel. For that, you need non-blocking I/O (stream selects, curl_multi) or actual process-level parallelism with pcntl_fork() or the parallel extension.
Wrapping Up
PHP Fibers give you a clean, dependency-free primitive for cooperative concurrency — useful for schedulers, progress tracking, and anything that benefits from interleaved execution without a full event loop. PHP 8.4 removes the destructor switching restriction, making them more reliable in long-running processes. For real async I/O, still reach for Revolt or Amp — they're built on Fibers under the hood, so the mental model transfers directly.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.