PHP has been declared dead so many times that the obituaries have their own version numbering, and yet the language still runs an enormous fraction of the web, which means the honest question is not whether to write database clients for it but whether to write them in the old style that everyone remembers from 2009, or in the style the language actually supports now, in 2026, after PHP 8.5 has been stable for eight months and the features that make a typed, fluent client possible have all landed and matured.

We picked the second path, and MongrelDB-PHP is the result: a pure-language HTTP client for MongrelDB that targets PHP 8.5, uses no C extension, and leans on property hooks, readonly classes, the pipe operator, and the new URI API to produce code that looks like something a competent engineer wrote this year, not something assembled from mysql_query fragments and prayer.

Why no C extension

The obvious architecture for a PHP database client is a C extension, because that is what mysqli and PDO are, and that is what every PHP database driver for the last twenty years has been, and the reason is performance: a C extension talks to the database’s wire protocol natively, without HTTP overhead, without JSON serialization, without anything in between, and for a database doing thousands of queries per request that matters.

MongrelDB-PHP does not do this, and the reason is that the bottleneck for an application talking to MongrelDB is almost never the serialization overhead between PHP and the HTTP endpoint, because MongrelDB is designed around fewer, heavier queries that push predicate logic down to native indexes, not the thousands-of-tiny-queries-per-request pattern that made C extensions necessary for MySQL in the first place. When a single query does the work of fifty, the cost of JSON over HTTP rounds to zero against the cost of the actual database work, and the engineering cost of maintaining a C extension that users have to compile, against every PHP version, every Zend ABI change, and every distribution’s build tooling, is enormous and never-ending.

The HTTP client ships as Composer package with zero compilation steps, runs on any PHP 8.5 or newer installation, and works identically whether the application is on shared hosting, a container, or a bare-metal server, because there is nothing to compile and nothing to link. The tradeoff is a measurable serialization cost per query, which is real, and which we measure, and which is dominated by the query execution time for any workload this client is designed for.

Typed CRUD and fluent queries

The CRUD surface uses readonly classes and property hooks to represent rows, which means a MongrelDB table maps to a PHP class whose properties are immutable after construction, and whose accessors are declared inline rather than via __get and __set magic methods that hide behavior and break static analysis.

final readonly class UserRow
{
    public function __construct(
        public string $id,
        public string $email,
        public ?string $displayName = null,
        public \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
    ) {}
}

This is PHP 8.5 code, and it is worth pausing on how different it looks from even five years ago: constructor property promotion puts the declaration in the signature, the readonly keyword guarantees the object cannot be mutated after construction, and the whole class is eight lines that a static analyzer like PHPStan can fully understand. Property hooks, a separate 8.4 feature, let you attach a get or set body to any non-readonly property when you need validation or normalization without breaking the public API into getX and setX methods, which is the pattern you reach for on mutable value objects where readonly does not fit.

The query builder is fluent and schema-aware, meaning it knows which columns exist and pushes predicate logic down to the native index instead of fetching rows and filtering in the client:

$users = $client->query('users')
    ->where('email', '=', $incomingEmail)
    ->orderBy('createdAt', 'DESC')
    ->limit(10)
    ->fetchAll(UserRow::class);

The where clause here does not pull every row and filter in PHP; it compiles to an index lookup inside MongrelDB, and the client receives only the matching rows, which is the same behavior the Rust and TypeScript clients get, because the query builder speaks the same protocol and asks for the same pushdown.

Idempotent batch transactions

Batch transactions are idempotent, which is the property that matters when you are writing to a database over HTTP from a request handler that might time out, retry, or be served by two different workers that both think they are the primary. An idempotent transaction can be retried safely, because re-running it produces the same result as running it once, and the client attaches an idempotency key to every batch so the server can deduplicate on receipt.

$result = $client->batch()
    ->upsert('users', ['id' => 'ABC123', 'email' => 'a@b.com'])
    ->upsert('users', ['id' => 'DEF456', 'email' => 'c@d.com'])
    ->key('newsletter-import-2026-07-23-batch-1')
    ->execute();

The key call is the idempotency guard: if the HTTP request times out and the caller retries, the server sees the same key, recognizes it has already applied the batch, and returns the original result instead of applying it twice. This is the same pattern Stripe uses for payment retries, and it is the correct pattern for any write over an unreliable transport, which HTTP from a PHP-FPM worker to a database server absolutely is.

Full SQL, users, roles, stored procedures

The client exposes the full SQL surface through a parameterized query method, because not every operation fits the fluent builder, and falling back to raw SQL should not mean falling back to string concatenation:

$rows = $client->sql(
    'SELECT id, email FROM users WHERE created_at > ? ORDER BY email',
    [$cutoffDate],
)->fetchAll(UserRow::class);

where $cutoffDate is a \DateTimeInterface the caller builds before the call, and parameter binding is the only supported path for user input, because falling back to raw SQL should not mean falling back to string concatenation, and the client will reject a query that attempts to interpolate a variable into the SQL string at the PHP level, because the cost of a SQL injection in 2026 is the same as it was in 2008 when we were all doing it wrong, and the fix is the same: bound parameters, always, no exceptions, no “just this once.”

User and role management, credential rotation, and stored procedure calls are all first-class methods on the client object, which means a PHP application can provision its own database users, grant scoped roles, and invoke stored procedures without reaching for a separate admin tool, because the expectation that a database client covers the administrative surface is correct and the client honors it.

The modern PHP that makes this possible

None of the above requires a single line of C, and that is the point worth dwelling on. PHP 8.5, released in November 2025 and now at patch release 8.5.8 in July 2026, added the pipe operator (|>) for chaining transformations, the clone with syntax for immutable updates, and a proper RFC 3986 / WHATWG URI API that replaces the parse_url function I have been cursing at since the register_globals era.

// Pipe operator: left-to-right function composition
$emails = $client->query('users')
    ->fetchAll(UserRow::class)
    |> fn(array $rows) => array_map(fn(UserRow $u) => $u->email, $rows)
    |> fn(array $rows) => array_filter($rows, fn(?string $e) => $e !== null);
// Clone with: immutable update without reflection hacks
$updated = clone($existingUser, displayName: 'New Name');

The pipe operator lets you compose transforms the way you would in Elixir or F#, left to right, without nesting function calls inside function calls, and clone with lets you produce a modified copy of a readonly object without the get_object_vars and reflection gymnastics that every PHP developer has written at least once and regretted every time.

Property hooks, introduced in 8.4, let you declare getters and setters inline with the property itself, which means the days of writing a forty-line class with seven private properties, seven getX methods, and seven setX methods are genuinely over, and a class that used to be a screen of boilerplate collapses to a constructor signature.

What you give up

The honest tradeoff is that a pure-HTTP PHP client will never match the raw throughput of a C extension for high-frequency, small-query workloads, and if your application’s pattern is thousands of single-row lookups per request, this client will cost you measurable serialization overhead on every one of them. The right answer for that pattern is not a faster client, it is fewer queries, but the client will not fix the workload for you.

The second tradeoff is that targeting PHP 8.5 means the client will not run on the long tail of PHP 7.x and 8.0-8.4 installations that still exist in the wild, particularly on shared hosting that updates on a geological timescale, but the features that make the client worth using, the pipe operator, clone with, property hooks, are all 8.4 or 8.5 features, and supporting older PHP means either writing two clients or writing one client that cannot use the language’s best features, and we chose the former constraint over the latter compromise.

The modern equivalent of all of this is that PHP in 2026 is a genuinely good language for writing typed, fluent, statically-analyzable code, and the reason most people do not know this is that the last time they wrote PHP was 2012, the language was genuinely worse then, and the reputation stuck. MongrelDB-PHP exists because the reputation is wrong, and the code is the proof.