Async Queries
Every read in Miko has an ...Async() twin that returns a Future. On MySQL / MariaDB, PostgreSQL and SQL Server, independent queries then run in parallel on extra connections: three queries of one second take about one second instead of three. Normal methods are unchanged - add Async only where several independent queries (or HTTP calls) can run together.
use Miko\Core\Async\Async;
[$users, $orderCount, $revenue, $rates] = Async::all([
User::where('Active', 1)->with('roles')->getAsync(),
Order::query()->countAsync(),
Order::query()->whereYear('Date', 2026)->sumAsync('Total'),
$http->getAsync('https://api.example.com/rates'), // HTTP requests join the same wait
]);
How it works
- Calling an
...Async()method sends the query right away on an extra connection and returns aFuture. - Your code continues; meanwhile the database works.
await()(orAsync::all()) waits until the results are there. While waiting, the other running queries and HTTP requests are processed too.
| Database | Async queries | Client |
|---|---|---|
| MySQL / MariaDB | parallel | mysqli when loaded, otherwise the built-in client |
| PostgreSQL | parallel | pgsql when loaded, otherwise the built-in client |
| SQL Server | parallel | built-in TDS client (pdo_sqlsrv has no async API) |
| SQLite | one by one on the main connection, same results | - (no server) |
No extra PHP extension is needed: see Built-in Clients.
Results are the same as the normal methods - same rows, same PHP types, same models - and errors are the same QueryException with the same code and SQLSTATE.
Async Methods
ORM builder and models
| Normal | Async |
|---|---|
get() | getAsync() -> Future<Model[]> (eager loads included) |
first() | firstAsync() |
find($id) / findMany($ids) | findAsync($id) / findManyAsync($ids) |
count() / exists() | countAsync() / existsAsync() |
sum() / avg() / min() / max() | sumAsync() / avgAsync() / minAsync() / maxAsync() |
value() / pluck() | valueAsync() / pluckAsync() |
paginate() | paginateAsync() - count and page in parallel |
User::all() | User::allAsync() |
Static calls work too: User::countAsync(), User::findAsync(5), User::where(...)->getAsync(). Relations: $user->posts()->getAsync(), $user->roles()->getAsync() (with pivot data).
Table builder, DB and RawQuery
DB::table('orders')->where('Status', 'open')->getAsync();
DB::table('orders')->countAsync();
DB::table('users')->paginateAsync(1, 50);
DB::queryAsync('SELECT * FROM logs WHERE Level = ?', ['error']);
DB::firstAsync('SELECT * FROM users WHERE Id = ?', [5]);
DB::scalarAsync('SELECT COUNT(*) FROM users');
RawQuery::make(DB::connection(), 'SELECT * FROM users WHERE Role = :r')->bind('r', 'admin')->getAsync();
HTTP
HttpClient::getAsync(), postAsync(), putAsync(), patchAsync(), deleteAsync(), downloadAsync(), requestAsync() return Future<HttpResponse> - see HttpClient.
Waiting for results
use Miko\Core\Async\Async;
// all: keys are kept; throws the first error (in key order) after every future finished
$results = Async::all([
'users' => User::query()->countAsync(),
'orders' => Order::query()->countAsync(),
]);
$results['users'];
// allSettled: never throws
$results = Async::allSettled(['a' => $q1->getAsync(), 'b' => $q2->countAsync()]);
// ['a' => ['status' => 'fulfilled', 'value' => [...]],
// 'b' => ['status' => 'rejected', 'reason' => QueryException]]
// one future
$future = DB::queryAsync('SELECT ...', [$id]); // sent now
// ... other work ...
$rows = $future->await(); // rethrows the query's error
Plain values may be mixed in: Async::all(['a' => $future, 'b' => 42]).
Future API
| Method | Description |
|---|---|
await() | wait and return the value (throws the error of a failed future) |
then($onFulfilled, $onRejected = null) | new future with the callback result; a callback may return another future |
catch($onRejected) | handle an error; return a value to recover |
finally($callback) | run when settled, keep the result |
isPending() / isReady() / isFulfilled() / isRejected() | state |
Future::resolved($value) / Future::rejected($error) / Future::call($callable) | create |
Future::all($items) / Future::settleAll($items) | combine without waiting |
$names = User::where('Active', 1)->getAsync()
->then(fn(array $users) => array_map(fn($u) => $u->Name, $users));
$total = Order::query()->sumAsync('Total')
->catch(fn(Throwable $e) => 0) // fallback value
->finally(fn() => Logger::general('sum done', [], 'DEBUG'));
echo implode(', ', $names->await()), ' / ', $total->await();
Deferred is the writing side of a future, for wrapping your own asynchronous work: $d = new Deferred(); ... $d->resolve($value); return $d->future();.
Settings
Config/Database.php (async section, DB_ASYNC_* in .env) or Async::configure():
| Setting | Default | Description |
|---|---|---|
enabled | true | false: every async method runs on the main connection, one by one |
max_connections | 4 | extra connections per database connection; more queries wait in a queue |
timeout | 0 | seconds before a running or queued query is cancelled on the server (0 = no limit) |
mysql_driver | auto | auto (mysqli if loaded, else built-in), extension, php |
pgsql_driver | auto | auto (pgsql if loaded, else built-in), extension, php |
sqlsrv_driver | auto | auto / php (built-in TDS), extension (one by one) |
Async::configure(['max_connections' => 8, 'timeout' => 5]);
DB::supportsParallelQueries(); // true when the default connection runs async queries in parallel
The extra connections use the connection's own config (host, credentials, charset, SSL options, session setup). connect_timeout in the connection config (default 10 s) limits how long opening one may take.
Timeouts
use Miko\Database\Async\AsyncTimeoutException;
Async::configure(['timeout' => 2]);
try {
$rows = DB::queryAsync('SELECT ... very slow ...')->await();
} catch (AsyncTimeoutException $e) {
$e->getSqlState(); // 'HYT00'
// the query was cancelled on the server (KILL QUERY / PostgreSQL cancel request / TDS attention),
// the connection stays usable
}
AsyncTimeoutException extends QueryException.
When queries run on the main connection
The same methods give the same results, just not in parallel, when:
- the database is SQLite;
- the connection is inside a transaction - async queries then run on the transaction's connection, so they see its uncommitted rows;
enabledisfalse, orsqlsrv_driverisextension;- no client fits the config: SQL Server with Windows authentication (no username), MySQL with a charset other than utf8mb4 / utf8 / latin1 / ascii and no
mysqli, Kerberos / GSSAPI logins; - no extra connection can be opened at all (server connection limit, login method not supported) - a warning goes to
Log/connection.log, and the next attempt is made after 60 seconds; - PostgreSQL values contain a NUL byte, or SQL Server text is not valid UTF-8.
If some extra connections opened and a later one fails, the queries share the open ones.
Examples
Dashboard
$stats = Async::all([
'users' => User::countAsync(),
'activeUsers'=> User::where('IsActive', true)->countAsync(),
'orders' => Order::whereDate('CreatedDate', date('Y-m-d'))->countAsync(),
'revenue' => Order::where('Status', 'completed')->whereYear('CreatedDate', 2026)->sumAsync('TotalAmount'),
'latest' => Order::with('user')->latest()->take(10)->getAsync(),
]);
Page with count in parallel
$page = Post::where('Published', true)->latest()->paginateAsync(20, $pageNo)->await();
Database and HTTP together
$http = HttpClient::create('https://api.example.com', ['timeout' => 5]);
[$customer, $orders, $credit] = Async::all([
Customer::findAsync($id),
Order::where('CustomerId', $id)->latest()->take(20)->getAsync(),
$http->getAsync("/credit-score/{$id}"),
]);
$score = $credit->ok() ? $credit->json('score') : null;
Partial failure
$results = Async::allSettled([
'main' => Report::query()->getAsync(),
'extras' => DB::queryAsync('SELECT * FROM optional_view'),
]);
$extras = $results['extras']['status'] === 'fulfilled' ? $results['extras']['value'] : [];
Good to know
- Use async for reads. A write sent through
DB::queryAsync()runs on a separate connection, outside your transaction;RawQuery::getAsync()accepts SELECT only. - Each parallel query needs its own server connection; with many PHP workers, keep
max_connectionsmodest so the database's connection limit is not reached. - Queries are logged with
async => trueinQueryLogger; slow ones are marked[async]in the slow query log. Connection::disconnect()closes the extra connections; queued queries then fail.