Promises and Deferred Objects in PHP: Managing Async Values
AI generated
<?php
8.4
PHP · Promises · Deferred · Design Patterns
Promises and Deferred Objects in PHP
Managing asynchronous values cleanly

Promises encapsulate the result of an asynchronous operation before that result even exists, while a Deferred object provides control over resolution and rejection from the outside. Anyone who understands the three states of a promise, the rules of chaining and the common combinators can structure complex asynchronous workflows in PHP, whether with ReactPHP, Amp or a custom minimal implementation.

17 min read Promise · Deferred · then · Combinators PHP 8.4 · react/promise

1. What a promise really is

A promise is an object representing a value that does not exist yet but will become available at some point in the future, or never, because the underlying operation fails. Unlike an ordinary variable, a promise contains no value at the moment of its creation, only the guarantee that a value or an error will be known at a later point. This simple idea solves a fundamental problem of asynchronous programming: how do you hand over the result of an operation that has not finished yet?

Before promises became widespread, this problem was solved almost exclusively with callback functions: an asynchronous function accepts another function as an argument and calls it once the result is available. This approach works well for individual operations but becomes unwieldy once several asynchronous steps must be coordinated sequentially or in parallel. The promise pattern solves this coordination problem by turning asynchronous results into objects that can be chained, combined and passed around like any other value.

In PHP, this pattern is mainly implemented by the react/promise library, closely modeled on the Promises/A+ specification from the JavaScript world. A promise in PHP behaves conceptually identical to its JavaScript counterpart: it encapsulates a future value, offers the then method to react to success or failure, and can be coordinated with other promises via combinators such as all or race.

2. The three states: pending, fulfilled, rejected

Every promise is in exactly one of three states at any given time. The initial state is always pending: the result is not available yet, neither a success value nor an error. Once the underlying asynchronous operation completes successfully, the promise transitions to the fulfilled state and from then on carries a concrete value. If the operation fails instead, the promise transitions to the rejected state and from then on carries a failure reason, usually an exception.

The decisive point about these three states: a promise can change its state exactly once, from pending to either fulfilled or rejected, never back and never a second time. This immutability after the first state change is not a technical detail but the foundation that allows several places in the code to react independently to the same promise without having to worry about race conditions between multiple state changes.

This guarantee fundamentally distinguishes a promise from a simple callback registry: calling then on a promise that is already fulfilled still guarantees that the passed callback runs, asynchronously in the next tick, with the already known value. This consistency between a still pending and an already settled promise makes the pattern robust against timing issues that frequently cause subtle bugs with pure callback approaches.

3. Deferred objects: controlling resolution from the outside

A Deferred object solves a practical problem when creating promises: a promise itself deliberately has no public methods to change its own state, because otherwise any arbitrary code with access to a promise could manipulate its result. The Deferred object separates this responsibility: it owns the resolve and reject methods allowed to change the state, while simultaneously exposing the associated, read-only promise object via promise().

This separation allows a clear security model: the code that actually performs an asynchronous operation keeps the Deferred object and eventually calls resolve or reject on it. Calling code that merely wants to wait for the result only ever gets the promise object back and can never force or falsify its result itself. This encapsulation prevents an entire class of bugs where several places in the code accidentally compete for control over the same asynchronous value.

In practice, a Deferred object is almost always created at the innermost point of an asynchronous function, right before the actual asynchronous operation is started, for example a network request or a timer. Once the operation completes, usually inside a callback of the underlying asynchronous API, resolve or reject is called on the Deferred object, and only the associated promise is returned to the outside.


<?php

declare(strict_types=1);

use React\Promise\Deferred;
use React\Promise\PromiseInterface;

function delayedValue(int $milliseconds, mixed $value): PromiseInterface
{
    // The Deferred owns resolve/reject, callers only ever see the Promise
    $deferred = new Deferred();

    $timerId = null;
    $timerId = \React\EventLoop\Loop::addTimer($milliseconds / 1000, function () use ($deferred, $value): void {
        $deferred->resolve($value);
    });

    // Only the read-only promise is exposed to the caller
    return $deferred->promise();
}

delayedValue(500, 'Hello after 500ms')->then(function (string $result): void {
    echo $result . "\n";
});

echo "This line runs before the delayed value resolves\n";

4. Chaining with then: rules that can surprise you

The then method is the central building block for working with promises. It accepts up to two callbacks, one for the success case and one for failure, and itself returns a new promise. This exact return of a new promise is the foundation for chaining: $promise->then($step1)->then($step2)->then($step3) runs three steps in sequence, with each step receiving the previous one's return value as input.

A rule that frequently surprises: if a then callback itself returns another promise instead of a plain value, the chain automatically waits for it to resolve before running the next step. This automatic flattening of nested promises, sometimes called promise flattening, prevents accidentally ending up with a promise of a promise of a value instead of the value itself directly.

A second rule concerns error handling: if a then callback throws an exception, the returned promise automatically transitions to the rejected state, with the thrown exception as the reason. This allows bundling error handling in a single place at the end of the chain, via a second then parameter or via the otherwise method, instead of guarding every single step separately, quite similar to how a try catch block guards several synchronous statements together.


<?php

declare(strict_types=1);

use function React\Promise\resolve;

resolve(['id' => 42])
    ->then(function (array $user): array {
        // Returning a value passes it to the next then() step
        $user['name'] = 'Loaded user ' . $user['id'];
        return $user;
    })
    ->then(function (array $user): array {
        if ($user['id'] < 0) {
            // Throwing here rejects the chain, skipping remaining then() steps
            throw new \InvalidArgumentException('Invalid user id');
        }
        $user['validated'] = true;
        return $user;
    })
    ->then(function (array $user): void {
        echo "Final result: {$user['name']}\n";
    })
    ->otherwise(function (\Throwable $error): void {
        // Single place to handle any error from the whole chain
        error_log('Chain failed: ' . $error->getMessage());
    });

5. Combinators: all, race, any and some in detail

Once several promises need to be coordinated at the same time, combinator functions come into play. React\Promise\all takes an array of promises and returns a new promise that gets fulfilled once all passed promises are fulfilled, with an array of all results in the original order. If even one of the passed promises fails, the returned promise is immediately rejected with the same error, regardless of the state of the others.

React\Promise\race behaves differently: it resolves as soon as the very first of the passed promises reaches a state, whether fulfilled or rejected, and adopts exactly its value or error. This behavior is excellent for timeout patterns: pitting a promise for the actual operation against a promise for a timer, so an operation that takes too long is effectively cancelled as soon as the timer fires first.

React\Promise\any resembles race but ignores rejections and only resolves with the first actually fulfilled promise, which fits redundant, parallel requests to several equivalent servers. React\Promise\some goes one step further and waits for a configurable minimum number of fulfilled promises before resolving itself, useful for scenarios where a quorum of responses is enough without having to wait for every single request.

6. Building your own minimal promise

To truly understand the promise pattern, it pays off to write a minimal custom implementation, even though production code should rely on mature libraries. The core of a promise consists of an internal state, a list of registered callbacks and the logic that notifies these callbacks on state change. This custom implementation makes visible why certain behaviors, such as automatically resolving already settled promises, are necessary at all.

The most important design aspect of such a custom implementation: callbacks registered via then must be handled correctly both in the pending and in the already settled case. If the promise is still pending, the callback gets stored in an internal list and only invoked at the later state change. If the promise is already fulfilled or rejected, the callback must be invoked immediately, but typically asynchronously in the next tick, with the already known result.

This exercise also shows why real promise libraries like react/promise take so much care with exception handling during callback execution: if a registered callback itself throws an exception, it must be caught and turned into a rejection of the resulting chained promise, instead of crashing the entire calling code stack.


<?php

declare(strict_types=1);

// Minimal educational Promise implementation, not for production use
final class SimplePromise
{
    private const STATE_PENDING = 'pending';
    private const STATE_FULFILLED = 'fulfilled';
    private const STATE_REJECTED = 'rejected';

    private string $state = self::STATE_PENDING;
    private mixed $value = null;
    /** @var array<int, array{onFulfilled: ?callable, onRejected: ?callable}> */
    private array $callbacks = [];

    public function resolve(mixed $value): void
    {
        if ($this->state !== self::STATE_PENDING) {
            return; // A Promise can only settle once
        }

        $this->state = self::STATE_FULFILLED;
        $this->value = $value;
        $this->notify();
    }

    public function reject(\Throwable $reason): void
    {
        if ($this->state !== self::STATE_PENDING) {
            return;
        }

        $this->state = self::STATE_REJECTED;
        $this->value = $reason;
        $this->notify();
    }

    public function then(?callable $onFulfilled = null, ?callable $onRejected = null): void
    {
        $this->callbacks[] = ['onFulfilled' => $onFulfilled, 'onRejected' => $onRejected];

        // Already settled: notify this new callback immediately
        if ($this->state !== self::STATE_PENDING) {
            $this->notify();
        }
    }

    private function notify(): void
    {
        foreach ($this->callbacks as $callback) {
            if ($this->state === self::STATE_FULFILLED && $callback['onFulfilled'] !== null) {
                ($callback['onFulfilled'])($this->value);
            } elseif ($this->state === self::STATE_REJECTED && $callback['onRejected'] !== null) {
                ($callback['onRejected'])($this->value);
            }
        }

        $this->callbacks = [];
    }
}

7. Common mistakes: swallowed rejections and forgotten catch

The most common mistake when working with promises is a rejection that never gets handled. If a promise is rejected but nobody calls then with an error callback or otherwise on it, the failure reason is silently lost without being logged anywhere. This so-called unhandled rejection is one of the most common causes of hard-to-find bugs in promise based code, because the error never shows up in the log.

A second common mistake concerns the chaining itself: if a new promise is created inside a then callback but not returned, the outer chain does not wait for it to complete. The code appears to run correctly, but side effects of the inner asynchronous operation, for example a database write, may not have finished yet by the time the next link in the chain runs. The rule is therefore: every promise created inside a then callback must be returned so the chain correctly waits for it.

A third pitfall: mixing promises from different libraries, for example a react/promise object with an Amp\Future, does not work for chaining and combinators without explicit adapters, because both implementations, while conceptually similar, are technically different classes. For consistent codebases it is recommended to settle on a single promise system per project instead of using several in parallel.

8. Why Amp v3 uses Future instead of Promise

The Amp library used classic promises in version 2, following the same pattern as react/promise, but switched entirely to Future objects in version 3, built on top of PHP Fibers. The reason lies in a fundamental simplification: a Future is not consumed via then callbacks but via the await method, which pauses the calling code inside a Fiber until the result is available and then directly returns the value, as described in the previous article on the async/await pattern.

Conceptually the idea remains identical to the promise pattern: an object representing a not-yet-available value, with the same three states pending, fulfilled and rejected. The difference lies purely in the consumption syntax: then chains versus direct await. For developers coming from JavaScript, where both then chains and async/await exist on the same promise object, this split into two separate libraries might initially seem surprising, but it is a deliberate design decision by Amp to avoid confusion between the two consumption styles.

For new PHP projects that need asynchronous I/O, this means: react/promise with classic promises remains relevant for projects built on the ReactPHP ecosystem, while Amp users work with Future and await. Both approaches solve the same fundamental problem but differ in the preferred syntax for consuming results.

9. Promise patterns compared directly

Different tools in the promise ecosystem suit different coordination problems. Choosing the right combinator has a direct impact on the correctness and readability of the resulting code.

Requirement Wrong approach Correct tool Reason
Wait for all results Nested then() Promise\all() Flat, readable structure
Timeout against an operation Manually setting a flag Promise\race() First result wins automatically
Query redundant servers Manually filter first response Promise\any() Automatically ignores failures
Handle errors in one place try/catch in every then() otherwise() at the end One central error path
Set a value from outside Mutating the promise directly Deferred Clear separation of read and write

The table shows that a fitting combinator already exists for practically every coordination problem. Anyone who knows these tools rarely has to write their own synchronization logic by hand, which considerably reduces the error proneness of promise based code compared to homemade solutions with manual counters or flags.

Mironsoft

Asynchronous PHP patterns and promise based architectures

Asynchronous workflows that coordinate cleanly?

We structure existing callback code with promises and Deferred objects, choose the right combinators for your coordination problems, and advise on the choice between react/promise and Amp Future.

Code refactoring

Resolving callback nesting into clean promise chains

Combinator selection

Applying all, race, any and some deliberately to your coordination problems

Architecture consulting

Choosing between react/promise, Amp Future and custom implementations

10. Summary

A promise encapsulates an asynchronous value through three clearly defined states: pending, fulfilled and rejected, with the transition from pending to one of the two final states happening exactly once and never being reversed. Deferred objects separate control over this state transition from the read-only promise passed to calling code, thereby preventing several places in the code from accidentally competing for the same resolution.

Chaining with then, combined with combinators such as all, race, any and some, covers practically every coordination problem between multiple promises without having to write custom synchronization logic. Anyone who rebuilds a minimal custom implementation once understands the underlying rules more deeply and avoids common mistakes such as swallowed rejections or forgotten return values in chains, regardless of whether react/promise or Amp futures end up being used.

Promises and Deferred Objects in PHP — The Essentials at a Glance

Three states

Pending, fulfilled, rejected. The transition happens exactly once and is immutable afterward.

Deferred

Separates resolve/reject from the read-only promise passed to callers.

Chaining

then automatically flattens nested promises, exceptions move into rejected.

Combinators

all, race, any and some coordinate multiple promises without manual counters.

11. FAQ: Promises and Deferred Objects in PHP

1What is a promise in PHP?
An object for a not-yet-available value that becomes available later or is rejected. Mainly implemented via react/promise.
2Which states exist?
Pending as the starting state, then fulfilled or rejected. The transition happens exactly once and is final.
3What is a Deferred object for?
Separates resolve/reject from the read-only promise passed to callers, preventing manipulation.
4What if then() returns a promise?
The chain automatically waits for it to resolve, instead of returning a promise of a promise.
5Difference between all and race?
all waits for all and returns all results. race resolves with the first finished promise, successful or not.
6What is any good for?
Ignores rejections, resolves with the first fulfilled promise. Suited for redundant requests to multiple servers.
7What is an unhandled rejection?
A rejection with no registered error callback. The error is silently lost, without logging.
8Why Future instead of Promise in Amp v3?
Future is consumed via await, built on Fibers. The idea stays identical, only the syntax differs.
9Can react/promise and Amp Future be mixed?
Not without adapters, since both are different classes. One system per project is recommended.
10Custom implementation in production?
No, use mature libraries for production. A custom build only serves deeper understanding of the mechanics.