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.
Architecture fit. This is not really a "should we adopt this" decision in a Laravel codebase — it is almost certainly already installed. guzzlehttp/guzzle depends on guzzlehttp/promises, and Laravel's own Illuminate\Http\Client is a thin wrapper over Guzzle. The real decision is narrower and more consequential: do we let GuzzleHttp\Promise\PromiseInterface into our application code, or do we keep it quarantined behind the HTTP client? That is an architectural boundary question, and it should be answered once, centrally, not per feature team.
Fit is strongest where the app is I/O-bound on third-party APIs and the alternative is a chain of sequential Http::get() calls. Fit is weakest where a team assumes "async" means parallelism — PHP here is cooperative, single-threaded, and nothing runs until something calls wait() or drains the task queue. Guzzle's cURL multi-handler provides the actual overlap; this library only provides the composition vocabulary around it. Any design that implies background execution after the response is sent is a misread.
Integration feasibility. Very high, and 3.1.0 does not change that. The release is a minor, non-breaking rename-plus-deprecation: it adds a State class for promise-state checks and deprecates Is, because PHP 8.6 deprecates is as a class name. No signature changes to then/wait/cancel/getState, no changes to Utils::*, no new requirements, no service provider, no config, no migration. composer require guzzlehttp/promises:^3.1 remains effectively a no-op where the transitive constraint already resolves.
What actually changed in 3.1.0 — and why it still matters:
State::fulfilled() and Is::fulfilled() are the same predicate over the same getState() value. The long-documented trap (a promise fulfilled with an inner promise reports fulfilled while wait() can still throw) is unaffected by the rename. Teams that adopt State believing it is the "safe" API will reproduce the exact same defect. Our lint rule must target the pattern, not the class name.phpunit helper calling Is::pending()/fulfilled()/rejected()/settled()/cancelled() will now emit deprecation notices under PHP 8.6 tooling. That is low-severity but it is dated work: removal is expected at the next major, so the migration window is "this cycle," not "eventually."Technical risk. Still concentrated in semantics, not in the install:
wait() is a blocking call. In FPM it burns a worker for the duration of the slowest call in the group. In Octane/Swoole it can stall other requests on the same worker. Without the cURL multi-handler actually multiplexing, Utils::all() is at best "one round trip's worth" and at worst a slower sequential loop with extra objects.getState()/State::* describes how a promise was settled, not the eventual outcome. Branching on either is a defect pattern we should lint against in review.cancel() notifies handlers; it does not abort the in-flight cURL transfer or the downstream vendor's work. Teams will assume it saves cost/latency; it does not.Is usage rather than filtering it.Key questions for the team:
wait() of N seconds?PromiseInterface in domain/service signatures, or wrap it (e.g. a ParallelRunner contract) so the library stays swappable?all (fail-fast) or settle/some (degrade gracefully)?GuzzleHttp\Promise\Is::* call sites exist across the monorepo, and do we migrate them to State::* inside this cycle's cleanup rather than deferring to the removal release?Stack fit. The natural seam is the service/adaptor layer that talks to third parties, not controllers and not Eloquent models. Laravel 10+ exposes Http::async(), which returns a PromiseInterface directly, so the idiomatic Laravel form is to collect pending requests in an adaptor, hand the array to a single application-owned runner, and resolve once at the boundary — keeping the promise type out of the rest of the codebase.
| Layer | Recommendation |
|---|---|
| Controllers / Livewire | Never. Collect inputs, call one service method, return a response. |
| Service / adaptor | Yes — the only place PromiseInterface appears. |
| Queued jobs | Only if the job itself orchestrates the fan-out synchronously inside handle(). Never pass a promise in. |
| Shared packages | Wrap it. Expose our own contract so the vendor type isn't in public signatures. |
Migration path. Incremental and low-ceremony. The 2.x→3.x track is unchanged; 3.1.0 adds one small step.
composer why guzzlehttp/promises and composer show guzzlehttp/promises across the monorepo. Confirm which constraint is already resolved and whether anything is pinned to 1.x (EOL) or 2.x.^3.1 (or ^2.5 if PHP < 7.4 anywhere) requirement to composer.json so we are not silently riding someone else's transitive constraint. Lockfile review is the control point.grep -r "Promise\\\\Is\|Promise\\\\Is::" across app code, tests, and shared packages; replace with State::*. This is a mechanical, low-risk change and should ride along with step 2's lockfile bump rather than become its own initiative.Compatibility. 3.0 requires PHP >= 7.4, < 8.7 and drops 7.2/7.3; it also marks previously non-final classes soft-final, makes static helpers non-instantiable, requires iterable inputs to collection helpers, and changes Utils::inspect() to return actual rejection reasons. 3.1.0 is additive and deprecation-only — no breaking change for 3.x consumers, but Is is now on a removal track, and PHP 8.6 deprecation reporting will flag it. If we are on 2.x anywhere, the 2.5 → 3.x jump still needs a code scan for: non-iterable inputs to all/some/any/Each::of, any reliance on Utils::inspect() returning the internal reason array, and any subclassing of the promise classes. Octane deserves a dedicated smoke test — long-lived workers change the blast radius of a leaked pending handler.
Sequencing.
| Phase | Scope | Exit criteria |
|---|---|---|
| 0 — Audit | Dependency graph, PHP version matrix, candidate call sites, Is:: usage count |
Explicit version decision recorded |
| 1 — Pilot | One adaptor, one endpoint, feature-flagged | p95 improvement measured against the sequential baseline |
| 2 — Harden | Timeouts, concurrency cap, partial-failure policy, cancellation semantics documented | Failure-mode tests green |
| 3 — Standardize | Shared runner contract, lint rule against getState()/State::* branching, docs, Is→State sweep complete |
Two or more teams using the same pattern; zero Is:: references |
| 4 — Steady state | Dependency policy, upgrade runbook, PHP 8.6 upgrade checklist | Ownership assigned |
Maintenance. MIT, small stable surface, active release cadence (2.x maintenance branch alongside 3.x), PHP 8.6 already supported and now actively de-risked. 3.1.0 is a good datapoint: the maintainers shipped a forward-looking deprecation ahead of the PHP release that forces it, rather than letting consumers discover it at upgrade time. Risk remains maintainer concentration rather than abandonment — a tiny number of people steward a package that sits underneath essentially every PHP HTTP integration we own. Mitigation stays boring and effective: pin constraints, let Dependabot/Renovate drive patch uptake, keep a lockfile-diff review step so a transitive bump never lands unreviewed, and add the Is→State rename to the standing deprecation-cleanup backlog with a due cycle. A semver-major (future 4.0, where Is presumably goes away) is the event that should trigger a scheduled work item.
Support. Community support via GitHub issues; commercial support and coordinated security disclosure via the Tidelift subscription ([email protected]). If any of our fan-out code paths sit in a payment, eligibility, or compliance flow, that subscription is cheap insurance and gives us a named escalation path — worth folding into the vendor review rather than treating as a nice-to-have.
Scaling. Concurrency here scales latency, not CPU: it reduces wall-clock time per request while holding worker occupancy roughly constant or worse. Levers that matter:
concurrency config to Utils::all() and Each::of() — use it. Unbounded fan-out converts a latency win into a vendor 429 storm at peak traffic.wait().Failure modes to design for explicitly:
| Failure | Symptom | Control |
|---|---|---|
Blocking wait() on a dead vendor |
Worker pool exhaustion, p99 collapse | Hard group timeout; circuit breaker at the adaptor |
| Partial vendor failure | Utils::all() rejects everything on one failure |
Use settle/some where degradation is acceptable; define which |
| Assumed cancellation | Cost/latency not reduced; vendor work continues | Document that cancel() is cooperative; rely on timeouts instead |
getState() / State::* branching |
Silent logic errors on promise-wrapped promises | Code-review lint rule targeting the predicate, not the class name; prefer wait() + try/catch |
Deprecated Is::* usage left in place |
Noisy CI, then hard failure at next major | Mechanical Is→State sweep in this cycle |
| Unhandled rejection | RejectionException/AggregateException surfacing as a 500 |
Map to domain exceptions at the adaptor boundary |
| Promise crossing a queue | Serialization rejected at runtime | Contract rule: jobs receive scalars/DTOs only |
| Handler leak under Octane | Growing worker RSS | Drain the queue; soak test |
Ramp-up. The API takes an afternoon; the semantics take a sprint. Budget for: one reference implementation, one short internal write-up covering the state/wait() caveat and the cancellation limitation, and a review checklist. Add one line to that write-up making clear that State is a rename, not a correction — otherwise the rename actively spreads the defect pattern under a name that sounds safer. Do not assume a mid-level engineer who has used Http::get() will infer that then() callbacks are lazy and that nothing executes until wait() — that misunderstanding produces code that looks asynchronous and behaves sequentially, which is the most expensive outcome here: added complexity, zero latency gain.
TPM recommendation. Unchanged in substance, plus one cheap addendum. Fund this as a small, time-boxed latency optimization on a named set of fan-out endpoints, with a measured baseline as the entry gate and a p95 delta as the exit gate — not as a platform-wide "adopt async" initiative. Keep the promise type behind an application-owned runner contract, cap concurrency centrally, and treat the Tidelift subscription as a decision to make once, now, while the scope is still small. Separately, attach the Is→State sweep to the next dependency-bump PR as a zero-cost tack-on; do not let it justify a project of its own, and do not let it be the reason anyone touches the async code paths prematurely.
How can I help you explore Laravel packages today?