DEV Community

Cover image for Handling Concurrent Requests in Symfony with Doctrine and PostgreSQL
Mykola Vantukh
Mykola Vantukh

Posted on

Handling Concurrent Requests in Symfony with Doctrine and PostgreSQL

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:

Two requests read AVAILABLE and pass their checks. B waits for A to commit, then overwrites the seat owner because its UPDATE checks only the seat ID

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();
Enter fullscreen mode Exit fullscreen mode

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);
    });
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The current EntityManager loads a seat as AVAILABLE.
  2. Another transaction changes it to HELD and commits.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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,
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)