Transactions and concurrency — closing double-allocated stock
Of the four problems raised in chapter 1, one remains untouched: double-allocated stock.
Chapter 5 gave "the moment you check and the moment you decrement are not the same moment" as the reason not to write the stock check in a Form Request. Chapter 6 stuck a warning on StockChecker saying "this implementation is not safe yet." Both deferred the solution to this chapter. Here we collect on it.
What is broken right now
Recall the order of operations in the OrderPlacementService from chapter 6.
assertEnoughchecks there is enough stock- The order and its lines are created
decreasedecrements stock
This code has two failures of different natures. They are easy to conflate, so let us separate them first.
Failure 1: a mid-way failure leaves things half-done
Say an exception occurs while creating the lines. The order record is already saved. Stock has not been decremented yet. An order exists with no goods reserved for it, and that state stays in the database.
Chapter 10's remark that "even when an error comes back, processing may have got partway through" refers to exactly this.
Failure 2: concurrent requests double-allocate
Two people order a product with one unit in stock at the same time.
B's check slips in between A's check and A's decrement. From B's point of view there is still one unit, so the check passes.
These two need separate countermeasures. Failure 1 is solved by a transaction. Failure 2 is not solved by a transaction. That is the point most often mixed up in this chapter.
Transactions — all of it, or none of it
Pass a closure to DB::transaction() and the processing inside becomes one unit1. If an exception is thrown it rolls back automatically and rethrows the exception. If it runs to the end it commits automatically.
use Illuminate\Support\Facades\DB;
public function place(User $user, array $items): Order
{
return DB::transaction(function () use ($user, $items) {
$this->stockChecker->assertEnough($items);
$this->stockChecker->decrease($items);
// ... creating the order and its lines ...
return $order;
});
}
Note the return. DB::transaction() returns the closure's return value as is, so the order comes out this way.
Now Failure 1 is closed. If line creation throws, the order record is rolled back too. Either everything happens or nothing does.
A transaction alone is not enough
Failure 2 is still there. A transaction is a mechanism for gathering your own writes, not one for stopping someone else's interruption.
In the diagram above, suppose A and B were each inside a transaction. The ordering does not change. Both read stock of 1, and both decide there is enough. Both complete normally, so no rollback happens either.
What you want to stop is B touching the same row from the moment A reads stock until A finishes decrementing it. That is a row lock.
Row locks — lockForUpdate
Read with lockForUpdate() and an exclusive lock is placed on the row you read2. Other transactions cannot update that row nor take the same lock until you commit.
A plain SELECT is not blocked. A read that takes no lock ignores the lock and returns the pre-update value3. What blocks is anything trying to write, and anything trying to take a lock. Another order trying to decrement stock waits until you are done.
Let us rewrite StockChecker::assertEnough. Two lines change from chapter 6.
public function assertEnough(array $items): void
{
$stocks = Stock::whereIn('product_id', array_column($items, 'product_id'))
->orderBy('product_id')
->lockForUpdate()
->get()
->keyBy('product_id');
foreach ($items as $item) {
$available = $stocks->get($item['product_id'])?->quantity ?? 0;
if ($available < $item['quantity']) {
throw new InsufficientStockException(
productId: $item['product_id'],
requested: $item['quantity'],
available: $available,
);
}
}
}
Now B cannot read the stock row until A commits. Once A sets stock to 0 and commits, B reads 0 and returns 409.
A lock only means something inside a transaction
Calling lockForUpdate() on its own protects almost nothing. A lock is released at the end of a transaction. With no transaction, it is released the instant that query finishes. Another request can slip in right after the read, so the gap is not closed.
Looking at assertEnough alone does not reveal that assumption. Its meaning depends on whether the caller opened a transaction. When a method has a hidden assumption, you have to read it together with its caller.
Confining the lock to the stock row
The only rows locked are in stocks. products is untouched.
That is why chapter 3 split stock into its own table. Had stock quantity been a column on products, processing that rewrites product information would wait too while stock is reserved. A price revision or a description fix stalls behind someone's order.
A read that merely displays a product listing is not blocked under either design. What waits is the writing side. Even so, stock changes on every order, while product information is updated only occasionally. There is no reason to drag low-frequency work into a high-frequency lock.
Split the tables and the lock's effect stays confined to processing that touches stock.
Row locks do not work in SQLite
Chapter 1 set the environment for this series to SQLite. Against SQLite, lockForUpdate() generates no SQL at all.
Laravel's SQLite query grammar implements the lock clause builder so that it returns an empty string4. Write ->lockForUpdate() and the SQL issued is character-for-character identical to leaving it out.
Code with lockForUpdate() runs against SQLite with no error and no warning. There simply is no lock. You cannot conclude "it worked, so it is fixed."
To confirm the effect of a row lock, use MySQL or PostgreSQL.
For that reason, chapter 3's claim that "splitting stock into its own table confines the lock" was about databases that generate a lock clause. The design is still right, but its effect comes with a condition.
Relying solely on a mechanism that silently becomes a no-op depending on the environment is dangerous. Let us add one more approach: a way of writing it that produces the same SQL on any database.
Stop reading before writing
The conflict happens because there is a gap between reading and writing. Besides locking, there is one other way to remove the gap: stop reading, and put the condition on the write itself.
UPDATE stocks SET quantity = quantity - 3
WHERE product_id = 1 AND quantity >= 3
This UPDATE completes in one statement. "Decrement if there is enough stock" becomes one indivisible operation. If there is not enough, no row matches the condition and nothing is updated.
Look at the number of rows affected and you know whether it succeeded. Laravel's decrement() returns the affected row count5.
public function decrease(array $items): void
{
foreach ($items as $item) {
$affected = Stock::where('product_id', $item['product_id'])
->where('quantity', '>=', $item['quantity'])
->decrement('quantity', $item['quantity']);
if ($affected === 0) {
throw new InsufficientStockException(
productId: $item['product_id'],
requested: $item['quantity'],
available: Stock::where('product_id', $item['product_id'])->value('quantity') ?? 0,
);
}
}
}
It re-reads stock when zero rows were affected. The affected count tells you only "there was not enough." The error shape settled in chapter 9 includes available, so we need the actual remaining count. Substitute 0 here and someone ordering 5 of a product with 3 in stock is told "out of stock." It costs one extra query, but only on the error path.
You still need a transaction
A conditional UPDATE guarantees safety for one row only.
Consider an order with two lines. The first line has enough stock and decrements successfully; the second does not, and an exception flies. The first decrement has already run. Without a transaction, stock has gone down while no order exists.
Gathering operations that span several rows is the transaction's job; preventing a conflict on one row is the conditional UPDATE's job. Their roles differ, so neither substitutes for the other.
Use both
The final form includes both the row lock and the conditional UPDATE.
| Technique | What it does | What it does not do |
|---|---|---|
| Row lock | State stays stable after the read. You can check all lines together before writing and return an accurate remaining count | No lock clause on SQLite. Meaningless outside a transaction |
| Conditional UPDATE | The decrement of one row itself withstands conflict. The same SQL on any database | You only learn of the shortfall at write time. With several lines, rows already decremented remain |
On databases that emit a lock clause, the lock is the main defense. On SQLite only the conditional UPDATE is at work. In either environment, no path leaves stock negative.
Deadlocks
Locking several rows creates a new problem.
Say two orders containing products 1 and 2 arrive at once. A locks product 1 and B locks product 2. Then A waits for product 2 and B waits for product 1. Each waits for the other's lock to release. That is a deadlock.
The countermeasure is simple: everyone touches rows in the same order. With no cycle, there is no mutual wait.
usort($items, fn ($a, $b) => $a['product_id'] <=> $b['product_id']);
Sort the lines by ascending product_id before processing. Where this takes effect is the loop in decrease. It issues one UPDATE at a time, so the sort order becomes the lock acquisition order. Both A and B touch product 1 first, so whoever arrives second simply waits.
assertEnough reads every row in a single whereIn statement, so sorting on the PHP side has no effect. Which row gets locked first within one statement is decided by the database's execution plan. The orderBy('product_id') is there to state intent; do not read it as a guarantee of lock acquisition order. To be sure of the order, process one row at a time as decrease does.
Retry when it happens anyway
Even with a consistent order, deadlocks cannot be avoided entirely. Some other processing may touch the same rows in a different order.
Pass an attempt count as the second argument to DB::transaction() and it re-runs the closure from the start on deadlock1.
}, attempts: 3);
This retry means something on SQLite too. Laravel's list of error strings treated as retryable includes SQLite's database is locked and database table is locked6. Not generating a lock clause and not retrying lock-related errors are separate matters.
DB::transaction() can nest, but the inner one becomes a SAVEPOINT. Specifying attempts: on an inner one does not retry; DeadlockException is thrown as is7, because the outer transaction cannot be rolled back from there.
Keep in mind whether the caller has already opened a transaction. If your tests use RefreshDatabase, each test is wrapped in a transaction too.
What must not go inside a transaction
The retry mechanism has an assumption that is easy to miss. A retry re-runs the closure. Not partially — from the top.
Writes to the database are rolled back, so re-running them is fine. Operations that left the database do not come back.
DB::transaction(function () use ($user, $items) {
$order = $this->createOrder($user, $items);
$user->notify(new OrderConfirmed($order)); // a sent message cannot be unsent
$this->paymentGateway->charge($order->total_amount); // a charge cannot be uncharged
return $order;
}, attempts: 3);
Three attempts on a deadlock means three confirmation emails and three charges. Rolling back the transaction does not bring the sent mail back.
The problem remains even without retries. If the external API is slow to respond, the transaction stays open that whole time. You hold locks for seconds while other requests pile up.
Put only database operations inside a transaction. Sending mail, calling external APIs, and dispatching to a queue happen after the commit. For queues, chapter 16 covers the afterCommit setting that delays dispatch until after the commit.
Stopping duplicate orders with a unique constraint
Stock is protected now. What remains is the same order being created twice.
A user double-clicks the submit button. The network is flaky and the client retries automatically. From the server's point of view, both are two indistinguishable requests.
Chapter 13 said a webhook returning 5xx gets resent, and that you have to build on the premise that the same event arrives twice. What we cover here is the general form. Use the event ID from the payment provider as the key for the mechanism we are about to build, and the same payment never gets recorded twice.
A queued job running twice is covered in chapter 17. Stopping duplicates at the entrance is this chapter; withstanding duplicates at execution time is chapter 17.
Let the client hold the key
The server alone cannot tell whether the second request is a retry or a genuine second order. The client is the one who knows they are the same operation.
So we have the client attach a unique value per request. The header name used is Idempotency-Key8.
POST /api/v1/orders HTTP/1.1
Idempotency-Key: 9f2b1c4e-6a3d-4f8b-9c1e-2d5a7b0f3e4c
On a retry, it sends the same value as the first time. The server uses that value to decide whether this is a retry.
Put a unique constraint on the storage
Make that decision in application code alone and you create another gap between checking and saving. It is the same structure as the problem this whole chapter has been about.
To leave no gap, use a database unique constraint.
public function up(): void
{
Schema::table('orders', function (Blueprint $table) {
$table->string('idempotency_key')->nullable()->after('total_amount');
$table->unique(['user_id', 'idempotency_key']);
});
}
It is composite with user_id. The client decides the key's value, so another user might happen to use the same string. Put the unique constraint on idempotency_key alone and the second person gets someone else's order back. That is worse than an error. Uniqueness per user is enough.
It is nullable. Some clients do not send the header. Two NULLs are not treated as duplicates by a unique constraint, so any number of header-less orders can be created. Make the column required and every test and factory written in earlier chapters stops working.
Add it to the model too. It is the same job as adding user_id in chapter 11.
#[Fillable(['user_id', 'customer_email', 'status', 'total_amount', 'idempotency_key'])]
Forget it and nothing errors. As chapter 3 mentioned, an attribute not in #[Fillable] is silently discarded by default. idempotency_key is always NULL and the unique constraint never fires. Retry prevention is entirely inoperative, with no sign of it whatsoever. Creating the migration and forgetting the attribute is an easy shape to fall into, so treat the two as a single unit.
What to return the second time
When the unique constraint trips, you will want to return an error. Do not.
Idempotency means "however many times you send the same request, there is one result." What a retrying client wants is not an error but the first result. Return 409 to a client whose network dropped before it got a response and it is left not knowing whether the order went through.
The Idempotency-Key draft specification also says to return the first result for a completed operation8.
Look it up before touching stock. Get the order wrong and the retry fails for a different reason. Say someone orders 3 of a product with 10 in stock, it succeeds, and the response never reaches the client. In the meantime other users buy the remaining 7. If the client now retries with the same key, the stock check runs first and returns 409 for insufficient stock. You would be reporting failure for an order that actually went through. Look up the existing order first and you never reach the stock check.
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
public function place(User $user, array $items, ?string $idempotencyKey = null): Order
{
if ($idempotencyKey !== null) {
$existing = $this->findByIdempotencyKey($user, $idempotencyKey);
if ($existing !== null) {
return $existing;
}
}
usort($items, fn ($a, $b) => $a['product_id'] <=> $b['product_id']);
try {
return DB::transaction(function () use ($user, $items, $idempotencyKey) {
$this->stockChecker->assertEnough($items);
$this->stockChecker->decrease($items);
$products = Product::findMany(array_column($items, 'product_id'))->keyBy('id');
$total = 0;
foreach ($items as $item) {
$total += $products[$item['product_id']]->price * $item['quantity'];
}
$order = Order::create([
'user_id' => $user->id,
'customer_email' => $user->email,
'status' => OrderStatus::Pending,
'total_amount' => $total,
'idempotency_key' => $idempotencyKey,
]);
foreach ($items as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_price' => $products[$item['product_id']]->price,
]);
}
return $order;
}, attempts: 3);
} catch (UniqueConstraintViolationException $e) {
$existing = $idempotencyKey === null
? null
: $this->findByIdempotencyKey($user, $idempotencyKey);
if ($existing === null) {
throw $e;
}
return $existing;
}
}
private function findByIdempotencyKey(User $user, string $key): ?Order
{
return Order::where('user_id', $user->id)
->where('idempotency_key', $key)
->first();
}
If nothing is found, it rethrows. A unique constraint violation reaching this catch is not necessarily about idempotency_key. With chapter 3's schema there are no other unique constraints on orders, so today it narrows to one; add a column later and that changes. If no matching order is found, the cause is something else. Rethrow rather than swallow, and chapter 9's mechanism returns a 500 with a tracking ID as an unexpected exception. Settle for firstOrFail() and a defect with a different cause turns into a 404 that misdirects the investigation.
It does not search when the key is null. where('idempotency_key', null) becomes a condition for "rows where that column is NULL," picking up past orders placed without a key. It would return a completely unrelated order with a 200 for a request that sent no key. Under the current schema this path is unreachable because NULLs do not collide on the unique constraint, but we close the meaning of the condition explicitly.
The catch sits outside the transaction. Catch inside and processing written after the exception is committed as part of the same transaction. You made it an exception in order to roll back, and the swallowed result gets saved. Outside, DB::transaction() finishes the rollback and then rethrows.
Under the current schema, the only thing reaching this catch is a retry that arrived concurrently. A normal retry is found by the leading lookup and returned. Only when the second arrives before the first has committed does the lookup come up empty, both proceed, and the later one hit the unique constraint. The database constraint closes the gap the application lookup cannot. It is the same structure as the "between the check and the write" theme running through this chapter.
A retry arriving before the first commits is not found by the leading lookup. If the last unit was the one remaining, the second request receives a 409 for insufficient stock. Since the first succeeds, the client is told "it failed" while the order stands.
The unique constraint protects only against "two orders being created"; it does not close this miss. To close it, create a record saying "processing started under this key," separate from the order itself, and have the second request see it and either wait or return 409. The draft specification assigns 409 to exactly this "in progress" state8.
This series does not implement it. A client that keeps retrying with the same key receives the correct result once the commit lands.
The stock the second request decremented is restored by the rollback. Because the stock decrement is placed before order creation, you never end up with stock decremented while only the order failed.
UniqueConstraintViolationException is provided by Laravel and thrown only for unique constraint violations9. It is thrown on SQLite too.
This catch stays inside the service class. Convert UniqueConstraintViolationException globally in bootstrap/app.php and a duplicate email address is treated as a duplicate order too. Put the catch where the cause narrows to one.
Receiving it in the controller
Read the header and pass it to the service.
public function store(StoreOrderRequest $request, OrderPlacementService $service)
{
$order = $service->place(
$request->user(),
$request->validated('items'),
$request->header('Idempotency-Key') ?: null,
);
return (new OrderResource($order))
->response()
->setStatusCode($order->wasRecentlyCreated ? 201 : 200);
}
The ?: null collapses the empty string. header() returns null when the header is absent, but returns an empty string for a header present with an empty value10. Unlike NULL, an empty string counts as a duplicate. Pass it through uncollapsed and, for a client that always sends an empty value, every order after the first becomes a reuse of the first. You think you placed a new order and an old one comes back with a 200. Because nothing errors, it is a hard failure to notice.
wasRecentlyCreated splits the status code. That attribute is true only when the instance was just created by an INSERT. Reading an existing row leaves it false. Return 201 for a new creation and 200 for a response to a retry.
Tests
Since chapter 11 made authentication necessary, tests that create orders use Sanctum::actingAs() to create an authenticated state.
Confirm stock goes down
The test written in chapter 10 becomes the detector as is.
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 7, // 10 - 3
]);
It confirms that adding the transaction did not make the stock decrement disappear. This is what chapter 10 meant by "becomes chapter 14's detector."
Confirm no order remains when stock runs out
Use the soldOut() state prepared in chapter 3.
test('insufficient stock returns 409 and creates no order', function () {
Sanctum::actingAs(User::factory()->create());
$product = Product::factory()->create();
Stock::factory()->soldOut()->create(['product_id' => $product->id]);
$this->postJson('/api/v1/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
])
->assertStatus(409)
->assertJsonPath('code', 'INSUFFICIENT_STOCK');
$this->assertDatabaseCount('orders', 0);
});
assertEnough throws before the order is created, so this test passes even with the transaction removed, because no write happens in the first place.
Chapter 10 already has essentially the same test. Do not read "it is green, so it is fixed." Which of this chapter's mechanisms tests can and cannot confirm is separated out below.
Confirm a retry does not create two orders
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('sending the same key twice creates only one order', function () {
Sanctum::actingAs(User::factory()->create());
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$payload = ['items' => [['product_id' => $product->id, 'quantity' => 3]]];
$headers = ['Idempotency-Key' => 'test-key-001'];
$first = $this->withHeaders($headers)->postJson('/api/v1/orders', $payload);
$first->assertStatus(201);
$second = $this->withHeaders($headers)->postJson('/api/v1/orders', $payload);
$second->assertStatus(200);
expect($second->json('data.id'))->toBe($first->json('data.id'));
$this->assertDatabaseCount('orders', 1);
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 7, // 10 - 3, not decremented twice
]);
});
Four things are checked: the first is 201 and the second is 200, the same order comes back, there is exactly one order, and stock was decremented once.
The happy path is included. Chapter 13 covered how a failure-only test passes even when the target does not exist. Without checking the first 201, this test passes even for an implementation that always errors.
Confirm the conditional UPDATE itself
The conditional UPDATE in decrease is the only defense on SQLite. And yet none of the three tests above goes through that path. assertEnough checks stock first and throws, so the branch where the affected count is zero is never reached.
It cannot be reached over the API, so call the class directly.
use App\Exceptions\InsufficientStockException;
use App\Services\StockChecker;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('decrementing more than the stock throws and leaves stock unchanged', function () {
$product = Product::factory()->create();
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 2]);
expect(fn () => app(StockChecker::class)->decrease([
['product_id' => $product->id, 'quantity' => 5],
]))->toThrow(InsufficientStockException::class);
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 2,
]);
});
Remove where('quantity', '>=', ...) and this test goes red: no exception flies and stock becomes -3. You are now in a state where deleting the conditional UPDATE gets noticed.
Confirm the rollback
A test that goes red when DB::transaction() is removed can be written the same way.
For a rollback to matter, one write has to succeed and the next has to fail. That cannot be produced over HTTP. A line with insufficient stock is rejected by assertEnough before any write, and an order with the same product split across two rows is rejected with a 422 by chapter 5's StoreOrderRequest::after().
Being rejected and being impossible to construct are different things. Call the service class directly and you can pass that combination.
use App\Exceptions\InsufficientStockException;
use App\Services\OrderPlacementService;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('running out of stock partway through the lines rolls back the decrement too', function () {
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 1]);
expect(fn () => app(OrderPlacementService::class)->place($user, [
['product_id' => $product->id, 'quantity' => 1],
['product_id' => $product->id, 'quantity' => 1],
]))->toThrow(InsufficientStockException::class);
$this->assertDatabaseCount('orders', 0);
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 1, // the one unit decremented on the first row is back
]);
});
assertEnough lets it through. It only compares each line's "1 unit" against the 1 unit in stock, one row at a time; it never looks at the two rows summing to 2. Inside decrease, the first row takes stock to 0 and the second finds no row matching the condition and throws.
Remove DB::transaction() and the first row's decrement remains, leaving quantity at 0. This test pins down that the transaction is rolling back.
With chapter 5's validation in place, these lines never reach the API. It is still worth writing. It separates whether data breaks or not when one validation is removed.
When defenses are layered, testing only the outer one tells you nothing about whether the inner one works. A test that calls the inner one directly pins down what happens when the outer one is breached.
The situation at the heart of this chapter — two requests arriving at once — is not reproduced by ordinary tests. It would need two processes genuinely running at the same time, and the test's result would vary from run to run.
What the two tests above confirm is the machinery that keeps things from breaking when a conflict occurs. The part that causes the conflict is not confirmed. Whether a row lock really makes another request wait is something to confirm by hand with MySQL or PostgreSQL.
What matters in production
Do not add the stock check back to the Form Request
Chapter 5 decided not to check stock in a Form Request. Now that the real line of defense exists, that judgment stands.
Rejecting early has its merits. With two lines of defense, readers cannot tell which one guarantees correctness. Nobody can judge what breaks if one is removed. The guarantee lives on this chapter's side.
Monitor for negative stock
With the conditional UPDATE in place, stock never goes negative through the normal path. Stock adjustments from an admin screen and data migration scripts do not go through that path.
You could also forbid negative numbers with a database constraint. Unsigned integer types are not the answer. PostgreSQL has no such type, and SQLite does not enforce types, so it silently becomes a no-op depending on the environment — the same shape of trap as lockForUpdate().
CHECK (quantity >= 0) works on all three databases. Whether to adopt it depends on what you want to protect. Hitting the constraint throws, so a write that would make stock negative is definitely stopped. On the other hand, Blueprint has no dedicated method for it, so you write raw SQL in the migration. And the error arrives as a constraint violation rather than an InsufficientStockException, so without a separate conversion you get a 500 instead of a 409.
This series does not add it. The normal order path is already closed by the conditional UPDATE, and what remains is writes from admin screens and migration scripts. If you want to protect those, a CHECK constraint is a reasonable choice. Without it, supplement with a mechanism that periodically searches for negative stock and notifies you.
Keep transactions short
The longer you hold a lock, the longer other requests wait. Put only processing whose success or failure you want decided together inside a transaction.
Calculations, formatting, and validation can all finish before entering the transaction.
Give retry keys an expiry
Holding idempotency_key on orders keeps it around as long as the order. Colliding with a key from a year ago is unlikely, but not impossible.
Once you are at scale, move to a separate table for keys and delete old ones. This series does not settle how long to retain them. The value depends on how long after the fact a client might retry, and there is no general right answer.
Decide what happens when the same key carries different content
The implementation in this chapter returns the first result without looking at the content when the key matches. A client bug could attach the same key to a different order.
The draft specification says to return 422 when a different body arrives under the same key8. Implementing it means storing a fingerprint of the request body and comparing. This series does not implement it. Deciding what is out of scope is itself design.
Summary
- A transaction guarantees "nothing half-done survives a mid-way failure." It does not prevent concurrent conflicts
- Conflicts are prevented by a row lock or a conditional UPDATE. Their role differs from a transaction's
lockForUpdate()means nothing outside a transaction. What blocks is only the writing side; a read that takes no lock passes straight through- On SQLite,
lockForUpdate()generates no SQL. Working locally does not mean you are protected - A conditional UPDATE is strong against single-row conflict and works anywhere. Guarantees spanning several rows are the transaction's job
- Deadlocks are avoided when everyone touches rows in the same order. They still happen, so retry with
attempts: - A retry re-runs the closure from the top. Do not put sending mail or charging inside a transaction
- On a retry, look up the existing order before touching stock. Get the order wrong and you report insufficient stock for an order that stands
- A concurrent retry the lookup misses is stopped by the database's unique constraint. An application-level check always has a gap
- Do not return an error to a retry. Returning the first result is what idempotency means
What to read next
With stock protected, one of the four problems from chapter 1 remains. The next chapter, Events and notifications — decoupling everything after order confirmation, handles the email send time becoming the response time. We start by separating the processing that follows order confirmation out of the service class. The decision made in this chapter — no sending mail inside a transaction — pays off there.
Exercises
The following code does not prevent double-allocated stock. Explain why
public function place(User $user, array $items): Order
{
$this->stockChecker->assertEnough($items);
return DB::transaction(function () use ($user, $items) {
$order = $this->createOrder($user, $items);
$this->stockChecker->decrease($items);
return $order;
});
}
Sample answer
The stock check is outside the transaction. Even if assertEnough uses lockForUpdate(), the lock is released the moment that query finishes.
A lock lives until the transaction ends. The lock from a query not inside a transaction disappears when that query completes. Between the check finishing and the transaction starting, another request can take the same stock.
The transaction has to be opened outside the range you want protected. In this example it must include both the check and the decrement.
Note that if decrease is written with a conditional UPDATE, stock going negative is avoided regardless. Even then, you would discover the shortfall after creating the order, which defeats the point of checking first.
How do you respond to "we got a deadlock, so we raised attempts to 10"?
Sample answer
Raising the count without confirming the cause is not advisable. There is no guarantee that a deadlock unresolved at three attempts resolves at ten; it only triples the time before failure. During that time the request returns nothing.
Two things to confirm first.
One: is the order of touching rows consistent? With a consistent order, most deadlocks do not happen. This chapter sorted lines by product_id, but if other processing touches stock, it needs the same order. If an admin stock adjustment runs in a different order, that is your cause.
Two: is the transaction too long? The longer you hold locks, the higher the chance of collision. Check whether an external API call slipped inside the transaction, or whether a loop is processing one record at a time.
Retries exist to absorb the rare collisions that remain after ordering is consistent. If they are frequent, the cause is in the design. Before raising the count, identify from the logs which pieces of processing are conflicting.