Skip to content

Transactions & Locking

Use transactions and row-level locks when writes must be consistent across multiple queries.

$entityManager->transactional(function (EntityManager $em) use ($entity) {
$em->persist($entity);
$em->flush();
return $entity;
});

The wrapper commits when the callback returns and rolls back when the callback throws.

  • beginTransaction() starts a transaction.
  • commit() flushes and commits.
  • rollback() rolls back pending database work.

Manual control is useful when a command needs to demonstrate intermediate failure states or lock behavior.

Use lock() on the query builder for SELECT ... FOR UPDATE:

$stock = $entityManager
->createQueryBuilder(StockLock::class)
->where('product_id', $productId)
->lock()
->getSingleResult();

An inventory-decrement flow locks stock rows before decrementing — lock() issues SELECT ... FOR UPDATE, so the row is held until the surrounding transaction commits or rolls back, and any other transaction trying to read or lock the same row blocks until then. That is what prevents two concurrent decrements from both reading the same starting quantity and overselling stock:

$entityManager->transactional(function (EntityManager $em) use ($productId, $qty) {
$stock = $em
->createQueryBuilder(StockLock::class)
->where('product_id', $productId)
->lock()
->getSingleResult();
if ($stock->quantity < $qty) {
throw new InsufficientStockException($productId);
}
$stock->quantity -= $qty;
$em->persist($stock);
$em->flush();
});
  • Calling lock() outside an active transaction raises a transaction-required error.
  • Acquire multiple locks in a deterministic order to reduce deadlock risk.
  • Keep transaction callbacks small; long-running work should happen before or after the transaction where possible.

Pessimistic locking (SELECT ... FOR UPDATE) blocks other writers for the duration of a transaction. Contrast with Optimistic Locking, which never locks and instead detects conflicts at write time.