Skip to main content

Transactions

transactions() returns the connection's TransactionManager. There is one per connection, created on first request, and it tracks the nesting level.

interface TransactionManager
{
public function begin(): void;

public function commit(): void;

public function rollback(): void;

public function inTransaction(): bool;

public function level(): int;

public function run(callable $callback): mixed;
}

Running a callback

run() is the way to use a transaction. It commits when the callback returns and rolls back when it throws, rethrowing the original exception.

$connection->transactions()->run(function () use ($connection): void {
$connection->execute('INSERT INTO users (name) VALUES (?)', ['Ada']);
$connection->execute('UPDATE totals SET users = users + 1');
});

From the Database

Database::transaction() and ConnectedDatabase::transaction() wrap the same run(), and hand the callback a ConnectedDatabase so the query builder is available inside:

$database->transaction(function (ConnectedDatabase $db): void {
$db->table('users')->insert(['name' => 'Ada']);
$db->table('totals')->where('id', '=', 1)->update(['users' => 1]);
});

Everything below applies to those too, since they are the same manager.

caution

Only work that goes through the object the callback was given is inside the transaction. Reaching for a different connection — $database->table('x', 'other') — runs outside it and commits independently.

The callback's return value is passed through, so a transaction can produce a value:

$id = $connection->transactions()->run(function () use ($connection): string {
$connection->execute('INSERT INTO users (name) VALUES (?)', ['Ada']);

return $connection->lastInsertId();
});

The callback takes no arguments. It closes over the connection it needs, which is what keeps middleware from being bypassed inside a transaction.

Nesting

Nested run() calls do not open a second transaction — the outer one is still open. The inner call takes a savepoint instead, and rolls back to it if it throws.

$connection->transactions()->run(function () use ($connection): void {
$connection->execute('INSERT INTO orders (reference) VALUES (?)', [$reference]);

try {
$connection->transactions()->run(fn(): Result => $connection->execute(
'INSERT INTO order_notes (order_id, note) VALUES (?, ?)',
[$connection->lastInsertId(), $note],
));
} catch (QueryException) {
// The note was rolled back. The order is still there.
}
});

level() reports the depth: 0 outside a transaction, 1 inside the outermost, and one more per nested call. inTransaction() is the same information as a boolean.

caution

The level is this manager's own count, not the database's. Issuing BEGIN, COMMIT, or SAVEPOINT as a query through execute() changes the database's state without changing the count, and the two disagree from then on. Use the manager for transaction control.

Driving it manually

begin(), commit(), and rollback() are there for control that does not fit a callback — a transaction that spans several method calls, or one that is committed by a different object than the one that opened it.

$transactions = $connection->transactions();

$transactions->begin();

try {
$connection->execute('INSERT INTO users (name) VALUES (?)', ['Ada']);
$transactions->commit();
} catch (Throwable $exception) {
$transactions->rollback();

throw $exception;
}

At level 0, begin() starts a real transaction; above it, it creates a savepoint. commit() and rollback() mirror that: at level 1 they commit or roll back the transaction, above it they release or roll back to the savepoint.

Committing or rolling back with no active transaction throws a TransactionException rather than passing the call to the driver.

Prefer run() where you can. The manual form is a finally away from leaking an open transaction for the rest of the request.

Rollback failures

If the rollback itself fails — the connection dropped, which is often why the callback threw — the rollback's exception never replaces the exception that caused it. That would swap the diagnosis for a symptom. It is recorded under rollback_failure in the context of the original exception instead, when that exception is a DatabaseException.

try {
$connection->transactions()->run($work);
} catch (DatabaseException $exception) {
$context = $exception->getContext();
// ['connection' => 'primary', ..., 'rollback_failure' => 'MySQL server has gone away']
}

The nesting level is restored to what it was before the failed run() either way, so a failed rollback does not leave the manager convinced it is still several levels deep.

Grammars

Savepoint SQL is not standard, so a driver is constructed with the TransactionGrammar its database understands.

interface TransactionGrammar
{
public function savepointName(int $level): string;

public function createSavepoint(string $name): string;

public function releaseSavepoint(string $name): ?string;

public function rollbackToSavepoint(string $name): string;
}
GrammarForSavepoints
StandardTransactionGrammarMySQL, PostgreSQL, SQLiteSAVEPOINT, RELEASE SAVEPOINT, ROLLBACK TO SAVEPOINT
SqlServerTransactionGrammarSQL ServerSAVE TRANSACTION, ROLLBACK TRANSACTION

SQL Server has no way to release a savepoint, so its grammar returns null from releaseSavepoint() and the manager skips the statement — committing a nested level is bookkeeping only. The savepoint is discarded when the outermost transaction commits.

Savepoint names

A grammar names its savepoints from a SavepointPrefix and the nesting level: dirthara1, dirthara2, and so on.

use Dirthara\Database\Connection\ValueObjects\SavepointPrefix;

new SavepointPrefix(); // 'dirthara'
new SavepointPrefix('app_tx'); // 'app_tx1', 'app_tx2', ...

Change it only if something else in your system already uses savepoints named dirthara…. Because a savepoint name cannot be a bound parameter, the prefix is validated on construction:

  • It must start with a letter or underscore and contain only letters, digits, and underscores.
  • It must be at most 24 characters.

Either violation throws a TransactionException where the prefix is constructed, long before it can reach a SAVEPOINT statement.

One prefix instance can be shared by every grammar; it is readonly and holds nothing but the string.