DEV Community

Alkin Veysal
Alkin Veysal

Posted on Edited on

Preventing lost updates in Symfony APIs with ETags and Doctrine

There is a concurrency problem in APIs that is easy to miss.

Two clients read the same resource:

Client A -> GET /documents/42
Client B -> GET /documents/42
Enter fullscreen mode Exit fullscreen mode

Both receive the same version.

Client A changes the title and saves it.

Then Client B, still working with the older representation, sends another update.

Without a concurrency check, the second request can overwrite the first one without knowing that the resource changed in the meantime.

This is the classic lost update problem.

Doctrine versioning is important, but there is another part

Doctrine supports optimistic locking with a version field:

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Document
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 180)]
    private string $title;

    #[ORM\Version]
    #[ORM\Column(type: 'integer')]
    private int $version;
}
Enter fullscreen mode Exit fullscreen mode

Doctrine's version field already provides the persistence-level optimistic locking mechanism.

It can also be used directly as the client-side concurrency token. An API could expose the version, have the client send it back with the update, and use that value as Doctrine's expected version. That is a perfectly valid approach, and ETags are not required to prevent lost updates.

For this bundle, I wanted to keep the persistence version out of the API representation and express the same precondition using standard HTTP semantics instead.

I want the client to say:

Update this resource only if it is still the version I previously received.

HTTP already has a mechanism for exactly this: ETag and If-Match.

Using an ETag when reading the resource

I built OptimisticConcurrencyBundle to connect these HTTP semantics with Doctrine versioned entities.

A read endpoint can be marked with #[EntityTag]:

use OptimisticConcurrency\Bundle\Attribute\EntityTag;
use Symfony\Component\HttpFoundation\JsonResponse;

#[EntityTag('document', scope: 'document-detail-v1')]
public function show(Document $document): JsonResponse
{
    return new JsonResponse([
        'id' => $document->getId(),
        'title' => $document->getTitle(),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

The response receives a strong ETag:

HTTP/1.1 200 OK
ETag: "oc1-..."
Enter fullscreen mode Exit fullscreen mode

The default validator is based on the entity identity, its Doctrine version and the optional representation scope.

The actual database ID and version are not exposed directly in the header.

Requiring the same version on update

The write endpoint uses #[RequireIfMatch]:

use Doctrine\ORM\EntityManagerInterface;
use OptimisticConcurrency\Bundle\Attribute\RequireIfMatch;
use Symfony\Component\HttpFoundation\JsonResponse;

#[RequireIfMatch('document', scope: 'document-detail-v1')]
public function update(
    Document $document,
    EntityManagerInterface $entityManager,
): JsonResponse {
    $document->rename('New title');

    $entityManager->flush();

    return new JsonResponse([
        'id' => $document->getId(),
        'title' => $document->getTitle(),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

The client sends back the ETag it received when reading the resource:

PATCH /documents/42
If-Match: "oc1-..."
Content-Type: application/json
Enter fullscreen mode Exit fullscreen mode

If the resource still has the same version, the request continues normally.

If somebody changed it in the meantime:

HTTP/1.1 412 Precondition Failed
Enter fullscreen mode Exit fullscreen mode

If the client does not send If-Match at all:

HTTP/1.1 428 Precondition Required
Enter fullscreen mode Exit fullscreen mode

Malformed conditional headers are rejected with 400 Bad Request.

Why there are two checks

This was the part I cared about most when implementing the bundle.

Checking If-Match before executing the controller is useful, but it is not enough.

Imagine this sequence:

1. If-Match is checked
2. The version is correct
3. Another request updates the row
4. Our controller calls flush()
Enter fullscreen mode Exit fullscreen mode

There is still a race window between steps 1 and 4.

For this reason the bundle does not try to replace Doctrine optimistic locking.

It uses both mechanisms:

HTTP If-Match check
        |
        v
controller executes
        |
        v
Doctrine #[ORM\Version] check during flush()
Enter fullscreen mode Exit fullscreen mode

The HTTP check rejects a request that was already stale when it arrived.

The Doctrine version check protects the final database write if another update happens after the HTTP check.

If Doctrine detects an optimistic lock conflict during flush(), the bundle converts it to 412 Precondition Failed.

I think keeping both responsibilities separate is important.

HTTP handles the client's representation.

Doctrine remains responsible for the atomic database update.

ETags represent representations

There is another detail that is easy to overlook.

An ETag validates a representation, not simply a database row.

For a simple API, entity identity + version may be enough.

But imagine that the response also depends on:

  • locale;
  • serializer groups;
  • related entities;
  • user-specific fields;
  • query parameters.

If those values can change without increasing the entity version, the default ETag is no longer enough to describe the complete representation.

For that reason the bundle supports an explicit scope and a custom EntityTagProviderInterface.

I prefer making this limitation explicit instead of pretending that one Doctrine version field can describe every possible HTTP representation.

A deliberate limitation: hard deletes

There is one case I intentionally don't support: normal Doctrine hard deletes.

Doctrine's standard DELETE operation does not include the optimistic-lock version in the SQL WHERE condition.

That means an If-Match check before the controller would still leave a race window before the actual delete.

So #[RequireIfMatch] rejects HTTP DELETE requests instead of providing a concurrency guarantee that is not really there.

A versioned soft delete is different because it goes through Doctrine's version-checked UPDATE path.

A practical Symfony UX Turbo example

This problem is not limited to JSON APIs.

I ran into the same kind of situation in a Symfony application using UX Turbo.

Imagine an operator opens a reservation page while the entity is at version 12.

Another operator opens the same reservation and changes its status. Doctrine updates the entity and its version becomes 13.

The first browser still has the old page open.

Browser A                        Browser B

GET reservation v12             GET reservation v12
                                 |
                                 change status
                                 |
                                 POST
                                 |
                                 reservation -> v13

old Turbo form still open
|
POST old state
Enter fullscreen mode Exit fullscreen mode

Without a concurrency check, the old form can submit data based on version 12 even though version 13 already exists.

The important part is that Turbo does not change the concurrency problem. It makes navigation and form submissions nicer, but the server still receives an HTTP request that may have been created from stale state.

So the same idea can be used.

When the page is rendered, the current validator is associated with the form. On submission, a small Stimulus integration can send it as:

If-Match: "oc1-..."
Enter fullscreen mode Exit fullscreen mode

If the reservation has not changed, the request continues normally.

If another operator changed it first, the server responds:

HTTP/1.1 412 Precondition Failed
Enter fullscreen mode Exit fullscreen mode

Instead of silently overwriting the newer data, the UI can then tell the user that the reservation changed and should be refreshed.

For me this is one of the useful properties of using HTTP preconditions for concurrency: the same contract works for an API client and for an interactive Symfony application using Turbo.

Turbo improves the interaction.

If-Match tells the server which version that interaction was based on.

Doctrine still protects the final database write.

Installation

The bundle can be installed with Composer:

composer require alkinbg/optimistic-concurrency-bundle
Enter fullscreen mode Exit fullscreen mode

It currently supports:

  • PHP 8.2+
  • Symfony 7.4 LTS and 8.1+
  • Doctrine ORM 3.4.4+

No bundle configuration is required.

The project is MIT licensed:

https://github.com/alkinbg/optimistic-concurrency-bundle

https://packagist.org/packages/alkinbg/optimistic-concurrency-bundle

If you build Symfony APIs and have dealt with lost updates differently, I'd be interested to hear how you handle it.

Top comments (2)

Collapse
 
jessy_pinkman_b51eb0e13b0 profile image
Jessy PInkman

I just really dont understand why do I even need use Etag for lost update, if I have version. I mean yeah I can, but its like inventing bicycle. I would rather use ETag to low my network traffic using cache

Collapse
 
alkin profile image
Alkin Veysal

That's a fair point — an ETag is not strictly necessary if the entity version is already part of your API contract and the client sends that version back as the expected version.

For example, you can expose version: 17 and use Doctrine's optimistic lock with 17 as the expected version. That solves the same cross-request stale-update problem.

The bundle takes a different approach: it keeps the Doctrine version as the persistence source of truth, but exposes the client's expected version through standard HTTP semantics instead of putting the persistence version into the representation.

So conceptually it is:

Doctrine version → ETag → If-Match → precondition check

and Doctrine's #[Version] check still remains the final atomic protection during flush() in case another update happens after the HTTP precondition was checked.

The distinction is also worth making between the two common uses of ETags:

  • If-None-Match + 304 is primarily cache validation;
  • If-Match + 412 is specifically intended by HTTP for conditional writes and preventing lost updates.

So I wouldn't say ETag is required here. If exposing and validating the version directly fits an API, that's perfectly valid. The bundle is mainly about expressing the same concurrency contract as an HTTP precondition while keeping the persistence version out of the API contract.