Skip to content

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.

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;
}
  • Invoice carries #[Version]. Every UPDATE through this class runs version = version + 1 and guards the write with WHERE version = ?.
  • InvoiceTitleEdit declares class-level #[VersionAware(['version'])]. It bumps the shared version column on UPDATE so 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.

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 = 1

The in-memory #[Version] property is bumped to match the committed row.

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
}

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.

  1. $em->clear() — drop stale identity-map entries.
  2. Re-find() the current row.
  3. Re-apply your change.
  4. 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 version

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 = ? guard

The next writer through Invoice will now see its own read as stale unless it reloaded after this edit — exactly the safety the bump provides.

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".
  • A #[Version] property must be typed int.
  • Do not write the same row through two different #[Version]-checking classes in one flush — the first UPDATE bumps the shared column and the second conflicts with itself. Use a #[VersionAware] sibling for the secondary write path instead.
  • OptimisticLockException cannot 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 — only articulate:validate surfaces the gap.

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.