fabpot/amphp-sqlite3
fabpot/amphp-sqlite3 provides asynchronous, non-blocking access to SQLite3 databases for Amp-based PHP applications, using promises and coroutines so queries can run without blocking the event loop.
This package is a thin async SQLite client built on Amp. It is not a Laravel database driver — it does not plug into DB, Eloquent, or the query builder. Treat it as a standalone client you reach for inside code that already runs on an Amp/Revolt event loop (CLI commands, long-running workers, custom daemons), not inside a normal HTTP request handled by PHP-FPM.
Install:
composer require fabpot/amphp-sqlite3
This pulls in amphp/amp (and, depending on the version you pin, amphp/sql / revolt/event-loop). Confirm which major version resolved — the API changed between Amp v2 and v3.
Prerequisites: ext-sqlite3 (the SQLite3 class, not pdo_sqlite), PHP 8.x, and a writable database file plus its containing directory (SQLite needs the directory for journal/WAL files).
Look first at the Fabpot\AmpSqlite3\Sqlite3Connection and Sqlite3ConnectionPool classes. Everything routes through amphp/sql interfaces (SqlConnection, SqlConnectionPool), so the method surface (execute, query, prepare, beginTransaction) will feel familiar if you've used amphp/mysql.
First use case — an Artisan command that does real concurrent I/O:
// app/Console/Commands/AsyncReport.php
public function handle(): int
{
return Amp\Loop::run(function () {
$pool = new Fabpot\AmpSqlite3\Sqlite3ConnectionPool(
new Fabpot\AmpSqlite3\Sqlite3ConnectionConfig(database_path('app.sqlite')),
maxConnections: 4,
);
$result = $pool->execute('SELECT id, email FROM users WHERE active = ?', [1]);
foreach ($result as $row) {
// handle row
}
return 0;
});
}
The win over PDO here is that while one query streams, you can await other futures (HTTP calls, file writes) without blocking. If you only have one query and nothing else to do concurrently, PDO is simpler and faster.
Bind a pool in a service provider. Resolve the pool once, keyed to a named SQLite file, and inject it into the services that need it. Constructing a connection per call defeats the point of the pool.
$this->app->singleton(Sqlite3ConnectionPool::class, fn () => new Sqlite3ConnectionPool(
new Sqlite3ConnectionConfig(config('database.connections.sqlite.database')),
maxConnections: (int) config('database.async_pool_size', 4),
));
Keep this out of the default DatabaseServiceProvider flow — it's an opt-in binding, not a replacement for db.
Repository wrapper. Hide the pool behind a repository so callers stay Amp-agnostic in their signatures and you have one place to swap in PDO later if the async bet doesn't pay off.
final class UserRepository
{
public function __construct(private SqlConnectionPool $pool) {}
public function activeEmails(): iterable
{
foreach ($this->pool->execute('SELECT email FROM users WHERE active = 1') as $row) {
yield $row['email'];
}
}
}
Fan-out reads. The pool is most useful when you issue many independent reads concurrently. Wrap each in Amp\async() and Future\awaitAll() them. SQLite in WAL mode handles concurrent readers fine; concurrent writers are serialized.
Transactions. Use $pool->beginTransaction() and keep the transaction inside a single coroutine. Don't interleave unrelated queries into an open transaction — the pool may hand them to a different connection and you'll get surprising isolation.
Integrate with a custom worker. If you've written your own queue consumer on Amp (or are evaluating amphp/redis for jobs), the pool slots in naturally: pop a job, run its DB work, all without blocking the loop. Do not try to run this inside Laravel's built-in queue:work loop — it's synchronous and PDO-backed.
Migrations stay with PDO. Keep php artisan migrate on the default PDO connection and let the async client be read-heavy at runtime. There's no schema builder here.
Testing. Forget RefreshDatabase. Spin the loop manually in the test, point at a temp file, and tear it down:
$db = tempnam(sys_get_temp_dir(), 'test') . '.sqlite';
// create schema with PDO or SQLite3, then run the pool against $db
DB in day-to-day Laravel work.sleep(), file_get_contents() on a slow URL, or synchronous PDO call inside the loop stalls all in-flight queries. Audit every call inside a coroutine. Prefer Amp's filesystem/HTTP clients alongside it.SQLITE_BUSY is a configuration problem. Enable WAL (PRAGMA journal_mode = WAL), set a busy_timeout (PRAGMA busy_timeout = 5000), and cap maxConnections low (2–4) for write-heavy workloads. Multiple writer connections on one file will fight.ext-sqlite3 ≠ pdo_sqlite. They bind parameters and return types differently (SQLite's type affinity is loose). Rows come back as associative arrays; cast explicitly rather than relying on coercion.Loop::run() → Revolt transition to break examples you find online. Budget time to read the source — it's small.beginTransaction() and subsequent execute() calls to different connections unless your amphp/sql version tracks transaction affinity. Verify this against the version you pin before trusting multi-statement transactions.database_path() is fine, but the directory must be writable for -wal and -shm files. In Docker, a read-only volume mount silently breaks WAL.sqliteCreateFunction, drop to the raw SQLite3 handle — which means leaving the async layer.amphp/mysql should be a one-file change, not a refactor.How can I help you explore Laravel packages today?