<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Psr-18 on MongrelDB</title><link>https://www.mongreldb.com/articles/tags/psr-18/</link><description>Recent content in Psr-18 on MongrelDB</description><image><title>MongrelDB</title><url>https://www.mongreldb.com/assets/og-mongreldb.png</url><link>https://www.mongreldb.com/assets/og-mongreldb.png</link></image><generator>Hugo</generator><language>en-US</language><lastBuildDate>Mon, 17 Aug 2026 09:00:00 -0500</lastBuildDate><atom:link href="https://www.mongreldb.com/articles/tags/psr-18/index.xml" rel="self" type="application/rss+xml"/><item><title>The Transport Interface: cURL, Streams, or Your Own Adapter</title><link>https://www.mongreldb.com/articles/2026/08/the-transport-interface-curl-streams-or-your-own-adapter/</link><pubDate>Mon, 17 Aug 2026 09:00:00 -0500</pubDate><guid>https://www.mongreldb.com/articles/2026/08/the-transport-interface-curl-streams-or-your-own-adapter/</guid><description>The MongrelDB PHP client hides all of HTTP behind a one-method TransportInterface, ships cURL as the default with a stream fallback, and lets you inject a fake for tests or a PSR-18 bridge for Guzzle without touching application code.</description><content:encoded><![CDATA[<p>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 <code>curl_exec</code> calls through the codebase it funnels every request through one small interface, and that decision is what makes the awkward environments boring.</p>
<h2 id="the-seam-is-one-method">The seam is one method</h2>
<p>The whole contract is four parameters in, one value object out:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">interface</span> <span style="color:#a6e22e">TransportInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">/**
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     * @param array&lt;string,string&gt; $headers
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     * @throws ConnectionException On network errors
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">     */</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">request</span>(<span style="color:#a6e22e">string</span> $method, <span style="color:#a6e22e">string</span> $url, <span style="color:#66d9ef">array</span> $headers <span style="color:#f92672">=</span> [], <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $body <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>Response</code> is a <code>final readonly</code> value object carrying <code>status</code>, <code>body</code>, and lowercase-keyed <code>headers</code>, with <code>json()</code> and <code>isSuccessful()</code> 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 <code>AuthException</code> and a 409 into <code>ConstraintException</code>, lives in the client and never sees cURL at all.</p>
<h2 id="curl-by-default-streams-when-you-have-to">cURL by default, streams when you have to</h2>
<p>The default is <code>CurlTransport</code>, which keeps a per-request handle pool keyed by host so sequential calls reuse warm connections, the right pattern here because a database client&rsquo;s workload is sequential rather than concurrent, so <code>curl_multi</code> would buy nothing, and it enforces a 256 MB response cap so a buggy or malicious server cannot exhaust your memory, set advisory through <code>CURLOPT_MAXFILESIZE</code> 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 <code>StreamTransport</code>, the fallback built on PHP&rsquo;s native stream wrappers:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\MongrelDB</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Transport\StreamTransport</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$client <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">MongrelDB</span>(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;http://127.0.0.1:8453&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">token</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;secret&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">transport</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">StreamTransport</span>(<span style="color:#a6e22e">timeout</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">10</span>),
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>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 <code>Authorization</code> header on a platter, and a transport that silently follows a 302 is a credential leak wearing a convenience feature&rsquo;s clothes.</p>
<h2 id="bringing-your-own-tests-proxies-psr-18">Bringing your own: tests, proxies, PSR-18</h2>
<p>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:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Transport\Response</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Transport\TransportInterface</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">FakeTransport</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">TransportInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">/** @var list&lt;Response&gt; */</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">array</span> $queue <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">/** @var list&lt;array{string, string, array, ?string}&gt; */</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">array</span> $requests <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">push</span>(<span style="color:#a6e22e">Response</span> $response)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">queue</span>[] <span style="color:#f92672">=</span> $response;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">request</span>(<span style="color:#a6e22e">string</span> $method, <span style="color:#a6e22e">string</span> $url, <span style="color:#66d9ef">array</span> $headers <span style="color:#f92672">=</span> [], <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $body <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">requests</span>[] <span style="color:#f92672">=</span> [$method, $url, $headers, $body];
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">array_shift</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">queue</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">??</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Response</span>(<span style="color:#ae81ff">500</span>, <span style="color:#e6db74">&#39;{&#34;error&#34;:&#34;no queued response&#34;}&#39;</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Push a 401 onto the queue and assert your code surfaces <code>AuthException</code>, push a 409 and assert the constraint path fires, and inspect <code>$requests</code> 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:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Psr\Http\Client\ClientExceptionInterface</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Psr\Http\Client\ClientInterface</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Psr\Http\Message\RequestFactoryInterface</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Psr\Http\Message\StreamFactoryInterface</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Exceptions\ConnectionException</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Transport\Response</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Transport\TransportInterface</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Psr18Transport</span> <span style="color:#66d9ef">implements</span> <span style="color:#a6e22e">TransportInterface</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">__construct</span>(
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">ClientInterface</span> $http,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">RequestFactoryInterface</span> $requestFactory,
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#a6e22e">readonly</span> <span style="color:#a6e22e">StreamFactoryInterface</span> $streamFactory,
</span></span><span style="display:flex;"><span>    ) {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">request</span>(<span style="color:#a6e22e">string</span> $method, <span style="color:#a6e22e">string</span> $url, <span style="color:#66d9ef">array</span> $headers <span style="color:#f92672">=</span> [], <span style="color:#f92672">?</span><span style="color:#a6e22e">string</span> $body <span style="color:#f92672">=</span> <span style="color:#66d9ef">null</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Response</span>
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        $request <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">requestFactory</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createRequest</span>($method, $url);
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> ($headers <span style="color:#66d9ef">as</span> $name <span style="color:#f92672">=&gt;</span> $value) {
</span></span><span style="display:flex;"><span>            $request <span style="color:#f92672">=</span> $request<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">withHeader</span>($name, $value);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($body <span style="color:#f92672">!==</span> <span style="color:#66d9ef">null</span>) {
</span></span><span style="display:flex;"><span>            $request <span style="color:#f92672">=</span> $request<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">withBody</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">streamFactory</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">createStream</span>($body));
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>            $response <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">http</span><span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">sendRequest</span>($request);
</span></span><span style="display:flex;"><span>        } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">ClientExceptionInterface</span> $e) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ConnectionException</span>($e<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getMessage</span>(), <span style="color:#a6e22e">previous</span><span style="color:#f92672">:</span> $e);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        $responseHeaders <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">foreach</span> ($response<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getHeaders</span>() <span style="color:#66d9ef">as</span> $name <span style="color:#f92672">=&gt;</span> $values) {
</span></span><span style="display:flex;"><span>            $responseHeaders[<span style="color:#a6e22e">strtolower</span>($name)] <span style="color:#f92672">=</span> <span style="color:#a6e22e">implode</span>(<span style="color:#e6db74">&#39;, &#39;</span>, $values);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Response</span>($response<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getStatusCode</span>(), (<span style="color:#a6e22e">string</span>) $response<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">getBody</span>(), $responseHeaders);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>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.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\Database</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">use</span> <span style="color:#a6e22e">Visorcraft\MongrelDB\MongrelDB</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$client <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">MongrelDB</span>(<span style="color:#e6db74">&#39;http://127.0.0.1:8453&#39;</span>, <span style="color:#a6e22e">token</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;secret&#39;</span>, <span style="color:#a6e22e">transport</span><span style="color:#f92672">:</span> $psr18);
</span></span><span style="display:flex;"><span>$db <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Database</span>(<span style="color:#a6e22e">client</span><span style="color:#f92672">:</span> $client);
</span></span></code></pre></div><h2 id="what-belongs-in-the-seam">What belongs in the seam</h2>
<p>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 <code>ConnectionException</code> 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 <code>curl_exec</code> 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.</p>
]]></content:encoded></item></channel></rss>