Two People Editing One Record: Optimistic Locking and the 409
Two People Editing One Record: Optimistic Locking and the 409

Priya opens invoice 412 at 10:02 and corrects the GST rate. Arun opens the same invoice at 10:03 and corrects the billing address. Priya saves at 10:07. Arun saves at 10:09. Both screens say “Saved”. The GST correction is gone, and nobody will notice until the client queries the tax on the invoice three weeks later.
This is a lost update, and it is the quietest data bug there is. There is no exception, no failed request, no log line. Two correct operations produced an incorrect result, and every piece of evidence available to support says the system worked.

Why it happens even though your writes are fine
Arun’s form did not post one field. It posted every field on the record, including a GST rate he loaded at 10:03 — four minutes before Priya corrected it. His save is a full overwrite with data that was already stale by the time he pressed the button.
<?php
public function update(Request $request, Invoice $invoice)
{
$invoice->update($request->validated()); // writes all fields
return back()->with('status', 'Saved');
}
There is nothing wrong with those two lines in isolation. They are wrong in combination with a form that a human held open for six minutes. The database did exactly what it was asked. It was asked the wrong thing.
Notice what does not help here. A transaction does not help; both writes were individually atomic and correct. Retrying does not help; the write did not fail. Wrapping the update in a lock does not help either, because the two operations were never concurrent in the database — they were two minutes apart. The concurrency lived in the two browser tabs.
Two different races, and only one of them is a database problem
This distinction is the one worth internalising, because the correct fix follows from it directly.
A race in the database
Two writes land microseconds apart on the same row. Two customers buy the last unit of stock at the same instant, or two payment webhooks decrement the same balance.
-- atomic: the database does the arithmetic
UPDATE stock SET qty = qty - 1 WHERE id = 8 AND qty > 0;
The database can solve this alone, because the whole decision is expressible in one statement. Row locks, atomic updates and conditional WHERE clauses exist precisely for this shape.
A race in the application
The value was read, a human or a process thought about it, and the write came later. The window is seconds or minutes, not microseconds.
<?php
$stock = Stock::find(8); // qty is 1
// ... validation, a form, a decision, 40 seconds
$stock->qty = $stock->qty - 1; // computed from a stale read
$stock->save();
No lock can span that window — nothing sane holds a database lock for forty seconds, let alone six minutes. The only thing that works is to carry a token of what you read and let the write refuse if the world has moved on.
The shape to hunt for in a codebase is read-modify-write: any place where a value written to the database was computed from a value read from it earlier in the request, or in an earlier request. If the arithmetic happened in PHP, you have the second kind of race.

Optimistic locking with a version column
Optimistic locking assumes clashes are rare — which for edit forms they are — and detects them rather than preventing them. Add an integer version to the row.
<?php
Schema::table('invoices', function (Blueprint $table) {
$table->unsignedInteger('version')->default(1);
});
Send it to the browser with the form, take it back on submit, and make it part of the WHERE clause on the update. The database itself decides whether the write is still valid.
<?php
$affected = Invoice::where('id', $invoice->id)
->where('version', (int) $request->input('version'))
->update($request->validated() + ['version' => DB::raw('version + 1')]);
if ($affected === 0) {
throw new StaleRecordException($invoice);
}
That is the whole mechanism. If somebody else saved first, the version in the row no longer matches the one in the form, zero rows match, and $affected is zero. The check and the write are a single statement, so there is no window between them.
Two details decide whether it works in practice.
- The version must be in the form, not in the session. It represents what this tab loaded. A user with the invoice open in two tabs is a real scenario, and session state gets it wrong.
- The increment must be in the same statement. Reading the version, adding one in PHP and writing it back reintroduces exactly the race you are fixing, one level down.
You can hide it behind a trait so that nobody has to remember, which is what makes it survive contact with a team:
<?php
trait HasVersion
{
public function saveWithVersion(array $attributes): void
{
$affected = static::where($this->getKeyName(), $this->getKey())
->where('version', $this->version)
->update($attributes + ['version' => DB::raw('version + 1')]);
if ($affected === 0) {
throw new StaleRecordException($this);
}
$this->refresh();
}
}
The 409 and what the interface does with it
A stale write is not a server error and not a validation error. It is HTTP 409 Conflict, and the status code matters because the interface has to behave differently for it.
<?php
// in the exception handler
if ($e instanceof StaleRecordException) {
return response()->json([
'message' => 'Somebody else saved this record while you were editing.',
'saved_by' => $e->record->updatedBy?->name,
'saved_at' => $e->record->updated_at->toIso8601String(),
'current' => $e->record->only(['gst_rate', 'billing_address']),
], 409);
}
The failure mode to avoid is a red toast saying “Conflict” that throws away everything the user typed. That is worse than the lost update, because now the person knows they have lost work. Three behaviours, roughly in order of effort:
- Tell them clearly and keep their input. “Arun saved this at 10:07. Your changes are still here.” Even with no merge tooling at all, keeping the form populated is the difference between an inconvenience and a complaint.
- Show what changed. The response already carries the current values. Listing the two or three fields that differ lets the person decide in seconds, and usually the fields do not even overlap.
- Offer a field-level merge. Per field, keep mine or take theirs. Worth building only for records that genuinely get edited concurrently — a shared task board, not a user profile.
Send the version on every update, including from your own API clients and mobile apps. A record protected on the web form and unprotected through the API is not protected; it just has a smaller hole.
When pessimistic locking is the right answer
Optimistic locking answers “has this changed since I read it?”. Sometimes the correct answer is “nobody else may touch this until I am done”, and that is a different tool.
<?php
DB::transaction(function () use ($orderId) {
$stock = Stock::where('product_id', $orderId)
->lockForUpdate() // SELECT ... FOR UPDATE
->firstOrFail();
if ($stock->qty < 1) {
throw new OutOfStockException();
}
$stock->decrement('qty');
Order::create([...]);
});
lockForUpdate holds a row lock until the transaction commits. Any other transaction reaching the same row waits. Use it when all three of these are true: the operation is short, a clash is likely, and asking a user to resolve a conflict makes no sense.
- Counters and stock. There is no human decision to preserve. Waiting 8 milliseconds is a better user experience than a conflict dialogue.
- Anything summed before it is written. Totalling line items and storing the result, allocating from a balance, or drawing the next number in a sequence.
- Sequence generation. Invoice numbers are the classic case. Two requests reading “last number 412” and both writing 413 is a guaranteed duplicate under any load at all.
Three rules for using it safely. Keep the transaction as short as possible — never make an HTTP call or send an email inside one, because every other request wanting that row is queued behind your API timeout. Always lock rows in a consistent order across the codebase, or two transactions locking A then B and B then A will deadlock. And set a lock wait timeout, so a stuck transaction fails in seconds instead of stacking up connections until the database refuses new ones. The transaction discipline behind all of this is worth reading separately in Laravel database transactions.
Where the whole operation fits in one statement, prefer that over a lock. It is faster and it cannot deadlock:
<?php
// no lock, no read, no race
$affected = DB::update(
'UPDATE stock SET qty = qty - 1 WHERE product_id = ? AND qty > 0',
[$productId]
);
if ($affected === 0) {
throw new OutOfStockException();
}

Last write wins is a decision, not a default
For plenty of records, the correct policy really is that the most recent write stands. A user updating their own notification preferences does not need conflict detection. The trouble is that almost nobody arrives at that policy deliberately — it is simply what happens when nobody thought about it.
The test is one question per table: if two people save this record five minutes apart, is silently discarding the first save acceptable? Write down the answer.
- Yes, for a single-owner record. A user’s own profile, their preferences, a personal draft. One person edits it, and the last thing they typed is what they meant.
- No, for shared records. Invoices, projects, tasks, client details, anything a team touches. These need a version.
- Definitely not, for anything financial or legally recorded. These need a version and an audit trail, so that when the amount is questioned you can show who set it and when.
There is also a middle option people forget: make the record immutable and append instead. An amended invoice becomes a new invoice with a reference to the old one. A changed rate becomes a new rate row with an effective date. Nothing is ever overwritten, so nothing can be lost — and you get the history for free. This is more work up front and less work forever afterwards.
Making conflicts rarer in the first place
Detection is the safety net. It is worth also reducing how often the net is needed, because every 409 is a person being interrupted.
Send only what changed
The single biggest cause of lost updates is forms that post every field. If Arun only touched the address, only the address should be written. A PATCH that carries the changed fields means two people editing different parts of the same record never collide at all.
<?php
// only the fields the form actually marked dirty
$changed = array_intersect_key(
$request->validated(),
array_flip($request->input('dirty', []))
);
This does not remove the need for the version — two people editing the same field still clash, and a field-level policy has to be a deliberate choice rather than an accident of how the form serialises. But it turns most conflicts into non-events.
Show who else is in the record
A line reading “Priya is also viewing this invoice” prevents more conflicts than any merge dialogue resolves, and it needs nothing more than a short-lived cache key per record per user. People co-ordinate by themselves once they can see each other; the whole problem with the lost update is that it is invisible from both sides.
Do not hold a form open for six minutes
Long forms create long windows. Splitting a fifteen-field invoice screen into sections that save independently shortens every window and, incidentally, loses less work when a browser crashes. Autosaving a draft has the same effect and is usually less work than it sounds.
What a version column does not fix
Optimistic locking protects one row. Several real problems live between rows, and it is worth knowing where the protection stops.
- Invariants across records. “The total of the line items must equal the invoice total” is not defended by versioning the invoice, because the lines are separate rows. Either version the parent and bump it whenever a child changes, or recompute the total in the same transaction that changes a line.
- Uniqueness. Two requests both checking that an email is free and both then inserting it will both succeed, whatever locking you have added. The only reliable defence is a unique index, with the resulting integrity error caught and turned into a validation message.
- Deletes. A record deleted while somebody had it open produces zero affected rows, which your code will report as a conflict. Distinguish the two cases before showing a message, or the user is told somebody edited a record that no longer exists.
- Anything outside the database. A file on disk, a document in an external system, a row in a payment provider’s ledger. The version column knows nothing about those.
Testing a race deliberately
Races are famously hard to test, and they are made harder by people assuming you need threads. For optimistic locking you do not. The entire race is two objects holding the same version.
<?php
public function test_a_stale_save_is_rejected(): void
{
$invoice = Invoice::factory()->create(['gst_rate' => 12]);
$priya = Invoice::find($invoice->id); // both hold version 1
$arun = Invoice::find($invoice->id);
$priya->saveWithVersion(['gst_rate' => 18]);
$this->expectException(StaleRecordException::class);
$arun->saveWithVersion(['billing_address' => 'New address']);
}
No sleeps, no threads, nothing flaky. The test fails on the day somebody writes a new update path that forgets the version, which is the only thing you actually need it to do. Add one of these for every record you decided needed protection.
The HTTP-level version is worth having as well, because it checks the status code and the payload the interface depends on:
<?php
public function test_a_stale_form_post_returns_409(): void
{
$invoice = Invoice::factory()->create(['version' => 4]);
$this->actingAs($this->user)
->putJson("/invoices/{$invoice->id}", [
'version' => 3, // the tab loaded the old one
'gst_rate' => 18,
])
->assertStatus(409)
->assertJsonStructure(['message', 'saved_at', 'current']);
}
For a genuine database race — the stock case — you do need two connections. The reliable pattern is to open a transaction on connection A, take the lock, then assert from connection B that the same query blocks or fails within a short timeout. It is fiddlier, and you only need it for the handful of places where money or inventory is involved.
A crude but effective alternative for staging: fire twenty concurrent requests at the same endpoint and assert the invariant afterwards. Twenty orders against one unit of stock should leave the quantity at zero and reject nineteen. If it leaves minus nineteen, you have found the bug without writing a single lock test.

Where these bugs actually hide
A short list of the places we have found lost updates in real applications, none of which were on anybody’s list of concurrency-sensitive code.
- An admin panel edit form. Two staff members fixing the same customer record after a support call. Extremely common, never designed for.
- A background job and a user editing at the same time. A nightly recalculation writing totals while somebody edits the same project. The job usually wins, and the user’s change vanishes.
- A retried webhook. The same payload processed twice, each reading the old value and writing a new one. This one overlaps with idempotency, and the same version check fixes both.
- A mobile app that syncs on reconnect. Edits made offline at 11:00 arriving at 15:00, overwriting four hours of work done on the web.
- Bulk actions. “Mark 40 selected as approved” issued from a list loaded ten minutes ago, four rows of which have since changed.
What to do on Monday morning
- List the tables a team edits through a form, rather than through a single-purpose action. That is usually five or six tables out of fifty.
- For each one, answer the question in writing: is silently discarding an earlier save acceptable? Record the answer next to the table, because the next developer will ask.
- Add a
versioncolumn to the ones where the answer is no, and put the check in a trait so it is one line at each call site. - Return 409 with the current values, and make the interface keep the user’s input. That part is more important than the merge UI.
- Find every read-modify-write on a counter, a balance or a sequence, and convert it to an atomic statement or a
lockForUpdateinside a short transaction. - Write the two-object test for each protected record. It takes four lines and it is the only thing that stops the protection eroding.
The reason this work gets postponed is that the bug does not produce a ticket. Nobody reports a lost update, because from the inside it looks like a mistake somebody else made — and by the time the tax on an invoice is queried, the two saves are three weeks in the past and nothing connects them.

