php 43 lines · 6 steps

Optimistic locking on invoices in Laravel

An Eloquent service updates an invoice inside a transaction, using a version column to reject concurrent writes.

Explained by highlit
1<?php
2 
3namespace App\Services;
4 
5use App\Exceptions\StaleModelException;
6use App\Models\Invoice;
7use Illuminate\Support\Facades\DB;
8 
9class InvoiceUpdater
10{
11 public function update(int $invoiceId, int $expectedVersion, array $attributes): Invoice
12 {
13 return DB::transaction(function () use ($invoiceId, $expectedVersion, $attributes) {
14 $invoice = Invoice::query()
15 ->whereKey($invoiceId)
16 ->lockForUpdate()
17 ->firstOrFail();
18 
19 if ($invoice->version !== $expectedVersion) {
20 throw new StaleModelException(
21 "Invoice {$invoiceId} was modified by another request "
22 . "(expected v{$expectedVersion}, found v{$invoice->version})."
23 );
24 }
25 
26 $affected = Invoice::query()
27 ->whereKey($invoiceId)
28 ->where('version', $expectedVersion)
29 ->update(array_merge($attributes, [
30 'version' => DB::raw('version + 1'),
31 'updated_at' => now(),
32 ]));
33 
34 if ($affected === 0) {
35 throw new StaleModelException(
36 "Concurrent update detected for invoice {$invoiceId}."
37 );
38 }
39 
40 return $invoice->refresh();
41 });
42 }
43}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1A version column lets you detect concurrent edits without holding locks for the whole request lifecycle.
  2. 2Wrapping the read, check, and write in one transaction with lockForUpdate makes the compare-and-set atomic.
  3. 3Checking the affected-row count guards against races that slip past the in-memory version comparison.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Optimistic locking on invoices in Laravel — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code