Error handling is where a database client either respects your time or wastes it, because the moment something goes wrong in production you are not reading documentation, you are reading a stack trace, and the difference between “the server said 409 because op 3 of your batch violated a unique key” and “DatabaseException: something failed” is the difference between a two-minute fix and an hour of log spelunking. The MongrelDB PHP client speaks HTTP to the daemon, and HTTP already has a perfectly good vocabulary for failure, so rather than inventing a parallel taxonomy of database-flavored error codes, the client maps the status codes it receives onto a small set of typed exceptions, and your catch blocks end up reading like the failure modes they actually handle.
One base class, five shapes of failure
Everything extends MongrelDBException, which extends \Exception, so a single catch at the boundary still works when you do not care about the specifics, and that is the entire hierarchy:
MongrelDBException
├── ConnectionException // daemon unreachable, network error
├── AuthException // 401 Unauthorized, 403 Forbidden
├── NotFoundException // 404 Not Found
├── ConstraintException // 409 Conflict
└── QueryException // 400 Bad Request, 500 Internal Server Error
The mapping lives in one place, a match on the response status inside the client, and it is deliberately boring; 401 and 403 both become AuthException even though the fixes differ, a 401 means your credentials were rejected while a 403 means you authenticated fine and simply lack the grant, because from the caller’s side both are the same category of problem, an auth-layer failure you cannot retry past, and the message tells you which half of it you are looking at. Where the mapping gets opinionated is 404, which the client detects two ways: a 404 status maps to NotFoundException directly, and on top of that the client inspects the parsed error envelope for a not found: message prefix regardless of status, because “that table does not exist” deserves its own type even when the server phrases it inside a broader error envelope.
The one that carries real weight: ConstraintException
Most of the hierarchy is just names, but ConstraintException earns its existence by carrying two extra properties, errorCode and opIndex, and those two fields are what turn a batch failure from a mystery into a diff:
use Visorcraft\MongrelDB\Exceptions\ConstraintException;
try {
$tx = $db->beginTransaction();
$tx->insert('users', ['email' => 'ada@example.com', 'name' => 'Ada']);
$tx->insert('users', ['email' => 'grace@example.com', 'name' => 'Grace']);
$tx->insert('users', ['email' => 'ada@example.com', 'name' => 'Ada again']);
$tx->commit();
} catch (ConstraintException $e) {
// $e->errorCode === 'UNIQUE_VIOLATION'
// $e->opIndex === 2
// the whole batch rolled back atomically
}
The errorCode is the server’s own string code, UNIQUE_VIOLATION, FK_VIOLATION, CHECK_VIOLATION, TRIGGER_VALIDATION, or the generic CONFLICT, and the opIndex tells you which operation inside the atomic batch tripped the constraint, zero-based, so when a forty-operation transaction fails you know exactly which insert to look at instead of bisecting your own code. Because the engine evaluates constraints at commit time and the batch is atomic, a ConstraintException also carries a guarantee you can build on: nothing partially applied, no cleanup pass, the database is exactly where it was before commit() was called, which is the property that makes retrying safe once you have fixed the offending row.
Why status-shaped exceptions beat a single DatabaseException
The old PHP database extensions trained a generation of us to check return values, mysql_query returned false and you called mysql_error() and hoped the string was parseable, and the PDO era improved that to a single exception class with a SQLSTATE code stuffed in getCode(), which meant every serious codebase grew its own switch statement mapping 23000 to “probably a duplicate key.” The shape of the failure was always there, it was just encoded in a place the type system could not see, so the compiler could not help you and your IDE could not autocomplete the recovery path. Putting the shape in the class name instead means catch (AuthException) is a complete sentence, it means a static analyzer with unchecked-exception tracking enabled can tell you that you handle constraint violations but not connection failures, and it means the retry policy for a transient ConnectionException never accidentally swallows a permanent QueryException, because those are different types and PHP’s catch semantics keep them apart for free.
There is a tradeoff worth naming, and it is that the hierarchy is shallow on purpose; the client does not try to give every server error code its own class, because a deep hierarchy is its own kind of failure, the kind where you version your exceptions and break everyone’s catch blocks on a minor release. Five leaves and one rich class is the line we picked, the wire stays the source of truth through errorCode for anything finer-grained, and if the daemon ever grows a genuinely new failure mode that callers must handle differently, that is a deliberate API change and not an accident of a new status string. That is the whole design: HTTP already classified the error, the client just refuses to throw the classification away on the way into PHP.