amphp/mysql
amphp/mysql is an asynchronous MySQL client for PHP built on Amp. It offers non-blocking queries, connection pooling, prepared statements, transactions, and streaming results for high-concurrency, event-driven applications.
amphp/mysql is the async MySQL client from the Amp ecosystem — not an Eloquent/PDO drop-in. You reach for it when a single PHP process needs to hold many concurrent database conversations: fan-out queries, bulk ingest, websocket/long-running workers, or an Octane worker that shouldn't serialize its I/O.
Install and orient
composer require amphp/mysql
amphp/amp v3 (Revolt event loop). It pulls in amphp/socket, amphp/sql, and amphp/file (used for TLS certificate loading).src/MysqlConfig.php (how connections are described), src/MysqlConnectionPool.php (what you'll actually touch 95% of the time), src/MysqlConnection.php / MysqlTransaction.php, then the README examples.First use case: an Artisan command that queries concurrently
use Amp\Mysql\MysqlConfig;
use Amp\Mysql\MysqlConnectionPool;
use function Amp\async;
use function Amp\Future\await;
class WarmReports extends Command
{
protected $signature = 'reports:warm {--account=*}';
public function handle(): int
{
$config = MysqlConfig::fromString(
'host=127.0.0.1 user=reports password=secret db=app'
);
$pool = new MysqlConnectionPool($config, maxConnections: 8);
$futures = [];
foreach ($this->option('account') as $id) {
$futures[$id] = async(fn () => $pool->execute(
'SELECT COUNT(*) AS c FROM orders WHERE account_id = ?', [$id]
)->fetchRow());
}
foreach (await($futures) as $accountId => $row) {
$this->line("{$accountId}: {$row['c']}");
}
$pool->close();
return self::SUCCESS;
}
}
If that feels like a lot of ceremony compared to DB::select(), that's the point: the ceremony is what buys you 8 queries in flight instead of 8 sequential round trips.
Pool per process, not per query. MysqlConnectionPool is the unit of work. Construct it once (in a command, worker boot, or Octane worker lifecycle hook), reuse it, and close() on shutdown. Building a pool inside a loop defeats the purpose.
// AppServiceProvider::register() — only meaningful in long-running contexts
$this->app->singleton(MysqlConnectionPool::class, fn () => new MysqlConnectionPool(
MysqlConfig::fromString(sprintf(
'host=%s user=%s password=%s db=%s',
config('database.connections.mysql.host'),
config('database.connections.mysql.username'),
config('database.connections.mysql.password'),
config('database.connections.mysql.database'),
)),
maxConnections: 10,
));
Concurrency with a bound. Unbounded async() over a 50k-row import will queue 50k futures and exhaust the pool. Use Amp\Sync\LocalSemaphore or chunk the work:
$semaphore = new LocalSemaphore(8);
$futures = array_map(fn ($chunk) => async(function () use ($semaphore, $pool, $chunk) {
$lock = $semaphore->acquire();
try {
return $pool->execute('INSERT INTO events (id, payload) VALUES '.$placeholders, $chunk)->getRowCount();
} finally {
$lock->release();
}
}), array_chunk($rows, 500));
Transactions are connection-scoped. Get an explicit connection from the pool, then transact on it. Do not interleave $pool->execute() calls inside a transaction and expect them to be part of it.
$connection = $pool->getConnection();
$transaction = $connection->beginTransaction();
try {
$transaction->execute('UPDATE accounts SET balance = balance - ? WHERE id = ?', [$amt, $from]);
$transaction->execute('UPDATE accounts SET balance = balance + ? WHERE id = ?', [$amt, $to]);
$transaction->commit();
} catch (Throwable $e) {
$transaction->rollBack();
throw $e;
} finally {
$connection->close();
}
Stream, don't materialize. The result object is iterable; for large exports, foreach over it and write to a file handle rather than iterator_to_array(). This pairs naturally with amphp/file for the write side.
Sit it beside Eloquent, not instead of it. The pragmatic Laravel split: keep Eloquent/PDO for CRUD, validation, and anything touching model events; use amphp/mysql for the hot path in workers — analytics aggregation, third-party sync fan-out, log/metric ingestion — where you're issuing dozens of independent statements. Both can point at the same database; just budget max_connections for the sum of PHP-FPM workers, Octane workers, queue workers, and your pools.
Queue jobs. In a queue worker, wrap the job body in an Amp context and share the pool across jobs:
public function handle(MysqlConnectionPool $pool): void
{
await([async(fn () => $pool->execute(...))]);
}
Keep an eye on worker restarts — pool->close() in a terminating/WorkerStopping listener so you don't leak sockets across deploys.
Error handling. Failures surface as Amp\Sql\SqlException (and Amp\Sql\ConnectionException / QueryError subtypes), not PDOException or Illuminate\Database\QueryException. If your app has global exception handling keyed on the Laravel types, add a mapping layer or you'll get unhelpful 500s.
Do not use this in a normal FPM request path. There's no event loop running; you'd be nesting Amp\Loop::run() inside a request, which is fragile and buys nothing over PDO. This package's value is entirely in processes that live longer than one request.
v3.1.1 is a safe patch. No API changes, no deprecations. Three things worth knowing:
UUID type is stored as 16 bytes; over the binary protocol you may receive raw bytes rather than the canonical xxxxxxxx-xxxx-... string you'd get over the text protocol. If you use UUID columns, normalize in PHP (bin2hex + hyphenation) or cast in SQL, and add a test on your actual server/version rather than assuming string semantics.chr() deprecation was silenced in the integer encoding path. Behaviour is unchanged on 8.1–8.4; on 8.5 you just stop getting deprecation noise in logs. Nothing to do on your side.amphp/[email protected] compatibility matters only if you use TLS with client certificates or a CA file — that's the code path reaching into amphp/file. If you pin amphp/file to 3.x elsewhere in your tree, Composer will resolve to the older amphp/mysql patch instead. Check composer why amphp/file after upgrading.Pool size vs. max_connections. A pool of 10 in each of 20 queue workers is 200 connections. MySQL's default max_connections is 151. Set maxConnections deliberately and monitor Threads_connected; connection exhaustion here looks like random ConnectionExceptions under load, not an obvious error.
Prepared statements and the binary protocol. $pool->execute() uses prepared statements, so type handling differs from the text protocol you're used to with PDO. DECIMAL, BIGINT, BIT, and JSON columns can arrive as strings or byte strings. Don't assume int for an ID column — cast explicitly.
No migrations, no schema builder, no query builder. You're writing SQL strings. Identifier quoting is on you; parameters are ? placeholders only (no named binds). Wrap table/column names from user input in backticks and validate against an allowlist.
Timeouts are your responsibility. Long-running async queries won't be killed by max_execution_time in CLI contexts. Wrap with Amp\Timeout\timeout() and make sure a stuck query can't pin a pool slot forever.
Debugging. There's no query log. Add a thin wrapper around execute() that records the SQL, bindings, and elapsed time to Laravel's logger, or you'll be debugging blind. amphp/log + a Revolt loop watcher is useful for spotting a blocked loop — an accidental synchronous call (a file_get_contents, a sleep, an Eloquent query in the middle of an async block) stalls all your concurrency, and the symptom is uniform latency inflation, not an error.
Extension points. MysqlConfig covers host/port/user/password/db/charset/TLS (sslCa, sslCert, sslKey). For retry logic, metrics, or read/write splitting, wrap MysqlConnectionPool in your own class implementing Amp\Sql\ConnectionPool rather than forking the library — the pooled-connection API is stable and the interface is small.
Upgrade hygiene. Track the amphp/amp v3 and revolt/event-loop pins; most "it just stopped working" reports against this package trace back to a mismatched Amp major, not to MySQL. Run composer why-not amphp/amp ^3 after any dependency bump.
How can I help you explore Laravel packages today?