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.
Table of Contents
- 1. What a promise really is
- 2. The three states: pending, fulfilled, rejected
- 3. Deferred objects: controlling resolution from the outside
- 4. Chaining with then: rules that can surprise you
- 5. Combinators: all, race, any and some in detail
- 6. Building your own minimal promise
- 7. Common mistakes: swallowed rejections and forgotten catch
- 8. Why Amp v3 uses Future instead of Promise
- 9. Promise patterns compared directly
- 10. Summary
- 11. FAQ
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.