Optimistic Locking
This content is for v1.0. Switch to the latest version for up-to-date documentation.
Detect lost updates when two contexts write the same row, without holding a database lock between the read and the write.
Optimistic locking is the clearest expression of Articulate’s differentiator. A naive version-per-entity-class lock breaks under context-bounded entities: if only one sibling class mapping a table bumps and checks the version column, another sibling can silently overwrite changes undetected. Articulate makes the contract explicit and per-class — every class mapping a versioned table must account for the version column, either by checking it (#[Version]) or by bumping it without checking (#[VersionAware]).
Two bounded-context classes, one table
Section titled “Two bounded-context classes, one table”A Billing feature maps one physical invoices table through two classes:
#[Entity(tableName: 'invoices')]final class Invoice{ #[PrimaryKey] #[AutoIncrement] public ?int $id = null;
#[Property(name: 'title', maxLength: 160)] public string $title;
// The canonical version column: bumped AND checked on every UPDATE. #[Property] #[Version] public int $version = 0;}
#[Entity(tableName: 'invoices')]#[VersionAware(['version'])]final class InvoiceTitleEdit{ #[PrimaryKey] #[AutoIncrement] public ?int $id = null;
// Narrow edit path: bumps the shared version column but never checks it. #[Property(name: 'title', maxLength: 160)] public string $title;}Invoicecarries#[Version]. EveryUPDATEthrough this class runsversion = version + 1and guards the write withWHERE version = ?.InvoiceTitleEditdeclares class-level#[VersionAware(['version'])]. It bumps the sharedversioncolumn onUPDATEso a full-model writer’s check still fires, but a lightweight title edit doesn’t take on lost-update detection it can’t reason about — it never checks the version itself.
Happy path
Section titled “Happy path”A fresh insert starts at version = 0; the next UPDATE guards on the version it read and bumps the row to 1:
$invoice = new Invoice();$invoice->number = 'INV-1001';$invoice->title = 'Initial invoice';$em->persist($invoice);$em->flush(); // version = 0 after INSERT
$invoice->amount = 150.0;$em->persist($invoice);$em->flush(); // UPDATE ... WHERE version = 0 → row is now version = 1The in-memory #[Version] property is bumped to match the committed row.
Conflict
Section titled “Conflict”When a concurrent writer moves the row first, the stale flush guards on a version the row no longer holds — the UPDATE matches zero rows and Articulate throws:
$invoice = $em->find(Invoice::class, $id); // reads version = 1
// A separate EntityManager loads, mutates, and commits first → row goes to version = 2.
$invoice->amount = 175.0;$em->persist($invoice);
try { $em->flush(); // UPDATE ... WHERE version = 1 matches 0 rows} catch (OptimisticLockException $e) { // stale-version conflict detected}Recovery
Section titled “Recovery”A failed flush does not poison in-memory state: the in-memory #[Version] is not bumped past its pre-flush value, and there’s no “manager is closed” state to reset.
$em->clear()— drop stale identity-map entries.- Re-
find()the current row. - Re-apply your change.
flush()again against the up-to-date version.
$em->clear();$fresh = $em->find(Invoice::class, $id); // reads the current version$fresh->amount = 200.0;$em->persist($fresh);$em->flush(); // succeeds against the up-to-date versionBump-only sibling
Section titled “Bump-only sibling”Editing through the #[VersionAware] sibling bumps the shared column without checking it, keeping a checking sibling’s lost-update detection honest:
$titleEdit = $em->find(InvoiceTitleEdit::class, $id);$titleEdit->title = 'Retitled by the narrow edit path';$em->persist($titleEdit);$em->flush(); // shared version bumps; no WHERE version = ? guardThe next writer through Invoice will now see its own read as stale unless it reloaded after this edit — exactly the safety the bump provides.
Validate coverage (CI-critical)
Section titled “Validate coverage (CI-critical)”There is no runtime enforcement that every class mapping a versioned table accounts for the version column. A class that maps invoices with neither #[Version] nor #[VersionAware] silently drops out of lost-update detection until articulate:validate catches it:
Class "App\Features\Billing\Entity\InvoiceUntracked" does not account for version column "version" on table "invoices".Common pitfalls
Section titled “Common pitfalls”- A
#[Version]property must be typedint. - Do not write the same row through two different
#[Version]-checking classes in one flush — the firstUPDATEbumps the shared column and the second conflicts with itself. Use a#[VersionAware]sibling for the secondary write path instead. OptimisticLockExceptioncannot tell a stale version from a deleted row; treat both as “the row moved on.”- Adding a class that maps a versioned table without declaring
#[Version]or#[VersionAware]compiles and runs — onlyarticulate:validatesurfaces the gap.
Background
Section titled “Background”This per-slice model replaced an earlier row-wide version model in 2.0.0 — see ADR 0001 for the full rationale and rejected alternatives.