hiblaphp / async
async/await implementation with structured concurrency.
Requires
- php: ^8.2
- hiblaphp/cancellation: dev-main
- hiblaphp/event-loop: dev-main
- hiblaphp/promise: dev-main
Requires (Dev)
- laravel/pint: ^1.25
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.0
This package is auto-updated.
Last update: 2026-03-19 17:24:08 UTC
README
Context-independent async/await for PHP without function coloring.
hiblaphp/async brings async and await to PHP as plain functions built
on top of PHP 8.1 Fibers and the Hibla event loop. Unlike JavaScript, Python,
or C#, await() works in both fiber and non-fiber contexts — you write normal
functions and lift them into concurrency at the call site, not inside the
function definition.
Contents
Fundamentals
Core Usage
- async() — Running Code Concurrently
- await() — Suspending Until a Promise Settles
- No Function Coloring in Practice
Features
- asyncFn() — Wrapping a Callable
- sleep() — Async-Aware Pause
- inFiber() — Context Detection
- Cancellation inside async()
- Combining with Promise Combinators
Reference
Meta
Installation
composer require hiblaphp/async
Requirements:
- PHP 8.3+
hiblaphp/event-loophiblaphp/promisehiblaphp/cancellation
Introduction
PHP has always been synchronous. When your code calls an HTTP endpoint, reads a file, or queries a database, it blocks and waits. One operation at a time, in sequence, from top to bottom. For short-lived scripts and simple request handlers this is fine. But the moment you need to fetch multiple things at once, handle WebSocket connections, or run background jobs without spinning up new processes, the model falls apart.
The standard solution in most languages is async/await — a way to mark
functions as asynchronous and pause them at I/O boundaries while other work
proceeds. But every major language that has implemented this — JavaScript,
Python, C# — has introduced what is known as function coloring: async
and await are syntax keywords that live inside the function definition. The
moment a function uses await, it must be marked async, which changes its
return type, which forces every caller to also be async. The color spreads
upward through the entire call stack, creating two incompatible worlds —
sync code and async code — that cannot be mixed freely.
hiblaphp/async solves this differently. async() and await() are plain
PHP functions, not keywords. await() is context-independent — it checks
whether it is running inside a Fiber at runtime and behaves accordingly.
Inside a Fiber it suspends cooperatively. Outside a Fiber it falls back to
blocking synchronously. A function that calls await() has no special
marking, no changed return type, and no impact on its callers. The caller
decides whether to give it concurrency by wrapping it in async() at the
call site. The color lives at the call site, not inside the function.
This library is the top of the Hibla async stack. It sits on
hiblaphp/event-loop for fiber scheduling, hiblaphp/promise for the
promise model, and hiblaphp/cancellation for external cancellation
coordination. Together these four libraries give you a complete async
programming model for PHP that reads like synchronous code but runs
cooperatively under the hood.
The Function Coloring Problem
In JavaScript, Python, and C#, async and await are keywords that live
inside the function definition. The moment a function uses await, it must
be marked async, which changes its return type, which forces every caller
to also be async. The color spreads upward through the entire call stack.
// JavaScript — color spreads upward through every layer
async function getUser(id) { // must be async
return await fetchUser(id); // uses await
}
async function buildPage(userId) { // must be async because getUser is async
const user = await getUser(userId);
return user;
}
async function handleRequest(req) { // must be async because buildPage is async
const page = await buildPage(req.userId);
return page;
}
Hibla solves this entirely. await() is just a regular PHP function that
checks its execution context at runtime. A function that uses await() has
no special marking, no changed return type, and no impact on its callers.
The caller decides whether to give it concurrency by wrapping it in async()
at the call site:
use function Hibla\async;
use function Hibla\await;
// A plain function — no special marking, no color
function getUser(int $id): User
{
return await(fetchUser($id));
}
// Works synchronously at the top level — no async() needed
$user = getUser(1);
// Works concurrently when wrapped in async() — no changes to getUser()
$promise = async(fn() => getUser(1));
The color lives at the call site, not inside the function. This means you
can write your entire application using normal functions with await() and
introduce concurrency selectively where you need it.
Fibers and Coroutines
PHP Fibers were introduced in PHP 8.1 as a first-class stackful coroutine primitive. A stackful coroutine is a unit of execution that can be suspended and resumed at any point in its call stack — including inside deeply nested function calls. This is what separates Fibers from generators.
A generator can only suspend at the top-level yield inside the generator
function itself. A Fiber can suspend from anywhere in its call stack. When a
Fiber suspends, the entire call stack at that point — every function frame,
every local variable, every instruction pointer — is frozen and preserved.
When the Fiber is resumed, execution continues from exactly where it left off
as if nothing happened.
A Fiber also has its own separate C-level stack, independent from the main thread stack, which is what makes suspension at any depth possible.
async() creates a Fiber and schedules it on the event loop. When the Fiber
calls await() on a pending promise, it calls Fiber::suspend() internally,
freezing the entire call stack and returning control to the event loop. When
the promise resolves, Loop::scheduleFiber() queues the Fiber to be resumed,
and the event loop restores the full call stack and continues execution from
the suspension point.
function fetchUserProfile(int $id): PromiseInterface
{
return async(function () use ($id) {
$user = await(Http::get("/users/$id"));
$avatar = await(Http::get("/avatars/$id"));
return ['user' => $user, 'avatar' => $avatar];
});
}
async(function () {
// Multiple pages load concurrently because each async() call
// runs in its own fiber and suspends independently at each await()
[$page1, $page2, $page3] = await(Promise::all([
fetchUserProfile(1),
fetchUserProfile(2),
fetchUserProfile(3),
]));
});
async() — Running Code Concurrently
async() wraps a callable in a PHP Fiber, schedules it on the event loop,
and returns a Promise that resolves with the callable's return value. The
callable does not run immediately — it is queued in the Fiber phase of the
next event loop iteration.
use function Hibla\async;
$promise = async(function () {
return 'hello from a fiber';
});
$promise->then(fn($value) => print($value)); // hello from a fiber
Multiple async() calls run concurrently. Each one gets its own Fiber and
yields to others at every await() point:
$start = microtime(true);
async(function () {
await(delay(1));
echo "Task 1 done\n";
});
async(function () {
await(delay(1));
echo "Task 2 done\n";
});
async(function () {
await(delay(1));
echo "Task 3 done\n";
});
// All three run concurrently — total time ~1 second, not 3
Loop::run();
echo microtime(true) - $start; // ~1.0
One fiber runs at a time — never block inside async()
The event loop runs only one Fiber at a time. Fibers are cooperatively
scheduled — a Fiber runs until it explicitly suspends via await() or
sleep(), at which point the event loop picks up the next ready Fiber.
A blocking call inside a Fiber — PHP's native sleep(), a synchronous
database query, file_get_contents(), or any other call that blocks the OS
thread — stalls the entire event loop for its duration. No other Fiber
runs, no timers fire, no I/O is processed until the blocking call returns.
// Wrong — blocks the entire loop for 2 seconds
async(function () {
\sleep(2); // PHP's native sleep — stalls everything
echo "done\n";
});
// Correct — suspends this Fiber cooperatively, loop stays free
async(function () {
sleep(2); // Hibla's sleep — use function Hibla\sleep
echo "done\n";
});
Always use the async-aware equivalents from the Hibla ecosystem —
Http::get() instead of file_get_contents(), await(delay($n)) instead
of \sleep($n), stream watchers via hiblaphp/stream instead of blocking
fread(). If you need to run genuinely blocking work or CPU-bound tasks,
offload them to a separate process via hiblaphp/parallel rather than
running them inside a Fiber.
Exceptions inside async()
Any exception thrown inside an async() block rejects the returned promise.
Always attach a catch() handler or await() the promise inside a
try/catch when you care about errors:
$promise = async(function () {
throw new \RuntimeException('Something went wrong');
});
$promise->catch(fn($e) => print($e->getMessage())); // Something went wrong
async(function () {
try {
$result = await(riskyOperation());
return $result;
} catch (\Throwable $e) {
logError($e);
return null;
}
});
Avoid unnecessary wrapping
Each async() call creates a new PHP Fiber. Fibers are lightweight but not
free — each one allocates a C-level stack and associated runtime state.
Creating a Fiber just to immediately await a single promise that already
exists adds overhead with no benefit.
If a function already returns a promise, await() it directly:
// Wrong — allocates a full Fiber just to await one existing promise
$result = await(async(fn() => await(Http::get('/api/data'))));
// Correct — await the promise directly, no Fiber needed
$result = await(Http::get('/api/data'));
The same applies to plain functions that use await() internally — they
already work in both sync and async contexts without wrapping:
function getUserName(int $id): string
{
$user = await(fetchUser($id));
return $user->name;
}
// Wrong — getUserName() already works in both contexts
$name = await(async(fn() => getUserName(1)));
// Correct — call it directly
$name = getUserName(1);
// Only wrap in async() when you specifically want concurrent execution
$promise = async(fn() => getUserName(1)); // justified — explicit concurrency
Use async() when you genuinely need a Fiber — when you need to await
multiple promises sequentially with logic in between, or when you want a
block of code to run concurrently as its own unit of work:
// Good use — multiple awaits with logic between them
$promise = async(function () {
$user = await(fetchUser(1));
$orders = await(fetchOrders($user->id));
$ratings = await(fetchRatings($user->id));
return processData($user, $orders, $ratings);
});
await() — Suspending Until a Promise Settles
await() suspends the current Fiber until the given promise settles, then
returns the resolved value or throws the rejection reason.
use function Hibla\await;
$user = await(fetchUser(1));
echo $user->name;
Context-independent behavior
await() checks Fiber::getCurrent() at runtime and behaves accordingly:
- Inside a Fiber (
async()block): suspends the Fiber cooperatively. The event loop continues running — other fibers, timers, and I/O all proceed while this Fiber waits. - Outside a Fiber (top level or sync function): falls back to
Promise::wait()and drives the event loop synchronously until the promise settles.
// Outside a Fiber — blocks synchronously
$user = await(fetchUser(1));
// Inside a Fiber — suspends cooperatively
async(function () {
$user = await(fetchUser(1)); // other work runs while waiting
echo $user->name;
});
This context-independence is what eliminates function coloring. A function
that calls await() works correctly regardless of where it is called from —
it does not need to know or care whether it is inside a Fiber.
Already-settled promises
If the promise passed to await() is already fulfilled at the time of the
call, await() returns the value immediately without suspending — whether
inside or outside a Fiber. If it is already rejected, it throws immediately.
If it is already cancelled, it throws CancelledException immediately.
$promise = Promise::resolved('immediate');
// Both contexts return immediately — no suspension, no event loop tick
$value = await($promise); // outside Fiber
async(fn() => $value = await($promise)); // inside Fiber — same behavior
Rejection and cancellation
If the awaited promise rejects, await() throws the rejection reason:
async(function () {
try {
$user = await(fetchUser(999)); // rejects with NotFoundException
} catch (\NotFoundException $e) {
echo "User not found\n";
}
});
If the promise is cancelled before or during the await, await() throws
CancelledException:
async(function () use ($token) {
try {
$user = await(fetchUser(1), $token);
} catch (\Hibla\Promise\Exceptions\CancelledException $e) {
echo "Fetch was cancelled\n";
}
});
With CancellationToken
Pass a CancellationToken as the second argument to automatically track
the promise against the token. If the token is cancelled while the Fiber
is suspended, the promise is cancelled and CancelledException is thrown
at the await() call site — no manual token->track() needed:
use Hibla\Cancellation\CancellationTokenSource;
use function Hibla\async;
use function Hibla\await;
$cts = new CancellationTokenSource(5.0); // 5 second timeout
async(function () use ($cts) {
try {
$user = await(fetchUser(1), $cts->token);
$orders = await(fetchOrders($user->id), $cts->token);
return compact('user', 'orders');
} catch (\Hibla\Promise\Exceptions\CancelledException $e) {
echo "Operation timed out or was cancelled\n";
}
});
No Function Coloring in Practice
The full power of the no-coloring design becomes clear when you write library
code that uses await() internally. The same code works in every context
without any changes:
// Plain functions using await() internally — no special marking
function getUser(int $id): User
{
return await(Http::get("/users/$id")->then(
fn($r) => User::fromArray(json_decode($r->getBody(), true))
));
}
function getUserWithOrders(int $id): array
{
$user = getUser($id);
$orders = await(fetchOrders($user->id));
return compact('user', 'orders');
}
These are plain functions. Callers can use them in any of these ways without any changes to the functions themselves:
// 1. Synchronous — blocks at each call
$data = getUserWithOrders(1);
// 2. Single async task — runs in a Fiber, non-blocking
$promise = async(fn() => getUserWithOrders(1));
// 3. Concurrent — multiple users fetched in parallel
$promises = array_map(
fn($id) => async(fn() => getUserWithOrders($id)),
[1, 2, 3, 4, 5]
);
await(Promise::all($promises));
// 4. With concurrency limiting
await(Promise::concurrent(
array_map(
fn($id) => fn() => async(fn() => getUserWithOrders($id)),
range(1, 100)
),
concurrency: 10
));
The functions never changed. The concurrency strategy is entirely decided by the caller.
asyncFn() — Wrapping a Callable
asyncFn() wraps a callable so that every call to it automatically runs
inside async() and returns a Promise. Useful when you want to convert
an existing function into a reusable async factory without changing the
original function.
The same performance considerations from the "avoid unnecessary wrapping" section apply — only use it when the wrapped function genuinely needs its own Fiber context for concurrent execution:
use function Hibla\asyncFn;
function processRecord(array $record): array
{
$enriched = await(enrichRecord($record));
$validated = await(validateRecord($enriched));
return $validated;
}
// Create an async version without changing processRecord()
$asyncProcess = asyncFn('processRecord');
// Primary use case: passing to Promise::map() or Promise::concurrent()
await(Promise::map($records, $asyncProcess, concurrency: 10));
sleep() — Async-Aware Pause
The sleep() function from hiblaphp/async is an async-aware replacement
for PHP's native sleep(). It accepts fractional seconds — sleep(0.5) for
500ms, sleep(1.5) for 1.5 seconds.
- Inside a Fiber: suspends the current Fiber non-blocking. The event loop continues — other fibers, timers, and I/O run while this Fiber waits.
- Outside a Fiber: blocks the entire script, identical to PHP's native
sleep().
use function Hibla\sleep;
async(function () {
echo "Task 1 start\n";
sleep(2);
echo "Task 1 done\n";
});
async(function () {
echo "Task 2 start\n";
sleep(1);
echo "Task 2 done\n"; // runs before Task 1
});
// Output:
// Task 1 start
// Task 2 start
// Task 2 done (~1 second)
// Task 1 done (~2 seconds)
// Total time: ~2 seconds, not 3
Important: Always import
Hibla\sleepexplicitly. PHP's nativesleep()and Hibla'ssleep()have the same name — if you forget the import you will silently call PHP's native blockingsleep()instead, stalling the entire event loop with no error or warning:use function Hibla\sleep; // required — do not omit async(function () { sleep(1); // Hibla's sleep — correct \sleep(1); // PHP's native sleep — stalls the entire loop });
inFiber() — Context Detection
inFiber() returns true if the current code is executing inside a PHP
Fiber. Useful for writing code that needs to behave differently depending
on whether it is in an async context:
use function Hibla\inFiber;
function getStatus(): string
{
if (inFiber()) {
return await(fetchStatusAsync());
}
return fetchStatusSync();
}
In most cases you will not need this — await() already handles both
contexts automatically. inFiber() is primarily useful when you want to
select between fundamentally different implementations rather than just
different blocking behaviors.
Cancellation inside async()
Pass a CancellationToken to await() calls inside async() blocks to
support external cancellation of the entire workflow. When the token is
cancelled, the current await() throws CancelledException and the Fiber
unwinds naturally through any catch or finally blocks.
Use finally inside async() to guarantee cleanup runs whether the
workflow completes normally, throws, or is cancelled:
use Hibla\Cancellation\CancellationTokenSource;
use function Hibla\async;
use function Hibla\await;
$cts = new CancellationTokenSource();
$workflow = async(function () use ($cts) {
$connection = openConnection();
try {
$user = await(fetchUser(1), $cts->token);
$orders = await(fetchOrders($user->id), $cts->token);
$report = await(generateReport($user, $orders), $cts->token);
return $report;
} catch (\Hibla\Promise\Exceptions\CancelledException $e) {
echo "Workflow cancelled\n";
return null;
} finally {
// Always runs — normal completion, exception, or cancellation
$connection->close();
}
});
// Cancel from anywhere — the next await() in the workflow throws
Loop::addTimer(2.0, fn() => $cts->cancel());
$result = await($workflow);
If the token is already cancelled before the first await() inside the
Fiber runs, the first await() call throws CancelledException immediately
without suspending.
Automatic resource cleanup without track()
When you pass a token to await(), the promise is automatically tracked by
the token — you do not need to call token->track($promise) manually. This
is particularly useful when awaiting promises that already have onCancel()
handlers registered internally, such as HTTP requests from
hiblaphp/http-client. The token triggers the promise's own onCancel()
cleanup without any extra wiring at the call site:
$cts = new CancellationTokenSource(5.0);
$workflow = async(function () use ($cts) {
// Http::get() has an onCancel() handler that aborts the curl request.
// Passing $cts->token to await() is enough — no track() needed.
$response = await(Http::get('https://api.example.com/users'), $cts->token);
$data = await(Http::get('https://api.example.com/orders'), $cts->token);
return compact('response', 'data');
});
Passing the token directly to await() is the preferred pattern inside
async() blocks — it is more concise and keeps the cancellation wiring
at the await() call site where the suspension happens.
Combining with Promise Combinators
async() returns a standard Promise so it composes naturally with all
of hiblaphp/promise's collection and concurrency methods:
Running tasks concurrently with Promise::all()
[$users, $products, $stats] = await(Promise::all([
async(fn() => fetchUsers()),
async(fn() => fetchProducts()),
async(fn() => fetchStats()),
]));
Concurrency limiting with Promise::concurrent()
$results = await(Promise::concurrent(
array_map(
fn($id) => fn() => async(function () use ($id) {
$user = await(fetchUser($id));
$orders = await(fetchOrders($user->id));
return compact('user', 'orders');
}),
range(1, 100)
),
concurrency: 10
));
Racing with Promise::race()
$fastest = await(Promise::race([
async(fn() => fetchFromRegionA()),
async(fn() => fetchFromRegionB()),
async(fn() => fetchFromRegionC()),
]));
Timeout with Promise::timeout()
$cts = new CancellationTokenSource();
try {
$result = await(Promise::timeout(
async(function () use ($cts) {
return await(slowOperation(), $cts->token);
}),
seconds: 5.0
));
} catch (\Hibla\Promise\Exceptions\TimeoutException $e) {
echo "Operation timed out\n";
}
Testing Async Code
Because await() falls back to blocking synchronously outside a Fiber, you
can test async code directly without any special test runner setup, event loop
runner, or test helpers. Just call await() at the test level and it drives
the loop until the promise settles:
public function test_fetch_user(): void
{
$user = await(fetchUser(1));
$this->assertEquals('John', $user->name);
}
public function test_concurrent_fetch(): void
{
[$user, $orders] = await(Promise::all([
fetchUser(1),
fetchOrders(1),
]));
$this->assertNotEmpty($orders);
}
public function test_cancellation(): void
{
$cts = new CancellationTokenSource();
$cts->cancel();
$this->expectException(\Hibla\Promise\Exceptions\CancelledException::class);
await(fetchUser(1), $cts->token);
}
This is one of the strongest practical advantages of context-independent
await() — the same code that runs non-blocking in production runs blocking
in tests, with no adaptation required.
Comparison with JavaScript async/await
| JavaScript | Hibla | |
|---|---|---|
await usable in sync functions |
No — syntax error | Yes — falls back to blocking |
| Function coloring | Yes — spreads upward | No — color lives at call site |
| Marking a function async | Required (async function) |
Not required |
| Return type change | Yes — always returns Promise |
No — return type unchanged |
| Concurrency primitive | async function |
async(fn() => ...) at call site |
| Already-settled promise | Returns on next microtask | Returns immediately, no suspension |
| Context detection | Not available | inFiber() |
| Testing async code | Requires async test runner | Plain await() — no setup needed |
The fundamental difference is that JavaScript's await is a compile-time
grammar rule — the parser enforces it at the syntax level. Hibla's await()
is a runtime function call that checks Fiber::getCurrent(). This single
difference is what eliminates function coloring entirely.
API Reference
Functions
| Function | Description |
|---|---|
async(callable $function): PromiseInterface |
Wrap a callable in a Fiber and schedule it on the event loop. Returns a Promise that resolves with the callable's return value. The callable does not run immediately — it is queued in the next Fiber phase. |
await(PromiseInterface $promise, ?CancellationToken $token): mixed |
Suspend the current Fiber until the promise settles (inside Fiber), or block synchronously (outside Fiber). Returns immediately without suspending for already-settled promises. Automatically tracks the promise on the token if provided. Throws on rejection or cancellation. |
asyncFn(callable $function): callable |
Wrap a callable so every call runs inside async() and returns a Promise. Creates a new Fiber per call. |
sleep(float $seconds): void |
Suspend the current Fiber non-blocking (inside Fiber), or block synchronously (outside Fiber). Accepts fractional seconds. Always import explicitly — PHP's native sleep() has the same name. |
inFiber(): bool |
Returns true if currently executing inside a PHP Fiber. |
Development
Running Tests
git clone https://github.com/hiblaphp/async.git
cd async
composer install
./vendor/bin/pest
./vendor/bin/phpstan analyse
Credits
- API Design: Inspired by JavaScript's
async/awaitsyntax and the function coloring problem described by Bob Nystrom in "What Color is Your Function?". Hibla's context-independentawait()is a direct solution to the problem that article described. - Fiber Scheduling: Powered by hiblaphp/event-loop.
- Promise Integration: Built on hiblaphp/promise.
- Cancellation: Powered by hiblaphp/cancellation.
License
MIT License. See LICENSE for more information.