Any application that lives long enough changes how it authenticates to its database at least once, because ops rotates a shared token into per-user credentials, or compliance decides the traffic has to pass through a signing proxy, and the cost of that change is decided entirely by where the auth lives in your code; if credentials are smeared across a dozen call sites the rotation is a week of edits and a prayer, and if auth is one constructor argument it is a one-line diff that code review can actually read. The MongrelDB PHP client is built around that observation, so the two ways the daemon knows how to authenticate a caller map to two named arguments, and everything the daemon has never heard of goes behind a transport seam you own, which means the day your security team invents a new requirement you change one file and not the application.
Two daemon modes, two constructor shapes
The server side keeps this deliberately simple; mongreldb-server runs in one of two auth modes, a single shared bearer token for service-to-service traffic (--auth-token), or catalog users with Argon2id-hashed passwords and table-level GRANTs for multi-tenant or human-facing deployments (--auth-users), and the client mirrors those modes exactly, with no third concept invented on the PHP side for you to learn.
use Visorcraft\MongrelDB\Database;
// Bearer token, matches the daemon's --auth-token mode
$db = new Database('http://127.0.0.1:8453', token: 'my-secret-token');
// HTTP Basic, matches the daemon's --auth-users mode
$db = new Database('http://127.0.0.1:8453', username: 'admin', password: 's3cret');
Two details are worth knowing before you wire this up. If you pass both a token and a username, the token wins, because the constructor checks it first and never builds the Basic header, which saves you from the ambiguous case where a request carries two Authorization identities and nobody can tell you which one the server honored; and every credential value goes through a CR/LF check before it touches the wire, so a token with a stray newline from a badly trimmed .env file raises a QueryException in your process instead of becoming a header-injection problem in someone else’s, which is the cheapest possible place to kill that bug.
What a failed login looks like
Auth failures are not generic errors in this client; a 401 or 403 from the daemon maps to AuthException, which sits in the same hierarchy as ConnectionException for network trouble and QueryException as the general catch-all, thrown by the engine for rejected queries and by the client itself for checks like the CR/LF guard above, and that split is what lets a catch block answer the only question that matters at 3am, namely whether the credentials are wrong or the daemon is simply down.
use Visorcraft\MongrelDB\Exceptions;
try {
$db->put('orders', [1 => 1, 2 => 'Alice', 3 => 99.50]);
} catch (Exceptions\AuthException $e) {
// 401 or 403: the token is wrong, expired, or the user lacks the grant.
echo "Not authorized: {$e->getMessage()}\n";
} catch (Exceptions\ConnectionException $e) {
// The daemon never answered at all; this is an ops problem, not an auth one.
echo "Cannot reach server: {$e->getMessage()}\n";
}
The reason to care about the distinction is that the retry policy is different for each branch, because a 401 will fail identically forever no matter how many times you replay it, while a connection failure is exactly the case where the client’s idempotent commit makes a retry safe, and a single catch-all DatabaseException would force you to parse message strings to tell those apart, which is the kind of code that breaks quietly on the next release.
The seam: TransportInterface
Constructor credentials are baked into the default headers at build time, which is the right behavior for a static token but the wrong layer for anything that rotates, and that is why the low-level client takes a TransportInterface, one method and no more, so the exotic cases live in code you write rather than in options the library has to grow.
namespace Visorcraft\MongrelDB\Transport;
interface TransportInterface
{
/** @param array<string,string> $headers */
public function request(string $method, string $url, array $headers = [], ?string $body = null): Response;
}
Per-request token vending is the canonical example, where a central service hands you short-lived credentials and the header has to be computed on every call; you wrap the default cURL transport, stamp the fresh header on the way out, and hand the whole thing to the client, after which the rest of the application, including the Database wrapper with its typed CRUD and transaction calls, has no idea anything changed.
use Visorcraft\MongrelDB\{Database, MongrelDB};
use Visorcraft\MongrelDB\Transport\{TransportInterface, CurlTransport, Response};
final class VendedTokenTransport implements TransportInterface
{
public function __construct(
private readonly CurlTransport $inner,
private readonly TokenVender $vender, // your credential-vending service
) {}
public function request(string $method, string $url, array $headers = [], ?string $body = null): Response
{
$headers['Authorization'] = 'Bearer ' . $this->vender->freshToken();
return $this->inner->request($method, $url, $headers, $body);
}
}
$client = new MongrelDB('http://127.0.0.1:8453', transport: new VendedTokenTransport(new CurlTransport(), $vender));
$db = new Database(client: $client);
The same seam covers HMAC-signing proxies, mTLS terminators that want client certificates attached per connection, and the boring but essential case of a test fake that returns canned Response objects without a daemon running, because a one-method interface is something you can stub in ten lines and trust, while a fifteen-method interface is something you mock with a framework and hope.
The honest tradeoff
None of this makes the wire itself safe, and it is worth saying plainly: the daemon speaks plain HTTP, so Basic credentials and bearer tokens are both cleartext the moment traffic leaves the loopback interface, and the right answer for anything crossing a real network is TLS terminated in front of the server, by a reverse proxy or a service mesh, not a PHP library pretending it can fix transport security from the client side. The other tradeoff is between the two daemon modes themselves, since a shared token is one secret with no per-table permissions behind it, while --auth-users costs you user management but buys storage-layer GRANT enforcement that middleware cannot fake. Back in the mysql_* days the auth mechanism and the credentials were fused into a global connect call and swapping either meant touching every file, and the modern equivalent of fixing that turned out to be nothing glamorous, just named arguments for the common cases and a one-method interface for the rest, which is exactly the amount of abstraction the problem deserves.