Transactions, rollback and commit callbacks
Transactions
$db->begin();
try {
$db->insert('orders', $orderValues);
$db->insert('order_items', $itemValues);
$db->commit();
} catch (Throwable $exp) {
$db->rollback();
throw $exp;
}
begin(): starts a transaction.commit(): saves the changes.rollback(): throws the changes away.inTransaction(): returnstruewhile a transaction is open. The flag is shared by every adapter in the process, so with several connections it tells you that some connection has a transaction. For the state of one connection, useisConnectionInTransaction()(below).
Code that may run inside someone else's transaction checks inTransaction()
first and leaves begin()/commit() to the code that started it.
Rollback callbacks
A rollback undoes only what is in the database. Some work done inside a transaction lives outside it: a file moved to the upload folder, a cache entry, a message sent to another system. When the transaction is rolled back, that work stays behind, and the database no longer has a record that matches it.
A rollback callback is the undo step for such work. You register it while the
transaction is open, and it runs only if that transaction is really rolled
back. It does not matter who calls rollback(): your code, a caller several
layers up, or a store action in the framework.
$db->begin();
$db->insert('documents', $values);
$filePath = $uploadDir.$fileName;
$upload->moveTo($filePath);
$removeFile = function () use ($filePath) {
if (is_file($filePath)) {
unlink($filePath);
}
};
$db->onRollback($removeFile);
// ... more work that may still fail
$db->commit();
If anything after moveTo() fails and the transaction is rolled back, the
moved file is removed. If the transaction is committed, the callback is
forgotten and the file stays.
The methods come from the ITransactionCallbacks interface. The PDO and
WordPress adapters implement it, and so does every DataAccessObject, which
passes the calls to its adapter:
| Method | What it does |
|---|---|
onRollback(callable $callback): void |
Registers an undo step for the open transaction of this connection. Throws DatabaseException if this connection has no transaction. |
onCommit(callable $callback): void |
Registers a finish step, run only after this connection's transaction is committed. Throws DatabaseException if this connection has no transaction. |
isConnectionInTransaction(): bool |
Returns true if this connection has an open transaction. PDO connections are asked directly; others fall back to the shared inTransaction() flag. |
getRollbackCallbackErrors(): array |
Returns the failures (Throwable[]) of the rollback callbacks of the transaction that just ended. Empty if it was committed. |
getCommitCallbackErrors(): array |
Returns the failures (Throwable[]) of the commit callbacks of the transaction that just ended. Empty if it was rolled back. |
Both accessors describe only the transaction that just ended: they are cleared when it ends and again when the next one begins, so a failure is never read as another transaction's.
Check for the interface before using it, because other IDataAccessObject
implementations may not support it:
if ($db instanceof ITransactionCallbacks && $db->isConnectionInTransaction()) {
$db->onRollback($removeFile);
}
How callbacks run
- Only on a real rollback.
commit()drops the callbacks without running them.begin()also starts with an empty list. - Once. A later rollback of a new transaction does not run callbacks of an earlier one.
- Newest first. Undo steps unwind like a stack: if you create a directory and then a file inside it, the file is removed before the directory.
- After the rollback. The database changes are already gone when a callback runs, so a callback must not write to the database.
- Per connection. Callbacks belong to the storage connection (the
PDOorwpdbobject) they were registered on. Adapters andDataAccessObjects that share that connection share its callbacks. A rollback on another connection never runs them.
When a callback fails
rollback() is usually called while you are handling the error that caused
it. That error must reach your caller, so rollback() never throws a
callback's exception:
- the other callbacks still run;
- each failure is written to the PHP error log;
- the failures are kept, and you can read them with
getRollbackCallbackErrors()until the next transaction begins.
} catch (Throwable $exp) {
$db->rollback();
foreach ($db->getRollbackCallbackErrors() as $cleanupError) {
$logger->warning('Cleanup failed: '.$cleanupError->getMessage());
}
throw $exp;
}
Commit callbacks
Some side effects need both halves: something to undo if the transaction is rolled back, and something to finish once it is committed. Replacing a file is the usual case. You cannot overwrite the old file before you know the save will succeed, so you keep a backup, restore it on rollback, and delete it on commit:
$db->begin();
$db->update('documents', $values, $search);
$backupPath = $filePath.'.backup';
rename($filePath, $backupPath);
$upload->moveTo($filePath);
$restoreOldFile = function () use ($filePath, $backupPath) {
rename($backupPath, $filePath);
};
$deleteBackup = function () use ($backupPath) {
if (is_file($backupPath)) {
unlink($backupPath);
}
};
$db->onRollback($restoreOldFile);
$db->onCommit($deleteBackup);
$db->commit();
Commit callbacks follow the same rules as rollback callbacks, with these differences:
- Only on a real commit.
rollback()drops them without running them. With nested code, only the commit of the outermost transaction counts. - In the order they were added. Finish steps run forward, not like a stack.
- After the commit. The data is saved when a callback runs.
commit()never throws a callback's exception. The data is already committed, so an exception would wrongly tell the caller the save failed. Failures go to the PHP error log and togetCommitCallbackErrors().
Adapter support
| Adapter | Rollback and commit callbacks |
|---|---|
ObjectPDOAdapter (MySQL, PostgreSQL, MSSQL, SQLite) |
Yes |
ObjectWPDBAdapter |
Yes |
ObjectCassandraAdapter |
No: it has no transactions, so onRollback() and onCommit() throw DatabaseException |
ObjectHandlerSocketAdapter |
No: it has no transactions |
In the Festi Framework
Store actions use this to fire Store::EVENT_ROLLBACK. FileField uses both
kinds of callback: it removes files uploaded by a save that was rolled back, and
when an upload replaces an existing file, it restores the old file on rollback
and deletes the backup on commit. See the framework's
docs/DGS/Events.md.