amphp/amp
amphp/amp provides futures and cancellations as core primitives for asynchronous PHP. Built on PHP 8.1 fibers and Revolt, it lets you write concurrent code that reads like synchronous code, using Amp\async() and Future::await() without callbacks.
Install
composer require amphp/amp revolt/event-loop
revolt/event-loop is pulled in transitively, but require it explicitly — you will reference Revolt\EventLoop directly for timers, signals, and EventLoop::run() in long-lived scripts.
Requirements check first: PHP 8.1+ (fibers), no extensions needed. If you're on 7.x or pre-fiber Laravel, this package is not for you — the entire API assumes fibers.
Where to look first, in this order:
Amp\async() — spawns a coroutine on a new fiber. This is your entry point for "run this concurrently."Amp\Future — await(), map(), catch(), finally(). The return-value-shaped promise.Amp\Future\await() / awaitAll() / awaitAny() / awaitFirst() / awaitAnyN() — the fan-out/fan-in combinators. This is where the actual day-to-day value lives.Amp\DeferredFuture — only when you must bridge a callback API into a Future.Amp\Cancellation / DeferredCancellation — wire this into anything that can hang.Amp\delay() — non-blocking sleep; useful for testing and backoff.First real use case: parallel HTTP inside an Artisan command. Sequential HTTP calls are the canonical thing Amp fixes. A report command that hits 20 endpoints at 300 ms each takes 6 s sequentially; with Future\await() it takes ~300 ms.
use Amp\Future;
use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;
$client = HttpClientBuilder::buildDefault();
$uris = [
'users' => 'https://api.example.com/users',
'orders' => 'https://api.example.com/orders',
'invoices'=> 'https://api.example.com/invoices',
];
$responses = Future\await(array_map(
fn (string $uri) => Amp\async(fn () => $client->request(new Request($uri))),
$uris,
));
foreach ($responses as $key => $response) {
$this->info($key . ': ' . $response->getStatus());
}
That is the whole mental model: Amp\async() starts work, Future\await() collects it, exceptions come back as thrown exceptions from await().
Fan-out / fan-in is the default shape. Wrap each independent I/O call in Amp\async(), collect the futures, then await the batch at the boundary of your method. Keep the futures as futures inside; only await() where you need the value. This keeps the concurrency local and the call sites readable.
$futures = [];
foreach ($ids as $id) {
$futures[$id] = Amp\async(fn () => $this->fetchUser($id));
}
$users = Future\await($futures); // keys preserved
Choose the combinator by failure semantics, not convenience:
| Situation | Combinator |
|---|---|
| All must succeed, fail fast | Future\await() |
| All must run, tolerate partial failure | Future\awaitAll() → [$errors, $values] |
| Need N successes out of M (quorum, redundant mirrors) | Future\awaitAnyN($n, $iterable) |
| First result wins, errors are fine | Future\awaitFirst() |
| First success wins, ignore errors | Future\awaitAny() |
awaitAll() is the one most production code should reach for around third-party calls, because one flaky upstream shouldn't blow away nineteen good responses.
Always pass a cancellation. Any operation that can hang should be cancellable; without it you have a coroutine leaking until the loop dies.
use Amp\Cancellation;
use Amp\TimeoutCancellation;
$cancellation = new TimeoutCancellation(5.0);
$results = Future\awaitAll($futures, $cancellation);
Combine with Amp\CompositeCancellation or DeferredCancellation when a user request aborts and you want to tear down all in-flight downstream work.
Bridge blocking/callback libraries with DeferredFuture, then get out of the deferred object immediately — it is internal state, not a handle.
function fetchAsync(string $url): Future
{
$deferred = new DeferredFuture;
// e.g. a client that only offers callbacks
$this->legacyClient->getAsync($url, function (?Throwable $e, $body) use ($deferred) {
$e ? $deferred->error($e) : $deferred->complete($body);
});
return $deferred->getFuture();
}
If the callback API has no async variant at all, amphp/parallel is the right escape hatch — push the blocking call to a worker process instead of stalling the loop.
Chain with map/catch/finally instead of awaiting twice. Use these when you want to transform results without suspending the current fiber, e.g. in a service that returns Future to callers.
return $this->fetchAsync($url)
->map(fn (string $body) => json_decode($body, true, flags: JSON_THROW_ON_ERROR))
->catch(fn (Throwable $e) => $this->fallback($e))
->finally(fn () => $this->metrics->increment('fetch'));
Laravel integration shape. The cleanest pattern is a small service layer that exposes Future-returning methods, and callers that await() once at the top:
Future\await() directly; the script ends when the loop drains.Don't await inside work you spawned. A pattern that reliably wedges things:
// Bad: this coroutine awaits a future that may itself depend on this one finishing
$outer = Amp\async(function () use (&$inner) {
return $inner->await();
});
$inner = Amp\async(fn () => $outer->await());
Futures here are not deadlock-checked. Structure concurrent work as a DAG with await() only at leaves.
Wrap the whole batch in try/catch. await() (and await()) surfaces exceptions as normal throws — no special error channel — so existing exception handlers, report(), and Laravel's handler all work unmodified.
Any blocking call collapses your concurrency to zero. file_get_contents, curl_exec, sleep, PDO, Redis via phpredis, most of Laravel's DB layer — these block the entire process, not just one fiber. There is exactly one fiber running at a time; blocking I/O means no other fiber runs either. Reach for amphp/http-client, amphp/mysql, amphp/redis, amphp/byte-stream, or offload to amphp/parallel.
PHP's CPU-bound work does not parallelize. Fibers are cooperative. A tight loop in one async() still blocks everything else. Use amphp/parallel for anything CPU-heavy.
Await the future or explicitly ignore() it. A future that errors with nobody awaiting it produces an unhandled-error notice at GC time in Amp v3. Either try { $f->await(); }, attach ->catch(), or call ->ignore() to document deliberate fire-and-forget. This is the single most common source of confusing test-suite noise.
Never complete a DeferredFuture with another Future. $deferred->complete($otherFuture) stores the future object as the value. Await it first, or use ->map().
Complete a DeferredFuture exactly once. Double-completion throws. If you're completing from a callback, guard against the library calling back twice — some legacy clients do.
Cancellation must be passed, not assumed. Future\await() accepts a Cancellation argument, but Amp\async() does not propagate one into the closure automatically. If you need cancellation to reach the work, thread it through the function signature: fn (Cancellation $c) => $client->request($req, cancellation: $c).
Awaiting a completed future is free and repeatable. Future::complete() and Future::error() are perfect for cache hits and for satisfying interfaces that demand a Future return type even when data is local. No need to fake asynchrony.
Event loop lifecycle surprises in scripts. A CLI script ends when there are no more event-loop watchers and no suspended fibers — but if you start a coroutine and never await nor keep a reference, the loop may exit before it runs. Keep the future in a variable through the end of the script.
File descriptor limits bite before CPU does. Thousands of concurrent sockets will hit ulimit -n (commonly 1024). Raise it or use the revolt/event-loop-ext-* extensions (ev, uv, event) for high-concurrency scenarios.
Debugging stack traces: because fibers have independent call stacks, an exception's trace shows the fiber's stack, not the spawning one. Log the point where you called Amp\async() yourself if you need to trace origin. Amp\async() does not attach the parent context.
Amp\delay() in tests is real time. There's no virtual clock out of the box; abstract timeouts behind an interface if you want to test timeout paths without sleeping.
Extension points worth knowing: implement Amp\Cancellation for custom abort signals (user-disconnect, supervisor shutdown); build your own combinators on top of DeferredFuture + the Future\await* family rather than reinventing waiting logic; and use Revolt\EventLoop::repeat() for periodic work instead of a loop with delay() inside.
Config quirks: there is no config file and no service provider to register. Amp is a library, not a framework integration. If you want a single place to tune concurrency limits (pool sizes, max in-flight requests, default timeouts), build it yourself as a wrapper service — Amp deliberately has no global knobs.
How can I help you explore Laravel packages today?