The Async/Await Pattern in PHP: Combining Fibers and Amp
AI generated
<?php
8.4
PHP · Fibers · Amp · Async/Await
The Async/Await Pattern in PHP
Combining Fibers and Amp

The async/await pattern allows writing asynchronous PHP code so it looks like ordinary, sequential code even though it does not block under the hood. Amp builds this pattern on top of PHP Fibers and replaces nested callback chains with simple async and await calls, without sacrificing the benefits of true concurrency.

17 min read async() · await() · Future · Fibers PHP 8.4 · amphp/amp v3

1. Why async/await is needed in PHP at all

The async/await pattern solves a readability problem that pure callback or promise based asynchronous programming almost inevitably brings: once several asynchronous steps must run one after another, callbacks nest deeply or promise chains become cluttered with many then calls. The async/await pattern solves this by making asynchronous code look like ordinary, sequential code while the underlying execution remains non-blocking.

In PHP, this async/await pattern is mainly implemented by the Amp library, built on top of Fibers available since PHP 8.1. Fibers themselves only provide the raw tool for suspending and resuming execution contexts, without defining any high level syntax for asynchronous programming. Amp fills this gap with the async() and await() functions, which together form an async/await pattern closely resembling comparable concepts in JavaScript or C#, but built entirely on native PHP tools.

The decisive advantage of the async/await pattern over raw Fibers or pure promises: the code reads linearly from top to bottom, error handling works with ordinary try catch instead of separate error callbacks, and the cognitive load on developers drops considerably. Where a promise chain needs five nested then calls, the async/await pattern needs only five consecutive await() calls in a single linear function.

2. Fibers as the foundation: suspend and resume under the hood

A PHP Fiber is an independent execution context with its own stack that can be paused at any time via Fiber::suspend() and later resumed via $fiber->resume(). The async/await pattern, as implemented by Amp, uses exactly this mechanism: every piece of code started with async() runs inside its own Fiber. Once the code inside hits an await(), the Fiber pauses, control returns to the Amp scheduler, and other Fibers can keep running in the meantime.

The key difference between raw Fibers and Amp's async/await pattern lies in the abstraction: anyone working directly with Fibers must write their own scheduler that manages paused Fibers and resumes them at the right time. Amp takes over this scheduler logic entirely and instead offers the simple async() and await() functions, which internally wrap Fiber suspend and resume calls without application code ever touching the Fiber class directly.

This encapsulation is why the async/await pattern in Amp is so much easier to use than raw Fibers. Developers write code that syntactically looks like synchronous PHP, while under the hood a full cooperative scheduler with event loop integration handles network I/O, timers and file access in a non-blocking way.


<?php

declare(strict_types=1);

use function Amp\async;
use function Amp\delay;

// async() starts this closure in its own Fiber and returns immediately
$future = async(function (): string {
    // delay() suspends the Fiber without blocking the whole process
    delay(1.0);
    return 'Result after 1 second';
});

echo "This line runs immediately, before the delay finishes\n";

// await() blocks only the current Fiber, not the whole process
$result = $future->await();
echo $result . "\n";

3. async() and Future: starting an operation in the background

The async() function is the entry point for the async/await pattern in Amp. It takes a closure, immediately starts it in a new Fiber, and returns a Future object right away without waiting for the result. This corresponds conceptually exactly to the async keyword in JavaScript or C#, except PHP uses a function for it instead of language syntax, because PHP itself has no native async/await keyword.

The Future object returned by async() represents the still pending result. It is conceptually comparable to a promise, but differs in how you wait for the result: instead of registering then() callbacks, you call $future->await() directly, which pauses the calling Fiber until the result is available. This direct call instead of a callback registration is the core of what distinguishes the async/await pattern from purely promise based code.

An important detail: async() starts execution immediately, not only on the first await(). This differs from some other languages where an async function only executes when needed. In Amp, code inside async() keeps running in parallel to the calling code until it itself hits an await() or delay(), which for the async/await pattern means that several async() calls right after each other actually start running concurrently.

4. await() and the scheduler: when waiting actually happens

The await() method on a Future object is the second half of the async/await pattern. It pauses the current Fiber until the associated Future is either fulfilled or rejected with an exception. Important to understand: await() does not block the entire PHP process, only the current Fiber. The underlying Amp event loop can keep running other Fibers in the meantime, exactly as the ReactPHP event loop keeps processing other timers and streams during a wait.

If you call await() on a Future that has already completed, the function returns immediately without actually pausing the Fiber. The async/await pattern in Amp thus automatically optimizes the case where the result is already available, avoiding unnecessary scheduler passes. This optimization is invisible to the developer but noticeably affects performance with very many short asynchronous operations.

A common misunderstanding with the async/await pattern: calling await() in a context that itself does not run inside a Fiber, for example directly in the global script scope outside of async(), causes an error or actually blocks the entire process, depending on the Amp version and configuration. The safe practice is to call await() only inside functions that were themselves started via async(), or at the top level where Amp provides the main Fiber context.


<?php

declare(strict_types=1);

use function Amp\async;
use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;

function fetchUrl(string $url): string
{
    $client = HttpClientBuilder::buildDefault();
    $response = $client->request(new Request($url));

    // await() suspends only this Fiber while the HTTP response streams in
    return $response->getBody()->buffer();
}

// Both requests start concurrently because async() runs them in separate Fibers
$future1 = async(fn (): string => fetchUrl('https://example.com/api/users'));
$future2 = async(fn (): string => fetchUrl('https://example.com/api/orders'));

// await() here blocks only until each specific Future resolves
$users = $future1->await();
$orders = $future2->await();

echo "Users response length: " . strlen($users) . "\n";
echo "Orders response length: " . strlen($orders) . "\n";

5. Composing multiple asynchronous operations

Once several Future objects must be managed at the same time, Amp offers helper functions that extend the async/await pattern with composition. Amp\Future\await(), not to be confused with the instance method, takes an array of futures and waits until all are fulfilled, similar to Promise.all in JavaScript. If one of the futures fails, the function throws the first exception that occurred, while the remaining futures keep running in the background.

Amp\Future\awaitFirst(), on the other hand, resolves as soon as the first future in the passed list finishes, regardless of whether it succeeded or failed, which fits race patterns: the fastest of several redundant requests wins. Amp\Future\awaitAny() is similar but ignores failed futures and returns the first successful result, provided at least one completes successfully.

These composition functions are why the async/await pattern in Amp remains suitable for complex, parallel workflows too, not just simple, sequential chains. A typical example: starting several independent database or API queries in parallel, collecting all results with await() on the combined future array, and only then continuing processing, without summing up the wait times of the individual queries.

6. Error handling: exceptions across Fiber boundaries

One of the biggest practical advantages of the async/await pattern over pure callback chains lies in error handling. If a closure inside async() throws an exception, it is not thrown immediately but stored in the Future object and only rethrown when await() is called in the calling context. This allows wrapping perfectly normal try catch blocks around await() calls, exactly as with synchronous code.

This property of the async/await pattern considerably simplifies error handling compared to promise based code, where errors must be handled via separate catch callbacks or the second parameter of then(), often at a different location in the code than the actual failing call. With await(), error handling sits directly next to the code that could cause the error, which makes debugging and code review noticeably easier.

Important to note: if the Future of a failed async() operation is never collected via await(), the exception is lost without being logged anywhere, a so-called unhandled rejection similar to JavaScript promises. In the async/await pattern in Amp, it is therefore important to eventually collect every started future via await(), even if its result is not directly needed, purely to avoid silently losing errors.

7. Implementing cancellation and timeouts cleanly

Long running asynchronous operations need a way to be aborted early, for example when a user cancels a request or a timeout is reached. Amp implements this in the async/await pattern via Cancellation objects, passed to functions that can themselves react to cancellation. A TimeoutCancellation automatically aborts an operation after a defined time span, a DeferredCancellation allows manual cancellation from application code, for example after a user click.

The decisive difference from simple timeout handling with timers: a Cancellation token in the async/await pattern gets passed through the entire call chain, so nested asynchronous operations can all be cancelled together, not just the outermost one. An HTTP request that internally performs several substeps can thus be stopped cleanly and completely on timeout, instead of only ending the outer call while inner operations keep running in the background.

Without consistent cancellation support, every application using the async/await pattern would risk wasting resources on operations that are no longer needed, for example when a client disconnects but server side started futures keep running undeterred and occupy database connections or external API quotas even though nobody is waiting for the result anymore.


<?php

declare(strict_types=1);

use function Amp\async;
use function Amp\delay;
use Amp\TimeoutCancellation;
use Amp\CancelledException;

function slowOperation(\Amp\Cancellation $cancellation): string
{
    // delay() respects cancellation and throws if the timeout fires
    delay(5.0, cancellation: $cancellation);
    return 'Completed successfully';
}

$cancellation = new TimeoutCancellation(2.0);

try {
    $result = async(fn (): string => slowOperation($cancellation))->await();
    echo $result . "\n";
} catch (CancelledException) {
    echo "Operation timed out after 2 seconds\n";
}

8. Migrating from nested callbacks to async/await

Existing codebases built on ReactPHP promises or manually nested callbacks can be migrated step by step to the async/await pattern without having to rewrite the entire application at once. Amp provides adapter functions that convert a ReactPHP promise into an Amp future and vice versa, so both systems can coexist in parallel while individual modules are converted one at a time.

The pragmatic migration path usually starts at the leaves of the call tree: individual, isolated functions that currently take callbacks are converted to async() and await() first, while the surrounding code stays unchanged and simply calls the new function like a synchronous one. Only once enough leaf functions are migrated does it pay off to rework the orchestration layer above into the full async/await pattern.

One benefit of this gradual migration: tests for individually migrated functions also become considerably simpler at the same time, because asynchronous code that looks like synchronous code can be tested almost identically, without complicated callback mocking constructions. This testability is often the real trigger for teams to prefer the async/await pattern over pure promise chains, independent of raw performance characteristics.

9. Async/await compared to raw Fibers and promises

The async/await pattern is one of several possible abstraction layers over PHP Fibers. Which layer is appropriate depends on the required level of control and the desired readability of the code.

Approach Readability Error handling Control
Raw Fibers Low, manual suspend/resume Manual, no standard Maximum, full access
Promises (react/promise) Medium, then chains Separate catch callbacks Good, but nested
Async/await (Amp) High, linear code Normal try/catch Good, with cancellation
Generators with yield Medium, own syntax Possible, but unfamiliar Good, but less common

For new projects that need asynchronous I/O operations, the async/await pattern with Amp is the right choice in most cases: it offers the readability of synchronous code with the full power of non-blocking execution. Raw Fibers remain relevant for library authors who want to build their own abstractions, while simple promise chains are often already sufficient for smaller projects with few asynchronous steps.

Mironsoft

Asynchronous PHP architectures with Amp and Fibers

Asynchronous code that stays as readable as synchronous code?

We migrate nested callback structures to the async/await pattern with Amp, including clean error handling, cancellation and timeout handling for production ready asynchronous services.

Code migration

Gradual conversion of callbacks and promises to async()/await()

Errors & cancellation

Try/catch based error handling and robust timeout handling

Architecture consulting

Choosing between Fibers, Amp and ReactPHP depending on the use case

10. Summary

The async/await pattern in PHP, implemented by Amp on top of Fibers, solves the readability problem of classic callback and promise chains without sacrificing the benefits of non-blocking execution. async() starts code in its own Fiber, await() pauses only the calling context instead of the whole process, and error handling works with ordinary try catch instead of separate error callbacks.

Composition functions such as await() over arrays of futures, awaitFirst() and awaitAny() extend the async/await pattern with parallel workflows, while cancellation tokens enable clean, cascading cancellation of long running operations. For new asynchronous PHP projects, this pattern is usually the right choice today, because it combines the understandability of synchronous code with the full power of asynchronous I/O.

The Async/Await Pattern with Fibers and Amp — The Essentials at a Glance

Basic principle

async() starts a Fiber immediately, await() pauses only the calling context until the result arrives.

Error handling

Exceptions are stored in the future and rethrown at await(). Normal try/catch works fine.

Composition

Future\await(), awaitFirst() and awaitAny() coordinate multiple parallel operations.

Cancellation

TimeoutCancellation and DeferredCancellation pass abort signals through nested calls.

11. FAQ: The Async/Await Pattern in PHP with Fibers and Amp

1What is the async/await pattern?
Makes asynchronous code look like synchronous, sequential code. Implemented in PHP by Amp with async() and await().
2Relation to Fibers?
Fibers provide the raw suspend mechanism. Amp builds on top of it and fully encapsulates the scheduler.
3Does await() block the process?
No, only the current Fiber. Other Fibers keep running in the event loop in the meantime.
4When does async() start?
Immediately, not only at await(). The code keeps running in parallel until it pauses itself.
5How does error handling work?
Exceptions land in the future and are rethrown at await(); normal try/catch works fine.
6What if a future is never collected?
Contained errors get lost without logging. Every future should eventually be collected via await().
7Waiting on multiple futures at once?
Future\await() waits for all, awaitFirst() for the first finished, awaitAny() for the first successful one.
8How to implement a timeout?
With a TimeoutCancellation passed to cancellation-aware functions. It throws CancelledException once the time expires.
9Combinable with ReactPHP?
Yes, Amp provides adapter functions between ReactPHP promises and Amp futures for gradual migration.
10Faster than raw promises?
Comparable speed, both use the same event loop. The advantage lies in readability and testability.