Every time a database client requires a C extension, a deployment story dies, because shared hosts disable phpize, CI images mismatch the ABI, and a developer somewhere is reading a PECL error at 11 PM instead of shipping a feature. That is the entire reason visorcraft/mongreldb-php is pure PHP, and the only way pure PHP works as a strategy is if the language gives you enough concrete features that you do not need to reach for native code to build a database client that feels modern. PHP 8.4 is the floor we ship on today, and PHP 8.5, released in November 2025, is the version that removes several of the remaining workarounds. This post is the tour of the features we use from 8.4, followed by the ones we are now adopting from 8.5, because the difference between a language version and a product decision is smaller than people pretend.

Asymmetric visibility and the shape of the client

The Database class is the front door of the client, and it carries internal state that callers should read but never set: the base URL, the default transport, the current session token, and a few cached metadata objects. Before PHP 8.4, the honest choices were either public readonly properties, which expose the value for reading but still let anyone assign it during construction if the constructor is inside the class, or a pile of private properties with boilerplate getters that exist only to say “this is not for you.” Asymmetric visibility fixes this by letting the property be public private(set), which means the world can read it and only the class can write it after construction, so internal mutation in methods like withToken still works while external callers are frozen out. This is exactly what a Database object needs.

final class Database
{
    public function __construct(
        public private(set) string $baseUrl,
        public private(set) TransportInterface $transport,
        private ?string $sessionToken = null,
    ) {}

    public function withToken(string $token): static
    {
        $clone = clone $this;
        $clone->sessionToken = $token;       // private property, fine here
        return $clone;
    }
}

This is not a cosmetic win; it is a stability win, because the public surface of the object now matches the contract we actually want, and there is no private getter a maintainer can forget to update when they rename a field. The client has a lot of value objects, Column, Table, ResultSet, QueryPlan, and asymmetric visibility keeps them honest without turning the source code into Java.

Readonly properties and the transport configuration

PHP 8.1 gave us readonly properties, PHP 8.2 added readonly classes, and by PHP 8.4 they are mature enough that we use them as the default for any object that is effectively a record. A transport configuration, for example, is a bundle of timeouts, retry counts, and header defaults that should be immutable once constructed, because a live request is already in flight and changing its timeout halfway through is a great way to get a bug nobody can reproduce. The combination of a readonly class and constructor property promotion means the entire configuration object collapses to a few lines.

readonly class TransportConfig
{
    public function __construct(
        public int $connectTimeoutMs = 5000,
        public int $requestTimeoutMs = 30000,
        public int $maxRetries = 2,
        public array $defaultHeaders = [],
    ) {}
}

Readonly classes are not a free lunch, because clone becomes awkward and you need a with method or clone plus reassignment if you want a modified copy, but for transport config that is exactly what we want: the object is a snapshot, not a living document, and the database client treats it that way.

Array find functions and query-plan navigation

PHP 8.4 adds array_find, array_find_key, array_any, and array_all, and these are the kind of functions that sound trivial until you write the code that used to do the same thing with a loop and a flag. We hit them constantly when walking query-plan metadata, because a plan node is a nested array of operators, and we need to know whether any operator touches a vector index, or whether all operators are point lookups, or which node is the first one that references a JSON column. The old way was array_filter(...)[0] ?? null for find, and a hand-rolled foreach with return false for any/all, which is fine once but becomes noise when it appears twenty times in a client.

// Before: array_filter gives you all matches, then you take the first.
$vectorNode = array_filter($planNodes, fn($n) => $n['op'] === 'ann')[0] ?? null;

// After: intent is explicit, no allocation of the full filtered array.
$vectorNode = array_find($planNodes, fn($n) => $n['op'] === 'ann');

$hasVectorScan = array_any($planNodes, fn($n) => $n['op'] === 'ann');
$allPointLookups = array_all($planNodes, fn($n) => $n['op'] === 'point');

The performance difference is small for most client workloads, but the readability difference is large, and a readable client is one that gets more contributions and fewer misuses.

Property hooks and lazy hydration

PHP 8.4 introduces property hooks, which let you attach get and set logic directly to a property declaration without writing a backing field and a separate accessor. We use them for lazy hydration on the ResultSet rows, where each row is a thin wrapper over a raw JSON array until the caller actually asks for a typed value. The hook is not magic; it is just a place to put the conversion logic so that the object remains a plain object while still doing the work on demand.

final class Row
{
    public function __construct(private array $data) {}

    public string $name {
        get => $this->data['name'] ?? ''
    }

    public int $id {
        get => (int) ($this->data['id'] ?? 0)
    }

    public object $document {
        get => json_decode($this->data['document'] ?? '{}')
    }
}

The tradeoff is that property hooks can hide work, and a getter that does JSON decoding on every access is a trap if you call it in a loop. We keep the hooks thin and cache the heavier conversions in the constructor, so the hook is mostly a type-safe doorway, not a compute sink.

ext-curl in PHP 8.4: the HTTP foundation

The default transport is cURL, because cURL is the only HTTP client that is present on enough hosts that we can assume it exists, and PHP 8.0 gave us the OO CurlHandle and typed exceptions we use in the wrapper, with PHP 8.4 refinements to default encoding behavior keeping the implementation small. The client detects cURL at runtime, falls back to streams if it is missing, and lets you inject a PSR-18 or Symfony HttpClient adapter if you want something else, but the cURL path is the one we optimize for because it is the one that exists everywhere.

final class CurlTransport implements TransportInterface
{
    private \CurlHandle $handle;

    public function __construct(private TransportConfig $config)
    {
        $this->handle = curl_init();
        curl_setopt($this->handle, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($this->handle, CURLOPT_FOLLOWLOCATION, false);
        curl_setopt($this->handle, CURLOPT_CONNECTTIMEOUT_MS, $config->connectTimeoutMs);
        curl_setopt($this->handle, CURLOPT_TIMEOUT_MS, $config->requestTimeoutMs);
    }

    public function request(string $method, string $url, string $body = ''): Response
    {
        curl_setopt($this->handle, CURLOPT_CUSTOMREQUEST, $method);
        curl_setopt($this->handle, CURLOPT_URL, $url);
        curl_setopt($this->handle, CURLOPT_POSTFIELDS, $body);

        $raw = curl_exec($this->handle);
        if ($raw === false) {
            throw new ConnectionException(curl_error($this->handle));
        }

        return Response::fromCurl($this->handle, $raw);
    }
}

The cURL handle is reused for the lifetime of the transport object, which is better than creating a new handle per request, but it is not yet true connection persistence across the whole process, which is where PHP 8.5 comes in.

PHP 8.5: the pipe operator and persistent cURL sharing

PHP 8.5 shipped in November 2025 with the pipe operator, persistent cURL share handles, and clone with modifications. These are not theoretical wins for the client; they are the features that let us simplify the code we already have.

The pipe operator lets you write $value |> $this->normalize(...) |> $this->send(...) instead of nesting calls right-to-left or assigning to a temporary variable. For a client that chains encoding, validation, and request building, the pipe operator makes the middle of the call stack readable without turning it into a fluent builder.

// PHP 8.5
$response = $requestBody
    |> json_encode(...)
    |> $this->transport->request('POST', $this->baseUrl . '/sql', ...)
    |> Response::fromRaw(...);

Persistent cURL share handles are the bigger deal. In PHP 8.4, the CurlTransport reuses a single CurlHandle for the lifetime of the transport object, but each transport object still starts a fresh TCP handshake. PHP 8.5 lets share handles persist across multiple PHP requests, which means an FPM or FrankenPHP worker can keep a warm connection to the database server instead of reopening it. We are wiring this into the transport layer so that the same CurlTransport can attach to a process-wide share handle when the runtime offers one, and the fallback for PHP 8.4 stays exactly the same.

clone with modifications is the last piece. Readonly classes are great for value objects, but changing one field used to mean either a constructor spread with get_object_vars or a hand-written with method. PHP 8.5’s clone($this, ['field' => $value]) collapses that to a single expression, so the withAlpha style of method becomes small enough that we no longer need helper traits to keep it readable.

// PHP 8.5
public function withAlpha(int $alpha): self
{
    return clone($this, ['alpha' => $alpha]);
}

The backward-compatibility tradeoff

The client currently supports PHP 8.4 as its minimum, and PHP 8.5 is now stable and widely available in the distributions we track. Runtime features like persistent cURL share handles can be guarded with function_exists and curl_share_init, so the same package can run faster on PHP 8.5 without breaking PHP 8.4, but syntax features like the pipe operator cannot be runtime-guarded. That means the pipe operator and clone with modifications will arrive as part of a major version bump that raises the minimum to PHP 8.5, while the cURL sharing lands as a backward-compatible performance improvement for anyone already on 8.5.

The modern equivalent

In 2010, a PHP database client would have been a thin wrapper around mysql_connect and the main challenge was escaping strings correctly. In 2026, the challenge is building a client that is safe, typed, and deployable without native code, and PHP 8.4 is the first version where that feels natural rather than defensive. PHP 8.5 is now the version that removes the remaining reasons a database client would need to touch anything outside Composer, not by adding one big feature, but by making the small workarounds unnecessary. That is the goal, and the language is finally moving in the same direction.