Embedded: PHP & frameworks
PHP 8 ships a built-in FFI extension, so PHP embeds the engine directly over the same C ABI — no native extension to compile. The twilldb/twilldb package wraps it in the same ergonomic surface as the Bun / Node clients. Frameworks that prefer Postgres connect over the wire with PDO instead.
Install
composer require twilldb/twilldb
Requires PHP ≥ 8.1 with the FFI extension enabled — set ffi.enable=1 in php.ini, or pass -d ffi.enable=1. The embedded path also needs the native libengine: build it with cargo build -p twill-engine --release, or set TWILLDB_ENGINE_PATH to a built libengine.{so,dylib,dll}.
Quickstart (embedded)
<?php
require __DIR__ . '/vendor/autoload.php';
use Twill\Database;
// file:// embedded · s3://|r2://|gs:// storage-disaggregated
$db = Database::open('file://./local.db');
try {
$db->exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)');
$db->query('INSERT INTO notes (id, body) VALUES (?, ?)', [1, 'hello']);
$rows = $db->query('SELECT id, body FROM notes');
\print_r($rows);
} finally {
$db->close();
}
Breakdown: the storage backend is chosen entirely by the URL scheme. Parameters bind positionally (?); values are never string-interpolated. Failures throw Twill\EngineError with a numeric ->status and a ->retryable flag.
API at a glance
| Call | Effect |
|---|---|
Database::open($url) | open a database (backend chosen by URL scheme) |
$db->exec($sql) | run DDL/DML, returns rows affected |
$db->query($sql, $params = []) | buffered rows; params bind positionally |
$db->prepare($sql) | reusable Statement (->all() / ->get() / ->run()) |
$db->transaction($fn) | BEGIN/COMMIT, rollback on throw; commit blocks until durable |
$db->branch($name) | copy-on-write branch at the current LSN |
$db->close() | release the handle (idempotent) |
Frameworks: Laravel, Symfony, CodeIgniter
There are two ways in, and the right choice depends on your runtime model.
1 — Embedded (persistent worker)
Open one Twill\Database in a singleton / service provider and reuse it across requests — the engine is a single writer per database. This shines on a long-lived worker (FrankenPHP, RoadRunner, Swoole), where the open + WAL-replay cost is paid once.
// A Laravel service provider (register the engine as a singleton).
$this->app->singleton(Twill\Database::class, fn () =>
Twill\Database::open(env('TWILLDB_URL', 'file://./app.db'))
);
Classic php-fpm pays the cost per request
Under classic per-request php-fpm, each request opens and replays the WAL afresh. For that model, prefer server mode below (or move to a persistent worker).
2 — Server mode (PDO — no FFI)
Run engine-server (the same engine behind a Postgres-wire listener) and point your framework's Postgres connection at it. No FFI, no native library in the PHP process — frameworks already speak Postgres.
// Plain PDO against engine-server (cleartext — sslmode=disable).
$pdo = new PDO('pgsql:host=127.0.0.1;port=5433;dbname=main;sslmode=disable', 'postgres', '');
$pdo->exec('CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, body TEXT)');
$stmt = $pdo->prepare('INSERT INTO notes (id, body) VALUES (?, ?)');
$stmt->execute([1, 'hello']);
- Laravel: add a
pgsqlconnection inconfig/database.phpwithhost=127.0.0.1,port=5433,sslmode=disable. - CodeIgniter: use the
Postgredatabase driver with the same DSN. - Symfony: set
DATABASE_URL="postgresql://[email protected]:5433/main?sslmode=disable".