DEV Community

Alkin Veysal
Alkin Veysal

Posted 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

The version check is important because it makes the database update safe against concurrent changes between loading the entity and flushing it.

But for an HTTP API, I also 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 (0)