Transactions#

Introduction#

DBLayer supports manual and closure transactions, including nested transaction levels where savepoints are available for the active driver.

Closure Transaction#

DB::transaction(function (): void {
    DB::table('accounts')->where('id', '=', 1)->update(['balance' => 900]);
    DB::table('accounts')->where('id', '=', 2)->update(['balance' => 1100]);
});

If an exception is thrown inside the callback, DBLayer rolls back and rethrows.

Manual Transaction#

DB::beginTransaction();
try {
    DB::table('orders')->insert(['user_id' => 1, 'total' => 100]);
    DB::commit();
} catch (Throwable $e) {
    DB::rollBack();
    throw $e;
}

Retry Attempts#

Use retry attempts for transient failures:

DB::transaction(function (): void {
    // critical write path
}, attempts: 3);

When retries are enabled, write logic must be idempotent or safely repeatable. Prefer transaction-level retry for deadlocks/serialization failures over retrying standalone non-idempotent statements.

After-Commit Callbacks#

Use DB::afterCommit() to defer side effects until the surrounding top-level transaction commits successfully:

DB::transaction(function ($connection): void {
    $connection->table('orders')->insert(['reference' => 'order-42']);

    DB::afterCommit(function (): void {
        // Publish an event, invalidate a cache entry, or notify another system.
    });
});

Callbacks registered outside a transaction run immediately. Nested callbacks are promoted when their savepoint commits and discarded when their savepoint rolls back. If a retry attempt rolls back, its callbacks are discarded before the next attempt starts.

After-commit callbacks run only after the database commit is durable. If one fails, DBLayer still runs the remaining callbacks and rethrows the first failure; the already committed database write cannot be rolled back. Design callbacks to be idempotent and send operational failures to your retry or monitoring system.

Read-Only Transactions#

DB::readOnlyTransaction(function ($connection): int {
    return (int) $connection->scalar('select count(*) from reports');
});

Driver behavior:

  • PostgreSQL: best-effort SET TRANSACTION READ ONLY.

  • MySQL/MariaDB: best-effort SET TRANSACTION READ ONLY.

  • SQLite: safe no-op (no transaction-scoped read-only toggle).

Execution Budgets#

Combine transaction logic with query-level timeout/deadline wrappers:

DB::withQueryTimeout(500, function (): void {
    DB::transaction(function (): void {
        DB::select('select 1');
    });
});

Note

Timeouts and deadlines are query-execution controls, not transaction-level lock-time guarantees. Database engine behavior still applies.

Introspection#

  • DB::transactionLevel()

  • DB::transactionStats()