PHP does not have native JavaScript-style async and await syntax.
But with Swoole or OpenSwoole coroutines, we already have most of the runtime primitives needed to build something very close to it.
Recently, while working on small/swoole-patterns, I wanted an API that would let me write concurrent PHP code like this:
use Small\SwoolePatterns\Async\Async;
use function Small\SwoolePatterns\Async\async;
use function Small\SwoolePatterns\Async\await;
$result = Async::main(function (): array {
$user = async(fn() => loadUser(42));
$orders = async(fn() => loadOrders(42));
return [
'user' => await($user),
'orders' => await($orders),
];
});
The important part is that loadUser() and loadOrders() start immediately and concurrently.
await() does not start the operation. It only waits for an already-running asynchronous operation.
That distinction is what makes the model feel similar to JavaScript promises.
This article explains the architecture behind it.
The goal
I did not want to build another event loop.
Swoole and OpenSwoole already provide an efficient coroutine scheduler.
The goal was therefore much smaller:
Build a thin abstraction over coroutines that represents an eventual value.
The architecture ended up looking like this:
Application
│
▼
Async::main()
│
▼
Runtime::run()
│
├───────────────┐
▼ ▼
async() async()
│ │
▼ ▼
Future<T> Future<T>
│ │
▼ ▼
Coroutine Coroutine
│ │
└───────┬───────┘
▼
await()
│
▼
Channel::pop()
There are three main concepts:
-
Future<T>represents a result that may not exist yet. -
async()starts an operation in another coroutine. -
await()suspends the current coroutine until the result becomes available.
The AwaitableInterface
The lowest-level abstraction is deliberately tiny:
namespace Small\SwoolePatterns\Async;
/**
* @template-covariant T
*/
interface AwaitableInterface
{
/**
* @return T
*/
public function await(?float $timeout = null): mixed;
}
Anything implementing this interface can be awaited.
Today that means Future.
But this abstraction could later represent things such as:
HTTP responses
database queries
timers
signals
pool acquisitions
message queue operations
without changing user code.
Implementing Future<T>
A Future has only three possible states:
enum FutureState
{
case pending;
case fulfilled;
case rejected;
}
Internally, the Future stores:
private FutureState $state = FutureState::pending;
private mixed $value = null;
private ?Throwable $exception = null;
private Channel $signal;
The important architectural choice is that the Channel does not carry the result.
The result is stored directly inside the Future.
The Channel exists only as a completion signal.
That makes multiple awaiters possible.
$future = async(fn() => expensiveOperation());
async(fn() => consume(await($future)));
async(fn() => logResult(await($future)));
async(fn() => cache(await($future)));
All three coroutines can await the same Future.
The value is calculated only once.
Why close the Channel?
When the Future resolves or rejects, we store the state first:
private function settle(
FutureState $state,
mixed $value,
?Throwable $exception,
): void {
if ($this->state !== FutureState::pending) {
return;
}
$this->state = $state;
$this->value = $value;
$this->exception = $exception;
$this->signal->close();
}
Closing the Channel wakes the waiting coroutines.
The Channel therefore behaves like a broadcast notification:
Future pending
│
├── coroutine A waiting
├── coroutine B waiting
└── coroutine C waiting
│
▼
Future settles
│
▼
Channel closes
│
┌──────┼──────┐
▼ ▼ ▼
A B C
No polling loop is required.
No repeated usleep().
No result duplication through the Channel.
Implementing await()
The core implementation is quite small:
public function await(?float $timeout = null): mixed
{
if ($timeout !== null && $timeout < 0) {
throw new InvalidArgumentException(
'Future timeout must be null or greater than or equal to 0.'
);
}
if ($this->isPending()) {
if (!Runtime::isInCoroutine()) {
throw new AsyncException(
'A pending Future can only be awaited inside Async::main() or Runtime::run().'
);
}
$this->signal->pop($timeout ?? -1);
if ($this->isPending()) {
throw new TimeoutException(
'Future did not complete before the timeout.'
);
}
}
if ($this->state === FutureState::rejected) {
throw $this->exception;
}
return $this->value;
}
If the Future has already completed:
$value = await($future);
does not interact with the scheduler at all.
It essentially becomes:
check state
return value
This is important because awaiting an already-resolved Future should be cheap.
Starting asynchronous work
The equivalent of invoking a JavaScript async function is Async::start().
Conceptually:
public static function start(
Closure $callback,
mixed ...$arguments,
): Future {
$future = new Future(
function (Closure $resolve, Closure $reject)
use ($callback, $arguments): void {
Runtime::create(
function () use (
$callback,
$arguments,
$resolve,
$reject
): void {
try {
$result = $callback(...$arguments);
$resolve($result);
} catch (Throwable $exception) {
$reject($exception);
}
}
);
}
);
return $future;
}
Calling:
$future = async(fn() => queryDatabase());
creates the coroutine immediately.
So:
$a = async(fn() => operationA());
$b = async(fn() => operationB());
$resultA = await($a);
$resultB = await($b);
does not execute sequentially. Both operations are already running before the first await().
The coroutine boundary
A coroutine needs a scheduler, so we need an entry point:
Async::main(function () {
// async code
});
Internally it delegates to the library's runtime abstraction:
public static function main(Closure $callback): mixed
{
return Runtime::run(
static function () use ($callback): mixed {
$result = $callback();
return $result instanceof AwaitableInterface
? $result->await()
: $result;
}
);
}
There is still only one scheduler.
The library does not try to replace Swoole.
Promise adoption
If a Future resolves to another awaitable, the outer Future adopts the inner result:
$result = $callback(...$arguments);
if ($result instanceof AwaitableInterface) {
$result = $result->await();
}
$resolve($result);
That avoids leaking Future<Future<T>> into application code.
Exceptions behave like synchronous PHP
$future = async(function () {
throw new RuntimeException('Database unavailable');
});
The exception is captured by the Future and rethrown when awaited:
try {
$result = await($future);
} catch (RuntimeException $exception) {
echo $exception->getMessage();
}
This keeps async control flow close to normal synchronous PHP.
Waiting for several Futures
Async::all() is similar to JavaScript's Promise.all():
$result = await(
Async::all([
'user' => async(fn() => loadUser()),
'orders' => async(fn() => loadOrders()),
'stock' => async(fn() => loadStock()),
])
);
The result preserves both keys and ordering:
[
'user' => $user,
'orders' => $orders,
'stock' => $stock,
]
Async::all() provides one aggregate Future representing the whole group.
Timeout without cancellation
await() supports a timeout:
try {
$value = await($future, timeout: 1.5);
} catch (TimeoutException) {
// operation did not finish in 1.5 seconds
}
A timeout does not cancel the Future.
The underlying coroutine continues running.
try {
await($future, timeout: 0.1);
} catch (TimeoutException) {
// continue doing something else
}
$result = await($future);
Timeout and cancellation are intentionally separate concepts.
Why not implement then()?
It would be easy to add a promise-style API such as:
$future
->then(...)
->then(...)
->catch(...);
But PHP already has excellent imperative exception handling:
try {
$user = await($userFuture);
$orders = await($orderFuture);
return buildResponse($user, $orders);
} catch (Throwable $exception) {
// ...
}
Once await() exists, callback chains are far less necessary.
More importantly, another promise scheduler would duplicate work already handled by Swoole.
Keeping the runtime lightweight
For:
$result = await(
async(fn() => operation())
);
the runtime structure is essentially:
1 Future
1 coroutine
1 Channel
1 stored value
1 optional exception
There is no custom event loop, polling scheduler, worker thread, or callback chain.
Swoole handles concurrency.
The Future only models the eventual result.
Supporting both Swoole and OpenSwoole
The project supports both:
Swoole 6.2.x
OpenSwoole 26.2.x
The async layer sits on top of a small runtime compatibility abstraction so application code does not need to know which engine is active.
The final test suite reached:
167 tests
441 assertions
Classes: 100%
Methods: 100%
Lines: 100%
including the native PDO MySQL coroutine integration test.
A coverage issue appeared because the Swoole/OpenSwoole root coroutine implementations require different runtime paths. The selector was kept as a single executable statement so each runtime does not lose line coverage simply because it cannot execute the other runtime's branch:
$run ??= method_exists(Swoole\Coroutine::class, 'run') ? [Swoole\Coroutine::class, 'run'] : 'Swoole\\Coroutine\\run';
The resulting developer experience
$result = Async::main(function () {
$product = async(fn() => loadProduct());
$stock = async(fn() => loadStock());
$prices = async(fn() => loadPrices());
return [
'product' => await($product),
'stock' => await($stock),
'prices' => await($prices),
];
});
Compare it with JavaScript:
const productPromise = loadProduct();
const stockPromise = loadStock();
const pricesPromise = loadPrices();
const result = {
product: await productPromise,
stock: await stockPromise,
prices: await pricesPromise
};
The syntax is different because PHP does not provide language-level await, but the execution model is very similar:
start operation
receive eventual result
continue doing work
await when needed
propagate exception normally
What comes next?
The architecture leaves room for several extensions without changing the fundamental API:
-
race()for the first completed Future - cancellation
- timeout decorators
- concurrency-limited groups
- adapting HTTP/database operations directly to
AwaitableInterface
But the important foundation is already there.
A Future should remain boring.
It represents one thing:
A value that exists now or will exist later.
Swoole handles concurrency.
The Future handles the result.
await() connects the two.
And with those few primitives, PHP coroutine code starts feeling a lot more like modern asynchronous JavaScript.
Links
Repository : https://git.small-project.dev/lib/small-swoole-patterns
Packagist : https://packagist.org/packages/small/swoole-patterns
Tags: #php #swoole #openswoole #async
Top comments (0)