Practical guide

How to work with database transactions

Put database changes that must succeed or fail together in one short transaction. Keep network calls outside it.

20 minutes · Doctrine DBAL

First, the short version

One business change, one outcome

A database transaction groups several operations into one unit. Commit publishes them together; rollback reverses them on failure. Atomicity is the first property in ACID.

A transaction protects only work performed through the same database connection. It cannot roll back an HTTP request, an email, or a message sent to another service, so external I/O does not belong inside an open transaction.

Get ready

What you need

Start with the invariant you need to protect. A transaction is not a generic wrapper around an entire controller.

  • Doctrine ORM or DBAL on a database that supports transactions.
  • One specific use case, such as transferring credit or creating an order with its items.
  • A stated invariant: what must always hold after commit and what must never be stored only partially.
  • An integration database with the same engine and isolation level as production.

Steps 1 to 3

Set a short, explicit boundary

Load the required data, enforce the rules, and save changes without waiting for the network or a user.

1. Define the atomic database operation

  1. List every database write that must not remain stored only partially. These writes belong in one transaction.
  2. Validate formats and perform slow computations before opening the transaction when they do not depend on currently locked data.
  3. Inside the transaction, check rules that depend on current state again, such as available balance or reservation uniqueness.
  4. Do not stretch the boundary over the whole HTTP request. The longer a transaction runs, the longer it holds locks and the greater the conflict risk.
Official PostgreSQL transaction isolation documentation

2. Let Doctrine manage commit and rollback

  1. With ORM, use EntityManager::wrapInTransaction(). Doctrine flushes and commits on success and rolls back on an exception.
  2. With plain DBAL, use Connection::transactional(). Every query must run through the same Connection used by the use case.
  3. Do not swallow an exception inside the callback. It must leave the transaction wrapper so rollback occurs and the caller sees the failure.
  4. After an ORM error, inspect the EntityManager state. Doctrine may close it; use a new manager for further work instead of a damaged Unit of Work.
$entityManager->wrapInTransaction(static function (EntityManagerInterface $em): void { /* entity changes */ });
Official Doctrine ORM transaction documentation

3. Separate the database from external effects

  1. Do not call an HTTP API, send email, or wait for a message broker while a transaction is open. A rollback cannot reverse another system.
  2. Run a simple follow-up action only after a successful commit and accept that it can fail independently.
  3. If an event must not disappear, write it to an outbox table in the same transaction as the business change. A separate worker sends it later.
  4. Make the message consumer idempotent. Repeated delivery then cannot create a second payment or order.
Official Doctrine DBAL transaction documentation

Step 4

Test commit, rollback, and concurrency

The happy path verifies only half the behaviour. What remains after an exception and under two concurrent requests matters more.

  1. Verify a successful commit

    Run the use case and load all changed data through a new EntityManager. Together, they must satisfy the protected invariant.

    php bin/phpunit --filter TransferMoney
  2. Force a failure before commit

    Throw a test exception after the first write. Through a new database connection, verify that not even the first part of the change remains.

    php bin/phpunit --filter RollsBack
  3. Send two competing operations

    Change the same data concurrently and verify that isolation, a lock, or an optimistic version prevents an invariant violation.

If something goes wrong

Common problems

Some data remained stored after an exception

The writes probably used different connections or some code committed early. Move every atomic write under one transaction wrapper.

The transaction holds locks for too long

Move HTTP, email, file operations, and computation outside the transaction. Keep only essential database reads, checks, and writes inside.

The application reports a closed EntityManager after rollback

Do not reuse the old EntityManager after an ORM transaction fails. Let the framework reset it and start a new attempt with a clean Unit of Work.

A nested method commits before the outer use case

The application use case should own the boundary. Inner services only change data; do not treat a nested transaction as an independent commit.

Done

The database change now stays together.

The transaction has a short boundary, a clear invariant, and reliable rollback. For each new use case, first decide which database changes must commit together.

Request a call

I will call you on the next working day between 9:00 and 17:00.

You can also call me directly.

+420 605 181 728

Leave your phone number and send a callback request.

By sending, you agree to processing your data in order to handle your request.