/ Async Queries

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

  1. Calling an ...Async() method sends the query right away on an extra connection and returns a Future.
  2. Your code continues; meanwhile the database works.
  3. await() (or Async::all()) waits until the results are there. While waiting, the other running queries and HTTP requests are processed too.
DatabaseAsync queriesClient
MySQL / MariaDBparallelmysqli when loaded, otherwise the built-in client
PostgreSQLparallelpgsql when loaded, otherwise the built-in client
SQL Serverparallelbuilt-in TDS client (pdo_sqlsrv has no async API)
SQLiteone 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

NormalAsync
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

MethodDescription
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():

SettingDefaultDescription
enabledtruefalse: every async method runs on the main connection, one by one
max_connections4extra connections per database connection; more queries wait in a queue
timeout0seconds before a running or queued query is cancelled on the server (0 = no limit)
mysql_driverautoauto (mysqli if loaded, else built-in), extension, php
pgsql_driverautoauto (pgsql if loaded, else built-in), extension, php
sqlsrv_driverautoauto / 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;
  • enabled is false, or sqlsrv_driver is extension;
  • 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_connections modest so the database's connection limit is not reached.
  • Queries are logged with async => true in QueryLogger; slow ones are marked [async] in the slow query log.
  • Connection::disconnect() closes the extra connections; queued queries then fail.