A timed-out HTTP request is the nastiest failure mode a database client can have, because the timeout tells you nothing about what happened on the other side; the server may have applied your write and died on the way back with the response, and the only way to find out is to ask again, which is exactly the move that turns one order row into two. The classic fix is the transactional outbox, where you write to a local table and let a background relay push it out, but PHP’s shared-nothing request model has no background anything, so every PHP shop that has ever needed safe retries ended up reinventing the outbox in a cron job, and it is always the cron job that pages someone at 3am. The MongrelDB PHP client takes a different route: it stages writes locally, commits them as one atomic batch, and lets the storage layer dedupe the retry, so the safe-retry problem is solved by the engine instead of by your crontab.
Stage locally, commit once
The batch API is a builder that never touches the wire until you say so; beginTransaction() hands you a Transaction object holding a plain PHP array of staged operations, and every put, upsert, and deleteByPk just appends to that array and returns $this, which means a mid-request failure before commit() costs you nothing, because nothing was ever sent and rollback() is just clearing the array.
$db = new Database('http://127.0.0.1:8453');
$txn = $db->beginTransaction();
$txn->upsert('accounts', [1 => 1, 2 => 'Checking', 3 => 500.0], [3 => 500.0]);
$txn->upsert('accounts', [1 => 2, 2 => 'Savings', 3 => 5500.0], [3 => 5500.0]);
$results = $txn->commit(idempotencyKey: 'transfer-2026-08-19-0001');
That commit() call is one POST to /kit/txn carrying the whole batch, and the engine applies all of it or none of it, with unique, foreign key, and check constraints plus triggers evaluated atomically at commit time rather than row by row as each statement arrives. The two upserts above are a money transfer, and the property you actually care about is not that they are fast but that they are inseparable; there is no interleaving in which Checking has been debited and Savings has not been credited, because the batch crosses the wire as a single request and commits as a single transaction.
One detail worth knowing before you reach for put() here: put is insert-only and will raise a uniqueness violation if the primary key already exists, which is why the transfer example uses upsert with explicit update cells, and this is the kind of thing the client could have papered over with a silent merge but deliberately did not, because an accidental insert-where-you-meant-update is a bug you want thrown at you, not absorbed.
The idempotency key is the retry story
The string you pass to commit() is not a client-side nicety; it rides along in the request payload and lands in the engine’s idempotency tracking at the storage layer, so when your request times out and you retry with the same key, the batch never applies twice, because the daemon durably recorded an intent before executing anything and a receipt once the outcome was known. If the receipt landed before your connection died, the retry is answered with the original response and no re-execution, even across a server restart; if the process died in the narrow gap between commit and receipt, the daemon refuses to guess and answers the retry with a non-retryable QUERY_OUTCOME_UNKNOWN instead, leaving a tombstone on disk for an operator to verify, because auto-replaying in that gap is exactly how you charge a credit card twice. This is the same mechanism the HTTP Idempotency-Key header standardizes for APIs, which despite living in an IETF draft that never graduated to an RFC became the de-facto retry contract once Stripe popularized it, except here the dedupe survives process death instead of living in a middleware cache, and that distinction is the entire reason the outbox cron job gets to stay unwritten.
try {
$results = $txn->commit(idempotencyKey: $key);
} catch (ConnectionException $e) {
// Timeout or refused connection: the write may or may not have landed.
// commit() consumes the Transaction (a second call throws LogicException),
// so rebuild the same batch on a fresh one and retry with the same key;
// you get back either the original result or a QUERY_OUTCOME_UNKNOWN
// telling you a human needs to look, but never a double-apply.
$results = $db->beginTransaction()
->upsert('accounts', [1 => 1, 2 => 'Checking', 3 => 500.0], [3 => 500.0])
->upsert('accounts', [1 => 2, 2 => 'Savings', 3 => 5500.0], [3 => 5500.0])
->commit(idempotencyKey: $key);
}
The usual rule from payment APIs applies here: the key must be stable across retries of the same logical operation and unique across different ones, so derive it from something your domain already has, like an order number or a transfer reference, rather than from uniqid() at retry time, because a fresh random key per attempt defeats the dedupe you are asking for. There is also a quiet implementation detail working in your favor: the client flattens your cell arrays in ascending column-id order before serializing, because the server hashes the payload to match a retried request against the original, and PHP’s unordered array iteration would otherwise make two identical commits look like a key reuse with a different body, which the server correctly rejects as an error rather than a replay.
When the batch itself is the problem
A retry is only safe when the original failure was in transport; when the failure is the batch itself, retrying just fails again, and the client tells you which case you are in through the exception type. A violated constraint throws ConstraintException, which carries the engine’s errorCode and an opIndex pointing at the exact staged operation that failed, and since the commit is atomic, that one bad op rolls the whole batch back, so your catch block is deciding about the data, not about cleanup.
try {
$txn->commit(idempotencyKey: $key);
} catch (ConstraintException $e) {
// $e->errorCode names the violated constraint, $e->opIndex is the
// zero-based position of the failing op in the batch.
$txn->rollback(); // staged ops are already gone server-side; this clears locally
report_bad_row($e->opIndex, $e->errorCode);
}
What you give up
This is a batch model, not a session model, and being honest about that matters more than selling it. There is no open transaction holding locks across requests, no read-your-writes inside the batch before commit, and no interleaving of queries with staged writes the way an interactive BEGIN ... COMMIT session allows, so if your workflow genuinely needs to read a row, branch on it in PHP, and write back inside one isolation boundary, you do that with a conditional request or a stored procedure instead of pretending the builder is a session. The trade is deliberate, because an open wire transaction is a connection pinned to a daemon that might restart, a lock held across a network partition, and a retry story you have to build yourself, whereas the batch-plus-key model is stateless on the way in and idempotent on the way out, which is exactly the shape PHP’s request lifecycle wanted all along.