Transaction Management — Where to Place the Consistency Boundary
What is a transaction
A transaction is a mechanism that groups multiple database operations into a single logical unit. If all operations succeed, the transaction is committed; if even one fails, it is rolled back.
The ACID properties
A transaction guarantees the following four properties (ACID).
| Property | Description | Example |
|---|---|---|
| Atomicity | Either everything succeeds or everything fails | Creating the order and saving the order lines both succeed, or both fail |
| Consistency | The database is always kept in a consistent state | The order's total amount always matches the sum of the line items |
| Isolation | Unaffected by other concurrently running transactions | Even if users A and B update stock at the same time, consistency is maintained |
| Durability | After a commit, data is retained even if a failure occurs | Data after a commit completes is not lost even in a system crash |
[Without Transaction]
1. Insert order ✓
2. Insert order_lines ✗ (ERROR!)
→ Result: only the order remains, leaving an inconsistent state with no line items
[With Transaction]
1. BEGIN TRANSACTION
2. Insert order ✓
3. Insert order_lines ✗ (ERROR!)
4. ROLLBACK
→ Result: all operations are undone, and consistency is maintained
Transaction management in Laravel
How DB::transaction() works
Laravel's DB::transaction() works as follows.
DB::transaction(function () {
// The operations inside run within a transaction
OrderModel::create([/* ... */]);
OrderLineModel::create([/* ... */]);
// An exception triggers an automatic rollback
if ($error) {
throw new Exception('Error!');
}
// Normal completion triggers an automatic commit
});
The details of the behavior:
- Automatic BEGIN:
BEGIN TRANSACTIONis executed the moment the outermostDB::transaction()is called (nested inner calls become savepoints; see below) - Rollback on exception: if an exception occurs inside the closure,
ROLLBACKhappens automatically - Commit on normal completion: if the closure completes normally,
COMMIThappens automatically - Re-throwing the exception: after a rollback, the exception is re-thrown to the caller
- Retry on deadlock: the second argument sets the number of attempts (
DB::transaction($callback, $attempts); the default is 1, meaning no retry)
// Controlling the transaction manually (not recommended)
DB::beginTransaction();
try {
OrderModel::create([/* ... */]);
OrderLineModel::create([/* ... */]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
// ↑ Using DB::transaction() automates the above
Managing beginTransaction(), commit(), and rollBack() manually makes it easy to forget exception handling, and the handling of nested transactions becomes complex. Using DB::transaction() avoids these problems.
Two approaches
There are two approaches to where the transaction is managed.
Where to put the transaction
| Case | Transaction location | Reason |
|---|---|---|
| Updating only one aggregate | Inside the Repository (Approach A) | The repository guarantees the aggregate's consistency |
| Updating multiple aggregates | Inside the UseCase (Approach B) | The UseCase guarantees consistency across multiple aggregates |
Updating a single aggregate: transaction inside the Repository
// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly DomainEventDispatcherInterface $eventDispatcher,
) {}
public function save(Order $order): void
{
// Transaction inside the Repository
DB::transaction(function () use ($order) {
$orderModel = OrderModel::findOrNew($order->id()->value());
// The identifier is assigned directly, not through $fillable (Chapter 13)
$orderModel->id = $order->id()->value();
$orderModel->fill([
'status' => $order->status()->value,
'total_amount' => $order->totalAmount()->amount(),
'shipping_prefecture' => $order->shippingAddress()->prefecture(),
'shipping_city' => $order->shippingAddress()->city(),
'shipping_street' => $order->shippingAddress()->street(),
])->save();
$this->saveOrderLines($orderModel, $order->orderLines());
});
// Waits for the outermost commit if there is one; otherwise runs immediately
$events = $order->pullDomainEvents();
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
}
}
version is deliberately absent here. Reading and writing the version is covered together in the optimistic locking section later. For the two-phase event dispatch, see the implementation in Chapter 13, "The Repository Pattern" and the place where Chapter 9, "Domain Events" calls out this chapter by name.
// app/Application/UseCase/Order/CreateOrderUseCase.php
final class CreateOrderUseCase
{
public function __construct(private readonly OrderRepositoryInterface $orderRepository) {}
public function execute(CreateOrderCommand $command): OrderId
{
$orderId = $this->orderRepository->nextIdentity();
$shippingAddress = new ShippingAddress(
$command->prefecture,
$command->city,
$command->street
);
$order = Order::create($orderId, $shippingAddress);
foreach ($command->items as $item) {
$order->addItem(
$this->orderRepository->nextLineIdentity(),
new ProductId($item['productId']),
$item['quantity'],
new Money($item['unitPrice'], 'JPY'),
);
}
// The UseCase is not aware of the transaction
// This is a new order, so use create(). The owner is passed as an argument (Chapter 13)
$this->orderRepository->create($order, new UserId($command->userId));
return $orderId;
}
}
Updating multiple aggregates: transaction inside the UseCase
Consider the case of decreasing stock when an order is confirmed.
// app/Application/UseCase/Order/ConfirmOrderUseCase.php
use App\Domain\Inventory\Exception\InventoryNotFoundException;
use App\Domain\Order\Exception\OrderNotFoundException;
final class ConfirmOrderUseCase
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly InventoryRepositoryInterface $inventoryRepository,
) {}
public function execute(ConfirmOrderCommand $command): void
{
// Because multiple aggregates are updated, manage the transaction inside the UseCase
DB::transaction(function () use ($command) {
// 1. Retrieve and update the Order aggregate
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId);
$order->confirm();
$this->orderRepository->save($order);
// 2. Update the Inventory aggregate (decrease stock)
foreach ($order->orderLines() as $line) {
$inventory = $this->inventoryRepository->findByProductId($line->productId())
?? throw new InventoryNotFoundException($line->productId());
$inventory->decrease($line->quantity());
$this->inventoryRepository->save($inventory);
}
});
}
}
Handling nested transactions
In Laravel, when DB::transaction() is nested, the savepoint feature is used.
What is a savepoint
A savepoint is a feature that marks a specific point within a transaction and lets you roll back to that point.
[Nested Transaction Flow]
DB::transaction(function () { ← Transaction Level 1: BEGIN
// operation 1
DB::transaction(function () { ← Transaction Level 2: SAVEPOINT trans2
// operation 2
}); ← normal completion (no RELEASE is issued; see below)
DB::transaction(function () { ← Transaction Level 2: SAVEPOINT trans2 (siblings share the name)
// operation 3
throw new Exception(); ← ROLLBACK TO SAVEPOINT trans2 (exception)
});
}); ← COMMIT (outermost)
The savepoint name is derived from the nesting depth, so the two calls at the same depth are both trans2. And when an inner transaction completes normally, no RELEASE SAVEPOINT is issued: Laravel simply decrements its internal transaction counter, and only the outermost level actually commits.
The actual behavior:
// Start the transaction inside the UseCase
DB::transaction(function () { // BEGIN TRANSACTION
// DB::transaction() is also called inside the Repository
$this->orderRepository->save($order); // SAVEPOINT trans2 → ... → normal completion
$this->inventoryRepository->save($inventory); // SAVEPOINT trans2 → ... → normal completion
}); // COMMIT (only the outermost actually commits)
- Only the outermost commits: a nested inner transaction is committed only when the outermost one succeeds
- An inner failure propagates to the whole: if an exception occurs in an inner transaction, it propagates outward and the whole thing is rolled back
- Safe even if the Repository opens a transaction: if you open a transaction inside the UseCase, the whole thing is treated as a single transaction
Because of this, the ConfirmOrderUseCase shown earlier works as a single transaction even though the Repository opens one of its own: the Repository's DB::transaction() becomes a savepoint, and only the outermost level actually commits.
DB::transaction() directly in the UseCaseIn strict clean architecture, there is a view that DB::transaction() is an infrastructure-layer detail and should not be used directly in the UseCase layer.
// A stricter implementation (optional)
use Closure;
interface TransactionManagerInterface
{
// DB::transaction() only accepts a Closure, so match that here
public function execute(Closure $callback): mixed;
}
final class LaravelTransactionManager implements TransactionManagerInterface
{
public function execute(Closure $callback): mixed
{
return DB::transaction($callback);
}
}
However, this book prioritizes Laravel's practicality and uses DB::transaction() directly. Integration tests work with the RefreshDatabase trait. If you want a unit test with mocks only, put the TransactionManagerInterface above in between.
Choose the level of abstraction according to your project's requirements.
Optimistic locking
To prevent conflicts from concurrent updates, optimistic locking is effective.
[Problem: Lost Update]
User A: read order (version 1)
User B: read order (version 1)
User A: update order → save (version 2)
User B: update order → save (overwrites A's changes!)
[Solution: Optimistic Locking]
User A: read order (version 1)
User B: read order (version 1)
User A: update order → save (version 1 → 2) ✓
User B: update order → save (version 1 → ?) ✗ Conflict!
Implementation in Laravel
The version column is defined with default(1) in the orders migration in Chapter 14, "Domain Model and Table Design". Adding version to OrderModel::$fillable also belongs to Chapter 13, "The Repository Pattern". This chapter covers the side that detects conflicts on top of that.
Version management in the domain entity
// app/Domain/Order/Order.php
final class Order
{
private function __construct(
private readonly OrderId $id,
private OrderStatus $status,
private int $version, // version
// ...
) {}
public function version(): int
{
return $this->version;
}
public function incrementVersion(): void
{
$this->version++;
}
}
Conflict detection in the repository
// app/Infrastructure/Repository/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private readonly DomainEventDispatcherInterface $eventDispatcher,
) {}
public function save(Order $order): void
{
DB::transaction(function () use ($order) {
$currentVersion = $order->version();
$affected = OrderModel::where('id', $order->id()->value())
->where('version', $currentVersion) // use the current version as a condition
->update([
'status' => $order->status()->value,
'version' => $currentVersion + 1,
// ...
]);
if ($affected === 0) {
// No rows to update = the version has changed = a conflict
throw new OptimisticLockException(
'This was updated by another user. Please reload and try again.'
);
}
// Advance only after success. Advancing before the UPDATE double-counts
// when a deadlock retry ($attempts > 1) reuses the same instance
$order->incrementVersion();
// update() only returns a count, so fetch the model again to save the lines
$orderModel = OrderModel::findOrFail($order->id()->value());
$this->saveOrderLines($orderModel, $order->orderLines());
});
$events = $order->pullDomainEvents();
DB::afterCommit(fn () => $this->eventDispatcher->dispatchAll($events));
}
}
On new creation, version starts at 1 without being specified, because the Chapter 14 migration carries default(1).
Defining the exception
// app/Domain/Shared/Exception/OptimisticLockException.php
// Without the namespace this becomes a global class, distinct from the
// App\Domain\Shared\Exception\OptimisticLockException that Chapter 16's match catches
namespace App\Domain\Shared\Exception;
final class OptimisticLockException extends DomainException
{
public function __construct(string $message = 'A concurrent update conflict occurred')
{
parent::__construct(
message: $message,
errorCode: 'OPTIMISTIC_LOCK_CONFLICT',
);
}
}
There is one more way to prevent concurrent-update conflicts: pessimistic locking.
| Method | Characteristic | Suitable case | Performance |
|---|---|---|---|
| Optimistic locking | Detect the conflict at update time | When conflicts are rare (many web apps) | Fast (no lock waiting) |
| Pessimistic locking | Acquire a lock at read time | When conflicts are frequent (e.g. concurrent stock updates) | Slow (lock waiting occurs) |
How optimistic locking works
User A: read (version 1) → processing...
User B: read (version 1) → processing...
User A: update (version 1 → 2) ✓ success
User B: update (version 1 → ?) ✗ conflict error (version is already 2)
Pro: because there is no lock at read time, performance is higher.
Con: on a conflict it becomes an error, and the user has to retry.
Pessimistic locking
How pessimistic locking works
User A: read with lock → processing... (other users wait)
User B: read with lock → waiting...
User A: update → commit → release the lock
User B: read with lock → start processing
Pro: it reliably prevents conflicts.
Con: lock waiting occurs, which may degrade performance.
Implementing pessimistic locking in Laravel
// app/Infrastructure/Repository/EloquentInventoryRepository.php
final class EloquentInventoryRepository implements InventoryRepositoryInterface
{
// For the other methods, see the interface definition in Chapter 8
public function findByProductIdForUpdate(ProductId $productId): ?Inventory
{
$model = InventoryModel::where('product_id', $productId->value())
->lockForUpdate() // SELECT ... FOR UPDATE (row lock)
->first();
return $model ? $this->toEntity($model) : null;
}
}
// Usage inside the UseCase
DB::transaction(function () {
// Retrieve stock with a pessimistic lock (other transactions wait here)
$inventory = $this->inventoryRepository->findByProductIdForUpdate($productId);
$inventory->decrease(5);
$this->inventoryRepository->save($inventory);
// After the commit, other transactions proceed once the lock is released
});
Choosing a locking strategy
For many web applications, optimistic locking is suitable.
When to choose optimistic locking:
- Cases where concurrent updates are rare, such as order updates or editing a user profile
- When you want to prioritize response speed
When to choose pessimistic locking:
- Cases where concurrent updates happen frequently, such as stock updates during a sale
- When you do not want to ask the user to retry on a conflict error
- Cases where exactly one transaction must process at a time, such as a seat-reservation system
Summary
| Scenario | Transaction location | Reason |
|---|---|---|
| Creating a new order | Repository | Updates only one aggregate (Order) |
| Confirming an order + decreasing stock | UseCase | Updates two aggregates (Order, Inventory) |
| Cancelling an order | Repository | Updates only one aggregate (Order) |
| Cancelling an order + restoring stock | UseCase | Updates two aggregates |
| Preventing concurrent-update conflicts | Repository | Detect with optimistic locking |
Reference resources
- Laravel Database Transactions - the Laravel official documentation
In the next chapter, we will take a detailed look at error handling.