guzzlehttp/promises
Guzzle Promises is a small PHP promise library that powers Guzzle's asynchronous requests. It provides promise chaining, synchronous waiting, cancellation, and helpers for coordinating groups of promises. Install it directly when you need promise composition without the full HTTP client.
If you're on Laravel, this package is almost certainly already installed as a transitive dependency of guzzlehttp/guzzle. Check first:
composer show guzzlehttp/promises
Only composer require guzzlehttp/promises directly if you're composing promises without the HTTP client — e.g. wrapping queue dispatches, cache warmers, or any callable you want to fan out. As of 3.1.0 you want ^3.1 if you're requiring it explicitly (see Version drift matters below).
Where to look first (in order):
docs/promise-quick-start.md and docs/promise-api.md — short and accurate; the README alone undersells the Utils surface.GuzzleHttp\Promise\Utils — the static helper class. This is your actual daily tool: all(), some(), any(), settle(), each(), eachLimit(), queue(), inspect(), task().GuzzleHttp\Promise\PromiseInterface — four methods (then, otherwise, wait, getState, plus cancel). Everything else is sugar over these.GuzzleHttp\Promise\State — static predicates (pending(), fulfilled(), rejected(), settled()) that mirror PromiseInterface::getState(). New in 3.1.0 and the class to use; its predecessor Is is deprecated (see Gotchas).First use case — the one that pays for itself immediately. A service that today makes three sequential third-party calls at ~200ms each:
use GuzzleHttp\Promise\Utils;
$promises = [
'pricing' => $client->requestAsync('GET', 'https://pricing.test/quote'),
'eligibility'=> $client->requestAsync('GET', 'https://risk.test/check'),
'profile' => $client->requestAsync('GET', 'https://crm.test/contact'),
];
$responses = Utils::all($promises)->wait();
That's ~200ms of wall time instead of ~600ms, with no new infrastructure. Return PromiseInterface from your service methods and call wait() only at the edge (controller, queued job, artisan command). Never let a promise leak into a Blade view or a JSON resource.
Fan-out with partial failure tolerance. Utils::all() rejects as soon as any input rejects, discarding the successes. For enrichment-style calls where a missing profile shouldn't kill the response, use settle():
$results = Utils::settle($promises)->wait();
$ok = collect($results)
->filter(fn ($r) => $r['state'] === 'fulfilled')
->map(fn ($r) => $r['value'])
->all();
$failed = collect($results)
->filter(fn ($r) => $r['state'] === 'rejected')
->map(fn ($r) => $r['reason'])
->all();
State strings are fine for the settle() array shape, but when you have a PromiseInterface in hand prefer State::fulfilled($promise) / State::rejected($promise) over string comparisons against getState() — it's greppable, typo-proof, and keeps the string literals in one place if the internals ever change.
Bounded concurrency for large fan-outs. Never build a 500-promise array and wait() it — Guzzle will open 500 sockets. Use a limit:
Utils::eachLimit(
$userIds,
10,
fn ($user) => $client->requestAsync('POST', $url, ['json' => ['id' => $user]]),
fn ($reason, $idx) => Log::warning('enrich failed', ['idx' => $idx, 'reason' => $reason])
)->wait();
In 3.x the preferred spelling is Each::ofLimit($iterable, 10, $onFulfilled, $onRejected); Utils::eachLimit() remains as the stable alias. Utils::all() also accepts a concurrency config in 3.0.
Chaining transforms, not side effects. then() flattens nested promises, so an async-then-async chain stays flat:
$client->requestAsync('GET', $url)
->then(fn ($res) => json_decode($res->getBody(), true))
->then(fn ($data) => $client->requestAsync('POST', $url2, ['json' => $data]))
->then(fn ($res) => Cache::put('key', (string) $res->getBody(), 300))
->wait();
Throw inside a handler and the derived promise rejects — that's the idiomatic way to validate mid-chain. Use otherwise() for recovery, and remember it is the rejection handler, not a try/finally.
Make synchronous seams async-agnostic. Any collaborator that might be sync today and async tomorrow should accept/return PromiseInterface internally and be wrapped at the boundary with Create::promiseFor($value) / Create::rejectionFor($reason). This lets you swap a cached (sync) lookup for an HTTP (async) one without touching callers.
Where this fits in a Laravel app:
handle(), wait(), then persist. Keeps a single job's runtime at ~1 round trip instead of N.wait() — an Octane worker is occupied. Prefer the job path for anything above ~5 parallel calls.Illuminate\Http\Client: Http::async() returns a Laravel promise wrapping the Guzzle one, with then() and wait(). If you're already using the HTTP facade, stay there — you get Http::fake() support. Drop to raw Guzzle promises when you need settle(), eachLimit(), or non-HTTP promise composition.State::pending($promise) check is handy in an assertion or a rescue() wrapper to distinguish "rejected" from "never started", but don't build dispatch logic on it — see the state caveat in Gotchas.Custom task queue for determinism. Utils::queue() is a global TaskQueue; handlers are queued, not invoked inline. In tests, install a fresh queue so handlers from a previous test don't bleed in:
Utils::queue(new TaskQueue());
Is is deprecated as of 3.1.0 — use State. GuzzleHttp\Promise\Is (Is::pending(), Is::fulfilled(), Is::rejected(), Is::settled()) is being retired because PHP 8.6 deprecates is as a class name. GuzzleHttp\Promise\State is the drop-in replacement with the same predicates. Sweep for it now:
grep -rn "Promise\\\\Is\b\|Promise\\\\Is::" app/ tests/
The swap is mechanical, but watch for the places a grep misses: use GuzzleHttp\Promise\Is; aliased to something else, Is::class strings in container bindings or test doubles, and docblocks/@param annotations that static analysis (PHPStan, Psalm) will flag once the class emits a deprecation. Expect a deprecation notice from 3.1.0 itself, so a failOnDeprecation test run will surface it for you — that's the cheapest way to find every call site.
Nothing runs until you run the queue or call wait(). then() only registers callbacks. In plain PHP (no event loop) there is no background progress: $promise->resolve('x') followed by an assertion on your handler will fail unless you call Utils::queue()->run() or wait(). The "async" in this package is really concurrency during curl_multi execution, not parallelism.
getState() describes how the promise was settled, not the eventual outcome. A promise resolved with another promise — or a then() handler returning one — reports fulfilled while the inner promise may still be pending, and wait() can still throw. Don't branch on state for anything that ever touched a nested promise; call wait() or poll Utils::inspect() (which returns the real rejection reason as of 3.0). This caveat applies equally to State::fulfilled() and the old Is::fulfilled() — the new class name is a rename, not a semantic fix.
wait() throws. If the reason is a Throwable it's rethrown directly; otherwise you get GuzzleHttp\Promise\RejectionException (getReason() for the value). Utils::all() throws AggregateException — use getReason() for the first and getReasons() for all. Catch narrowly in jobs so you can distinguish a downstream 500 from a bug in your handler.
Errors thrown in a rejection handler reject the derived promise, they don't surface at the call site. If your otherwise() callback has a bug, you'll see a confusing rejection two links later. Keep rejection handlers trivial.
There are no aggregate timeouts. Utils::all(...)->wait() has no timeout parameter. Set per-request timeouts (['timeout' => 2, 'connect_timeout' => 1]) and use cancel() on the aggregate when a deadline is breached. Cancellation is best-effort and propagates to the underlying transfer — it will not un-write a promise that already settled.
Promises don't serialize. 3.0 explicitly rejects native serialization of in-flight runtime objects. Never put a pending promise on a queued job payload, in a session, or in a cache — resolve it first, then serialize the plain value.
Long-lived workers (Octane, Horizon). A global TaskQueue and any promise you stash in a singleton survive between requests. Always wait() to completion before returning from a request, and never store a promise as a resolved dependency. On Horizon, a leaked pending promise means a leaked file handle and memory growth.
Debugging a "never resolves" aggregate. Walk it down: check the input count, verify every element is a promise or raw value (3.0 requires iterables for collection helpers and will iterate IteratorAggregate rather than treat it as a value — a change from 2.x that silently breaks Utils::all($collection)), and confirm you aren't iterating a generator twice. Utils::inspect($promise) gives you state, value, and reason without throwing.
Version drift matters. 3.1.0 adds State and deprecates Is for PHP 8.6 compatibility — pin ^3.1 on greenfield code so new call sites use State from day one, and treat the Is removal as a future major. 3.0 changed Utils::inspect() to return actual rejection reasons, made helper classes non-instantiable, deprecated non-iterable inputs to collection helpers (deprecated in 2.5, required in 3.0), and fixed EachPromise settling bugs. If you pin ^2.5, EachPromise can abandon its aggregate when the pending window drains unsettled — a real production hazard for eachLimit workloads — and you're on the version whose Is class is on the chopping block for PHP 8.6. Prefer ^3.1 (PHP >=7.4,<8.7). A minor bump here is behavioural no-op but namespace-relevant: re-run PHPStan/Psalm and your deprecation-strict test suite after upgrading.
Extension points if you need them: implement PromiseInterface for foreign promises (Guzzle's wait() handles them), supply a custom TaskQueueInterface to Utils::queue(), or build on Coroutine for generator-based sequential flows that read like sync code. All promise classes are soft-final — extend with care and re-run your full test suite on minor bumps (3.1.0 is a reminder: even a two-line changelog can invalidate a class name you've referenced).
How can I help you explore Laravel packages today?