A database client that speaks HTTP is mostly a translation layer between your language and a daemon, and the part that actually moves bytes is the part everyone ignores until it breaks somewhere you do not control, like a shared host without ext-curl, or a test suite that should not open real sockets, or a platform team that routes every outbound call through a proxy with its own auth. The MongrelDB PHP client assumes that day comes, so instead of scattering curl_exec calls through the codebase it funnels every request through one small interface, and that decision is what makes the awkward environments boring.
The seam is one method
The whole contract is four parameters in, one value object out:
interface TransportInterface
{
/**
* @param array<string,string> $headers
* @throws ConnectionException On network errors
*/
public function request(string $method, string $url, array $headers = [], ?string $body = null): Response;
}
The Response is a final readonly value object carrying status, body, and lowercase-keyed headers, with json() and isSuccessful() helpers, and that is genuinely the whole seam; the transport owns connection concerns like timeouts, TLS, pooling, and redirect policy, while everything above it, the status-code-to-exception mapping that turns a 401 into AuthException and a 409 into ConstraintException, lives in the client and never sees cURL at all.
cURL by default, streams when you have to
The default is CurlTransport, which keeps a per-request handle pool keyed by host so sequential calls reuse warm connections, the right pattern here because a database client’s workload is sequential rather than concurrent, so curl_multi would buy nothing, and it enforces a 256 MB response cap so a buggy or malicious server cannot exhaust your memory, set advisory through CURLOPT_MAXFILESIZE against the Content-Length header and then enforced authoritatively by counting the bytes that actually arrived, and on PHP 8.5 it can opt into persistent share handles, which the previous article covered in detail. When ext-curl simply is not there, which still happens on stripped-down shared hosting builds in 2026, you drop to StreamTransport, the fallback built on PHP’s native stream wrappers:
use Visorcraft\MongrelDB\MongrelDB;
use Visorcraft\MongrelDB\Transport\StreamTransport;
$client = new MongrelDB(
'http://127.0.0.1:8453',
token: 'secret',
transport: new StreamTransport(timeout: 10),
);
The stream fallback is honest about its limits: no keep-alive, no connection pooling, a fresh TCP setup per call, so latency climbs on chatty workloads, but the API your application code sees is identical, which is the entire point of the seam. One rule both shipped transports share is worth stating plainly, neither follows redirects, because a redirect target that is not your daemon would receive your Authorization header on a platter, and a transport that silently follows a 302 is a credential leak wearing a convenience feature’s clothes.
Bringing your own: tests, proxies, PSR-18
The case that pays for the interface immediately is testing, because a fake that returns canned responses is twenty lines and turns your whole data layer into something you can exercise without a daemon:
use Visorcraft\MongrelDB\Transport\Response;
use Visorcraft\MongrelDB\Transport\TransportInterface;
final class FakeTransport implements TransportInterface
{
/** @var list<Response> */
private array $queue = [];
/** @var list<array{string, string, array, ?string}> */
public array $requests = [];
public function push(Response $response): void
{
$this->queue[] = $response;
}
public function request(string $method, string $url, array $headers = [], ?string $body = null): Response
{
$this->requests[] = [$method, $url, $headers, $body];
return array_shift($this->queue)
?? new Response(500, '{"error":"no queued response"}');
}
}
Push a 401 onto the queue and assert your code surfaces AuthException, push a 409 and assert the constraint path fires, and inspect $requests to verify the idempotency key header actually went out, all with zero sockets. The second case is the enterprise-shaped one, where outbound HTTP must pass through a proxy or carry credentials your application never sees, and there the right move is a PSR-18 bridge over whatever client the platform already standardized on:
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Visorcraft\MongrelDB\Exceptions\ConnectionException;
use Visorcraft\MongrelDB\Transport\Response;
use Visorcraft\MongrelDB\Transport\TransportInterface;
final class Psr18Transport implements TransportInterface
{
public function __construct(
private readonly ClientInterface $http,
private readonly RequestFactoryInterface $requestFactory,
private readonly StreamFactoryInterface $streamFactory,
) {}
public function request(string $method, string $url, array $headers = [], ?string $body = null): Response
{
$request = $this->requestFactory->createRequest($method, $url);
foreach ($headers as $name => $value) {
$request = $request->withHeader($name, $value);
}
if ($body !== null) {
$request = $request->withBody($this->streamFactory->createStream($body));
}
try {
$response = $this->http->sendRequest($request);
} catch (ClientExceptionInterface $e) {
throw new ConnectionException($e->getMessage(), previous: $e);
}
$responseHeaders = [];
foreach ($response->getHeaders() as $name => $values) {
$responseHeaders[strtolower($name)] = implode(', ', $values);
}
return new Response($response->getStatusCode(), (string) $response->getBody(), $responseHeaders);
}
}
Guzzle, Symfony HttpClient, or anything else with a PSR-18 front drops in through this shape, and the wiring stays a one-line change at construction. One caveat before you paste it into production: PSR-18 has no standard timeout API, so timeouts and retry policy live on the concrete client you inject, and if you leave Guzzle or Symfony HttpClient at its defaults a dead upstream will hang your request far longer than the thirty seconds the shipped transports give you.
use Visorcraft\MongrelDB\Database;
use Visorcraft\MongrelDB\MongrelDB;
$client = new MongrelDB('http://127.0.0.1:8453', token: 'secret', transport: $psr18);
$db = new Database(client: $client);
What belongs in the seam
The discipline that keeps this interface small is knowing what not to put in it, so a custom transport should return the raw status and body and let the client map errors, it should throw ConnectionException only when the network itself failed, it should refuse redirects rather than follow them, and it should never grow methods for queries or transactions, because the moment transport code learns about SQL the seam has leaked and you are back to curl_exec in the business logic with extra steps. We used to reach for a full HTTP abstraction library just to fake a database call in a unit test, which is how a three-method mock turned into a weekend; the modern equivalent is an interface narrow enough that the fake is smaller than the test that uses it, and that is the shape worth copying.