Fibers
Fibers are a low-level PHP feature for pausing and resuming a complete call stack. PHP added them in PHP 8.1. They give async libraries a way to interrupt work without forcing every function between the application and the interruption point to return a generator or expose a callback.
That description is precise, but it can create the wrong expectation. A fiber is not a thread, does not create CPU parallelism, and does not make blocking code non-blocking. It is a control-flow primitive. A scheduler, event loop, or other coordinator still has to decide when work should pause and when it is safe to continue.
Most application developers should use fibers through an established runtime rather than build a scheduler. Understanding the mechanics is still valuable because it explains how modern PHP libraries can present straightforward, sequential-looking APIs while coordinating several outstanding operations.
A Fiber Has Its Own Call Stack
A generator can suspend at a yield, but the calling code must work with the returned Generator. A fiber can suspend from a deeply nested function and pauses the whole stack belonging to that fiber. The functions above the suspension point do not need special return types.
<?php
declare(strict_types=1);
function loadProfile(): string
{
return requestProfile();
}
function requestProfile(): string
{
$response = Fiber::suspend('waiting for profile');
return 'profile: ' . $response;
}
$fiber = new Fiber(function (): string {
return loadProfile();
});
echo $fiber->start() . PHP_EOL;
$fiber->resume('Ada');
echo $fiber->getReturn() . PHP_EOL;
// Prints:
// waiting for profile
// profile: Ada
requestProfile() suspends even though loadProfile() called it. When the fiber resumes, execution continues inside requestProfile(), then returns normally through loadProfile(). This full-stack behavior is the main reason fibers are useful to library authors.
Values Cross The Suspension Boundary
The caller and fiber can exchange values in both directions:
start()runs the fiber until its first suspension or termination.- The argument passed to
Fiber::suspend()becomes the return value ofstart()orresume()in the caller. - The argument passed to
resume()becomes the return value ofFiber::suspend()inside the fiber. - After termination,
getReturn()reads the fiber callback's final return value.
<?php
declare(strict_types=1);
$fiber = new Fiber(function (string $jobId): string {
echo 'Starting ' . $jobId . PHP_EOL;
$instruction = Fiber::suspend(['job' => $jobId, 'progress' => 50]);
return $jobId . ' finished with ' . $instruction;
});
$status = $fiber->start('import-42');
echo json_encode($status, JSON_THROW_ON_ERROR) . PHP_EOL;
$fiber->resume('continue');
echo $fiber->getReturn() . PHP_EOL;
// Prints:
// Starting import-42
// {"job":"import-42","progress":50}
// import-42 finished with continue
Arguments for the fiber callback belong on start(). Values used to continue a suspended fiber belong on resume(). Mixing those two roles is a common source of confusing examples.
Lifecycle States Matter
A Fiber object moves through a small lifecycle. PHP exposes methods for checking it:
isStarted()tells you whetherstart()has been called.isRunning()is true while that fiber currently has control.isSuspended()is true when it has paused atFiber::suspend().isTerminated()is true after its callback returns or exits with an uncaught exception.
The legal operation depends on the state. Start an unstarted fiber. Resume or throw into a suspended fiber. Read its return value only after successful termination. A terminated fiber cannot be restarted.
<?php
declare(strict_types=1);
$fiber = new Fiber(function (): int {
Fiber::suspend('checkpoint');
return 200;
});
echo $fiber->isStarted() ? 'started' : 'new';
echo PHP_EOL;
$fiber->start();
echo $fiber->isSuspended() ? 'suspended' : 'not suspended';
echo PHP_EOL;
$fiber->resume();
echo $fiber->isTerminated() ? 'terminated' : 'active';
echo PHP_EOL;
echo $fiber->getReturn() . PHP_EOL;
// Prints:
// new
// suspended
// terminated
// 200
Schedulers should make state transitions explicit instead of calling resume() optimistically. Invalid transitions produce a FiberError, which usually indicates a programming error in the coordinator rather than an operational failure in the task.
Cooperative Concurrency, Not Parallelism
Fibers are cooperative. A running fiber keeps control until it suspends, returns, or throws. PHP does not interrupt it after a time slice. A CPU-heavy loop that never suspends can therefore prevent every other fiber in the same process from progressing.
<?php
declare(strict_types=1);
function makeTask(string $name): Fiber
{
return new Fiber(function () use ($name): void {
echo $name . ' step 1' . PHP_EOL;
Fiber::suspend();
echo $name . ' step 2' . PHP_EOL;
});
}
$tasks = [
makeTask('A'),
makeTask('B'),
];
foreach ($tasks as $task) {
$task->start();
}
foreach ($tasks as $task) {
if ($task->isSuspended()) {
$task->resume();
}
}
// Prints:
// A step 1
// B step 1
// A step 2
// B step 2
This is interleaving, not simultaneous execution. It can still improve throughput for I/O-bound applications because one task can make progress while another is waiting for a socket, timer, process, or database driver that the runtime knows how to observe. For CPU-bound work, use suitable process or parallel-execution facilities and measure the overhead.
A Fiber Does Not Fix Blocking I/O
Putting file_get_contents() or a traditional blocking database query inside a fiber does not automatically free the process to run another task. If the underlying operation blocks the PHP process, the event loop cannot regain control merely because the code lives inside a fiber.
Async runtimes pair fibers with non-blocking resources and an event loop. A library begins an operation, registers interest in its completion, suspends the current fiber, and returns control to the loop. The loop resumes that fiber only after the resource is ready.
This distinction prevents a serious architecture mistake: wrapping synchronous calls in fibers and expecting lower latency. The capability must exist through the entire path, including the client library, PHP extension, operating-system resource, and scheduler integration.
Throwing Into A Suspended Fiber
Fiber::throw() resumes a suspended fiber by throwing an exception from the corresponding Fiber::suspend() call. Runtimes can use this mechanism to communicate cancellation, timeout, or upstream failure.
<?php
declare(strict_types=1);
final class OperationCancelled extends RuntimeException
{
}
$fiber = new Fiber(function (): string {
try {
Fiber::suspend('waiting');
return 'completed';
} catch (OperationCancelled) {
return 'cancelled cleanly';
} finally {
echo 'Released task resources' . PHP_EOL;
}
});
echo $fiber->start() . PHP_EOL;
$fiber->throw(new OperationCancelled('Request ended'));
echo $fiber->getReturn() . PHP_EOL;
// Prints:
// waiting
// Released task resources
// cancelled cleanly
Cancellation is a protocol, not a built-in universal policy. The runtime must decide when cancellation is requested, which exception represents it, whether child operations are cancelled, how cleanup runs, and whether the caller receives a result or an exception. finally remains important for releasing task-owned resources.
Exceptions Cross Back To The Caller
An exception that escapes the fiber callback is thrown from the call to start(), resume(), or throw() that caused execution to reach the failure.
<?php
declare(strict_types=1);
$fiber = new Fiber(function (): void {
Fiber::suspend();
throw new RuntimeException('Import failed.');
});
$fiber->start();
try {
$fiber->resume();
} catch (RuntimeException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
echo $fiber->isTerminated() ? 'terminated' : 'active';
echo PHP_EOL;
// Prints:
// Import failed.
// terminated
Do not expect getReturn() to recover a return value after an uncaught exception. The exceptional completion has already been delivered to the coordinator. Production runtimes need a deliberate policy for logging failures, preserving useful stack traces, associating errors with tasks, and preventing one failed task from disappearing silently.
Knowing The Current Fiber
Fiber::getCurrent() returns the currently running fiber or null when execution is outside one. Low-level libraries may use it to verify that an operation is running in a context where suspension is possible.
Application code should not scatter checks for the current fiber throughout business logic. That couples ordinary services to one runtime model. Prefer an async abstraction whose implementation owns the suspension behavior, while domain code works with a documented API.
Fibers Compared With Generators
Fibers and generators both suspend execution, but they solve different problems.
A generator primarily produces a sequence of values. Its caller explicitly advances it, and any function containing yield returns a Generator. This makes generators ideal for lazy iteration and streaming transformations.
A fiber represents an interruptible execution context with its own stack. Suspension may happen several calls below the fiber callback without changing every intermediate signature. This makes fibers suitable for hiding runtime coordination behind ordinary-looking function calls.
Do not replace a clear generator with a fiber merely because fibers are newer. If the job is "produce the next item," a generator usually communicates that contract better.
Design Responsibilities Beyond Fibers
A useful asynchronous runtime needs much more than start() and resume():
- an event loop that watches resources and timers
- fair scheduling so one ready task does not starve others
- timeout and cancellation propagation
- task ownership and structured cleanup
- limits for concurrency and backpressure
- error collection, logging, and observability
- adapters for non-blocking network, filesystem, process, and database operations
- deterministic testing utilities
These responsibilities are why a tiny round-robin scheduler is a teaching tool, not production infrastructure. Mature libraries have already addressed edge cases that are easy to miss during a local demonstration.
Testing Fiber-Based Code
Tests should focus on observable behavior and explicit scheduling boundaries. Avoid tests that depend on real timing such as "sleep for 100 milliseconds and hope the task has resumed." A controllable clock, fake event source, or runtime test driver makes timeout and cancellation cases deterministic.
At a lower level, test each transition: the first suspension value, the value sent back on resume, successful termination, propagated failure, and cancellation cleanup. Also test what happens when a dependency blocks despite being used from an async context. The happy path alone does not establish that concurrent code is reliable.
When To Use Fibers
Use raw fibers when you are implementing or studying infrastructure that genuinely needs interruptible stacks. In an application, prefer the abstractions supplied by a maintained async library or framework. Follow that runtime's rules about supported clients, cancellation, and process lifecycle.
Normal synchronous PHP remains the right choice for many request-response applications. It is easier to deploy, reason about, profile, and debug. Fibers become valuable when a long-running process must coordinate many I/O-bound operations and the surrounding stack is designed for non-blocking execution.
What You Should Be Able To Do
After this lesson, you should be able to trace values through start(), Fiber::suspend(), resume(), and getReturn(); inspect lifecycle states before changing them; explain how throw() communicates failure into suspended code; and distinguish cooperative concurrency from parallel execution.
You should also be able to reject two incorrect claims: a fiber does not automatically make blocking I/O asynchronous, and a collection of fibers is not a complete runtime. The difficult engineering work is the scheduling, resource integration, cancellation, limits, diagnostics, and testing built around them.
Practice
Practice: Pause And Resume A Fiber
Create a small PHP example that starts, suspends, resumes, and reads the return value of a fiber.
Task
Build a fiber that:
- prints a message before suspending
- suspends with a value
- receives a value when resumed
- returns a final string
Use strict types. Keep the expected output in the PHP code block as printed lines or comments.
Check Your Work
Confirm:
start()runs the fiber until suspensionsuspend()returns a value to the callerresume()sends a value back into the fibergetReturn()reads the final return value
Afterward, explain why raw fibers are usually a library-level feature.
Show solution
This solution shows the basic control flow between the caller and the fiber.
<?php
declare(strict_types=1);
$fiber = new Fiber(function (): string {
echo 'Inside fiber before suspend' . PHP_EOL;
$value = Fiber::suspend('paused');
echo 'Inside fiber after resume with ' . $value . PHP_EOL;
return 'finished';
});
$pauseValue = $fiber->start();
echo 'Caller received ' . $pauseValue . PHP_EOL;
$fiber->resume('resume value');
echo $fiber->getReturn() . PHP_EOL;
// Prints:
// Inside fiber before suspend
// Caller received paused
// Inside fiber after resume with resume value
// finished
Raw fibers are usually a library-level feature because useful async code also needs scheduling, I/O integration, cancellation, timeout handling, and error propagation.
Practice: Build A Lifecycle-Aware Runner
Create a small runner that advances several fibers without attempting invalid state transitions.
Task
Build two fibers that each:
- accept a task name through
start() - suspend twice, returning a progress message each time
- return a final completion message
Write coordinator code that starts unstarted fibers, resumes only suspended fibers, and reads return values only from terminated fibers. Keep advancing the tasks until both terminate.
Use isStarted(), isSuspended(), and isTerminated() in the coordinator. Print the suspension and return values so the order is visible.
Check Your Work
Confirm that:
- callback arguments are passed through
start(), notresume() - neither fiber is resumed after termination
getReturn()is called only after successful termination- output demonstrates interleaving rather than parallel execution
Explain what would happen if one task performed a long blocking operation before reaching its next suspension.
Show solution
The coordinator checks each fiber's state before deciding which operation is legal.
<?php
declare(strict_types=1);
function createTask(): Fiber
{
return new Fiber(function (string $name): string {
Fiber::suspend($name . ': 25%');
Fiber::suspend($name . ': 75%');
return $name . ': complete';
});
}
$tasks = [
'images' => createTask(),
'reports' => createTask(),
];
while ($tasks !== []) {
foreach ($tasks as $name => $fiber) {
if (!$fiber->isStarted()) {
echo $fiber->start($name) . PHP_EOL;
continue;
}
if ($fiber->isSuspended()) {
$value = $fiber->resume();
if (!$fiber->isTerminated()) {
echo $value . PHP_EOL;
}
}
if ($fiber->isTerminated()) {
echo $fiber->getReturn() . PHP_EOL;
unset($tasks[$name]);
}
}
}
// Prints:
// images: 25%
// reports: 25%
// images: 75%
// reports: 75%
// images: complete
// reports: complete
The fibers take turns only because they reach explicit suspension points. A long blocking call before a suspension would hold the process and delay the other task as well.
Practice: Cancel A Suspended Task
Model cancellation by throwing a dedicated exception into a suspended fiber.
Task
Create:
- an
OperationCancelledexception - a fiber that opens a temporary in-memory stream
- a
try,catch, andfinallystructure inside the fiber - a suspension representing an outstanding operation
- coordinator code that calls
throw()withOperationCancelled
The fiber should return a cancellation result when it catches the exception. Its finally block must close the stream and print a cleanup message. Read the return value after termination.
Check Your Work
Confirm that:
- cancellation is delivered from the
Fiber::suspend()expression - cleanup runs whether the task completes or is cancelled
- the exception does not escape because the fiber handles it
- the fiber is terminated before
getReturn()is called
Then remove the catch block temporarily and identify which coordinator call receives the uncaught exception.
Show solution
throw() continues the fiber by making its suspension point throw the supplied exception.
<?php
declare(strict_types=1);
final class OperationCancelled extends RuntimeException
{
}
$fiber = new Fiber(function (): string {
$stream = fopen('php://memory', 'w+');
if ($stream === false) {
throw new RuntimeException('Could not open the stream.');
}
try {
fwrite($stream, 'partial result');
Fiber::suspend('waiting for permission to continue');
return 'completed';
} catch (OperationCancelled $exception) {
return 'cancelled: ' . $exception->getMessage();
} finally {
fclose($stream);
echo 'stream closed' . PHP_EOL;
}
});
echo $fiber->start() . PHP_EOL;
$fiber->throw(new OperationCancelled('request disconnected'));
if ($fiber->isTerminated()) {
echo $fiber->getReturn() . PHP_EOL;
}
// Prints:
// waiting for permission to continue
// stream closed
// cancelled: request disconnected
Without the catch, the OperationCancelled exception would escape from the coordinator's call to $fiber->throw(...). The finally block would still run while the stack unwinds.