Optimistic Locking
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. The standard “one table, one entity” version lock assumes every writer goes through the same class, so bumping and checking a single version column is enough. Context-bounded entities break that assumption — several classes can write the same row — so Articulate makes the version contract explicit and per-class instead: every class mapping a versioned table must account for the version column, either by checking it (#[Version]) or by bumping it without checking (#[VersionAware]). The result is more correct lost-update detection than a naive row-wide lock: a lightweight sibling class can’t silently skip the check and let an unguarded write slip past another class’s version guard undetected.
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.