Two requests reserve the last available seat. Two workers decrement the same stock quantity. Two administrators update the same order.
Each operation can work correctly on its own and still produce an incorrect result when executed concurrently. In Symfony applications using Doctrine, the solution requires coordinating database access and making sure the ORM exposes the state that was actually locked.
This article uses seat reservations as an example, but the same approach applies to inventory, balances and guarded status transitions.
Scope: PostgreSQL at
READ COMMITTED. Code snippets are simplified; links point to concrete implementations in a repository.
Here is the race the following sections will address:
Waiting for a row lock does not repeat the application's earlier availability check.
The core rule is:
Protect the whole decision: Read current state under the chosen concurrency mechanism, validate the invariant, and commit the corresponding changes together.
Different operations need different mechanisms. Before looking at pessimistic locking in detail, it helps to have the overall decision model:
| Problem | Typical mechanism |
|---|---|
| Read current state, validate several conditions, then modify | Pessimistic locking with SELECT ... FOR UPDATE
|
| Perform a simple guarded state transition | Conditional UPDATE
|
| Handle edits spanning multiple HTTP requests | Optimistic locking |
| Prevent concurrent creation of the same logical record |
UNIQUE constraint or primary key with ON CONFLICT
|
The rest of the article explains why these mechanisms solve different concurrency problems and what Doctrine adds on top of PostgreSQL's behavior.
Why a transaction alone is not enough
Consider a typical read–check–write operation:
$seat = $seatRepository->find($seatId);
if (!$seat->isAvailable()) {
throw new SeatNotAvailableException($seatId);
}
$seat->holdFor($reservationId);
$entityManager->flush();
Two requests can both read AVAILABLE before either writes its change:
| Step | Request A | Request B |
|---|---|---|
| 1 | Reads AVAILABLE | Reads AVAILABLE |
| 2 | Availability check passes | Availability check passes |
| 3 | Updates owner to A | Waits to update the same row |
| 4 | Commits | Updates owner to B and commits |
Wrapping this code in a transaction makes each operation atomic, but does not prevent both availability checks from passing.
PostgreSQL serializes the writes. However, an update with only WHERE id = :id can still overwrite the previous owner: its condition remains true after waiting. The application made its business decision using an earlier state. This follows PostgreSQL's READ COMMITTED behavior.
Key rule: Protect the availability check together with the write.
Pessimistic locking: protect the entire decision
For a short operation that reads an entity, validates its state and updates it, Doctrine provides LockMode::PESSIMISTIC_WRITE.
On PostgreSQL, a locking query uses SELECT ... FOR UPDATE. A competing transaction requesting the same lock waits until the current holder commits or rolls back.
Service: define the transaction boundary
The transaction belongs around the entire use case. A simplified service method looks like this:
public function reserve(
string $showId,
string $seatId,
string $reservationId,
): void {
$this->entityManager->wrapInTransaction(function () use (
$showId, $seatId, $reservationId,
): void {
$seat = $this->seatRepository->findForUpdate($showId, $seatId);
if ($seat === null || !$seat->isAvailable()) {
throw new SeatNotAvailableException($seatId);
}
$seat->holdFor($reservationId);
});
}
wrapInTransaction() flushes before committing. The transaction must already be active when the locking query executes; adding a lock to an ordinary repository call outside a transaction is insufficient.
The controller only calls the service and maps the result to HTTP. This keeps the same transaction boundary usable from HTTP, CLI and Messenger handlers.
Keep external HTTP calls and other slow work outside the locked section.
Repository: acquire the lock
The repository method contains the locking query:
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\Query;
public function findForUpdate(string $showId, string $seatId): ?Seat
{
return $this->createQueryBuilder('s')
->andWhere('s.id = :seatId')
->andWhere('IDENTITY(s.show) = :showId')
->setParameter('seatId', $seatId)
->setParameter('showId', $showId)
->getQuery()
->setLockMode(LockMode::PESSIMISTIC_WRITE)
->setHint(Query::HINT_REFRESH, true)
->getOneOrNullResult();
}
When request A commits a hold, request B acquires the lock and checks the held state. If A rolls back, B can acquire the still-available seat.
Code examples: locking query and service transaction boundary.
Doctrine can still hold an outdated entity
The query above includes Query::HINT_REFRESH for a reason.
Doctrine maintains an identity map: within one EntityManager, an entity identity normally corresponds to the same PHP object. If that object was loaded earlier, executing another query does not normally replace its fields with the returned database values.
Consider this sequence:
- The current EntityManager loads a seat as
AVAILABLE. - Another transaction changes it to
HELDand commits. - The current EntityManager executes a locking query for that seat.
The database returns the current locked row, while Doctrine can retain the previously managed object:
PostgreSQL row: HELD
PHP object: AVAILABLE
The lock protects the row, but the availability check can still use stale state.
HINT_REFRESH makes Doctrine refresh an already managed entity from the query result. This is documented in Doctrine's query hints.
Refresh before modifying: Refreshing can overwrite unflushed changes. If the entity is being loaded for the first time, there is no earlier managed state to replace.
A refreshing locking query should therefore be treated as an acquisition boundary for the aggregate: acquire the lock and load the current state before making changes. Avoid calling such a query after modifying the same managed entity unless discarding those unflushed modifications is intentional.
Acquire the lock → hydrate current state → validate → modify → commit.
Locking after an earlier availability check leaves the race open.
Multiple rows: consistent lock order
When one operation needs several rows, acquire pessimistic locks in a consistent order. Different acquisition orders can cause a deadlock:
Transaction A: locks seat 1, waits for seat 2
Transaction B: locks seat 2, waits for seat 1
Every competing code path must follow the same order. Sorting IDs in PHP only helps if the repository actually acquires locks in that sequence; the order of values inside SQL IN (...) does not guarantee it.
Consistent ordering reduces deadlocks for this resource set but does not eliminate transaction-level deadlocks involving other resources.
Code example: ordered lock acquisition.
Limit how long a request waits for a lock
Choose a lock_timeout within the operation's latency budget and apply it with SET LOCAL inside the transaction.
A lock timeout means the operation could not acquire a lock in time; it does not establish that the seat is unavailable.
The timeout also applies to locks acquired implicitly by UPDATE and INSERT, including the alternative strategies below.
Choose the concurrency strategy for the operation
Pessimistic locking is useful when a short transaction needs to inspect current entities before changing them. It is not the only option.
Conditional UPDATE: simple state transitions
For a simple state transition, move the precondition into the write:
UPDATE seats
SET state = 'HELD',
held_by_reservation_id = :reservationId
WHERE id = :seatId
AND state = 'AVAILABLE';
With Doctrine DBAL, inspect the affected-row count. One changed row means the transition succeeded; zero means the seat was missing or unavailable.
For a multi-row operation, compare the count with the number of unique requested IDs and roll back partial changes before committing.
Conditional updates still acquire locks and can wait. Direct SQL also bypasses Doctrine's managed state, so avoid making subsequent decisions from entities that have become stale.
Optimistic locking: edits spanning HTTP requests
Optimistic locking is another choice when conflicts are infrequent, especially for edits spanning user interaction.
With a #[ORM\Version] field, Doctrine checks the entity's version against the database when updating it at flush and raises OptimisticLockException on a mismatch.
The application then needs an explicit conflict response or retry policy. Do not hold a database transaction open while a user edits a form.
See Doctrine's locking support.
For edits spanning HTTP requests, preserve the version originally shown to the user and check that expected version when processing the submission, for example:
$entityManager->lock(
$entity,
LockMode::OPTIMISTIC,
$expectedVersion,
);
Loading the latest entity on submission and relying only on the check at flush can still overwrite changes made while the form was open.
See Doctrine's optimistic-locking implementation notes.
Concurrent INSERT: let a unique key decide
What if the row does not exist yet?
At READ COMMITTED, a SELECT ... FOR UPDATE that finds no row does not reserve that key. Two requests can both pass an existence check and attempt an insert.
Define what makes the record unique with a UNIQUE constraint or PRIMARY KEY, then handle the expected duplicate with ON CONFLICT:
INSERT INTO inbox_messages (
consumer_name,
message_id,
message_type,
processed_at
)
VALUES (
:consumerName,
:messageId,
:messageType,
:processedAt
)
ON CONFLICT (consumer_name, message_id) DO NOTHING;
Here, (consumer_name, message_id) is the primary key.
With DBAL's executeStatement(), inspect the affected-row count:
- 1: this transaction inserted the claim and can process the message.
- 0: the claim already exists; skip duplicate processing.
If another transaction is inserting the same key, PostgreSQL can wait for its outcome. If it commits, the waiting insert skips the duplicate; if it rolls back, the waiting insert can succeed.
One transaction: Commit the claim and the handler's database changes together, using the same connection. Otherwise, a committed claim followed by failed processing could make retries skip unfinished work.
DO NOTHING handles this expected conflict without a unique-violation error.
Use DO UPDATE when updating the existing record is the intended behavior.
Neither option automatically validates whether duplicate requests carry the same payload.
See PostgreSQL's ON CONFLICT documentation.
Code example: InboxMessageStore::claim() returns whether the insert succeeded; the middleware invokes the handler only for a successful claim.
Handle contention and transaction failures
Not every conflict should be retried. A seat that is already held is a valid business outcome; a lock timeout or deadlock is a transient infrastructure failure.
For transient failures, retry the entire transaction, not only the failed statement. After rollback, start from a fresh Doctrine EntityManager and reload the current state.
Keep retries bounded and within the request's latency budget. Repeated immediate retries can increase contention, so use a small retry limit with backoff and monitor lock waits, deadlocks and exhausted retries.
Final rule
Transactions, row locks, version fields and unique constraints solve different parts of the concurrency problem.
The important part is not choosing the most powerful mechanism. It is choosing the mechanism that protects the invariant at the point where the decision is made.
Protect the whole decision: Read current state under the chosen concurrency mechanism, validate the invariant, and commit the corresponding changes together.

Top comments (0)