Preventing Lost Updates with Doctrine Optimistic Locking
Prevent data loss in concurrent environments using Doctrine ORM's optimistic locking. Learn to implement @Version, handle lock exceptions, and identify when to switch to pessimistic locking.
04 Jul 2026, 05:35 UTC

The Lost Update Problem
In concurrent environments, two separate processes may load the same database entity, modify different fields, and save the changes. Without locking, the process that saves last silently overwrites the changes made by the first, leading to a "lost update." Optimistic locking solves this by ensuring the entity has not changed since it was last read before allowing an update to commit.
Minimal Design Requirements
The smallest viable implementation requires a versioning column, a mechanism to catch conflict exceptions, and a strategy for resolution (either a retry or a user notification).
1. Entity Configuration
Add a non-null integer field to the entity and mark it with the @Version annotation. Doctrine automatically manages this value, incrementing it every time the entity is updated.
/**
* @Entity
*/
class InventoryItem
{
/** @Id @Column(type="integer") @GeneratedValue */
private $id;
/** @Column(type="integer") */
private $stockQuantity;
/** @Version @Column(type="integer") */
private $version;
// Getters and setters
}
2. Service Layer Implementation
The service layer must encapsulate the transaction and handle the Doctrine\ORM\OptimisticLockException. Because the entity state is stale when this exception occurs, the process must reload the entity from the database to obtain the current version before retrying.
use Doctrine\ORM\OptimisticLockException;
class InventoryService
{
private $entityManager;
private $maxRetries = 3;
public function updateStock(int $itemId, int $adjustment): void
{
$attempts = 0;
while ($attempts < $this->maxRetries) {
try {
$item = $this->entityManager->find(InventoryItem::class, $itemId);
$item->setStockQuantity($item->getStockQuantity() + $adjustment);
$this->entityManager->flush();
return;
} catch (OptimisticLockException $e) {
$attempts++;
$this->entityManager->detach($item);
if ($attempts >= $this->maxRetries) {
throw new \RuntimeException("Concurrent update limit reached", 0, $e);
}
}
}
}
Trust and Data Boundaries
- Application Boundary: The application manages the business logic and the retry loop. It trusts the entity's version number as the marker of state consistency.
- Database Boundary: The database enforces the integrity. Doctrine implements this by adding the version to the
WHEREclause of theUPDATEstatement. If zero rows are affected, Doctrine knows the version has changed and throws the exception.
Operational Checks
To verify the system is behaving as expected, use the following checks:
- SQL Inspection: Enable SQL logging in your development environment. Verify that
UPDATEqueries follow this pattern:UPDATE inventory_item SET stockQuantity = ?, version = 2 WHERE id = ? AND version = 1. - Exception Monitoring: Track the frequency of
OptimisticLockExceptionin your logs. While occasional conflicts are normal, a high rate indicates high contention on specific rows. - Nullability Check: Ensure the version column is defined as
NOT NULLin the database schema. If the column is nullable, Doctrine may skip the version check entirely.
Failure Modes and Redesign Triggers
Optimistic locking is not a universal solution. Be aware of these failure modes:
- Non-Idempotent Retries: If the logic inside the retry loop has side effects (e.g., sending an email), those effects will repeat on every conflict. Ensure the loop only contains database state changes.
- High Contention: If the exception rate exceeds a manageable threshold (e.g., 5-10% of requests), the overhead of retries and reloading entities will degrade performance.
- Long-Running Conversations: If an entity is loaded in one request, edited in a UI, and saved in a second request minutes later, the version must be passed to the client and back to the server to maintain the lock across requests.
When to redesign: If contention is consistently high, move to Pessimistic Locking (using LockMode::PESSIMISTIC_WRITE) to block other processes at the database level, or implement a Message Queue to serialize updates to the same entity.
Verification Method
To verify the implementation, execute two concurrent PHP processes:
- Process A loads Entity 1.
- Process B loads Entity 1.
- Process A modifies a value and calls
flush(). (Success: Version increments to 2). - Process B modifies a value and calls
flush(). (Failure: Process B's version is still 1, but the DB is 2). - Confirm Process B receives an
OptimisticLockException.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.