Locking

Optimistic locking detects conflicting writes with a version column; pessimistic locking makes other transactions wait for the row.

Syntax$em->find(Invoice::class, $id, LockMode::OPTIMISTIC, $expectedVersion)

Two requests load the same row, both change it, and the second write silently discards the first. That is a lost update. Locking is how you stop it. Doctrine offers two strategies, and the right one depends on how often you expect a clash.

Optimistic locking

Add a version field: a column marked #[ORM\Version], typed integer or datetime. Doctrine then raises it on every update and adds the old value to the UPDATE's WHERE clause. If no row matches, somebody saved first, and flush() throws OptimisticLockException. Nothing is ever locked; conflicts are detected after the fact. To check a version the user was shown minutes ago, pass it when loading. The shorthand is $em->find(Invoice::class, $id, LockMode::OPTIMISTIC, $expected); the longhand is find() followed by $em->lock($invoice, LockMode::OPTIMISTIC, $expected). Both throw the same exception on a mismatch.

Pessimistic locking

LockMode::PESSIMISTIC_WRITE asks the database for a row lock, which is SELECT ... FOR UPDATE on most platforms. Another transaction wanting that row waits. LockMode::PESSIMISTIC_READ blocks writers but lets readers through. These require an open transaction, and waiting can end in LockWaitTimeoutException or DeadlockException from Doctrine\DBAL\Exception.

Choosing

Clashes rare, work cheap to redo, such as editing an article: optimistic. Nobody waits, and the occasional conflict becomes a message asking the user to reload. Clashes likely, or redoing the work is unacceptable, such as handing out the last seat or moving money: pessimistic. In DBAL 4 LockMode is an enum rather than integer constants, so the names read the same but comparing one to an int no longer works.

Example

Example · php
<?php
// src/Service/InvoiceEditor.php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Invoice;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use RuntimeException;

final class InvoiceEditor
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    /**
     * $expectedVersion is the version number the editing screen was built from.
     */
    public function rename(int $id, string $newTitle, int $expectedVersion): Invoice
    {
        // Mismatch here throws OptimisticLockException straight away.
        $invoice = $this->entityManager->find(
            Invoice::class,
            $id,
            LockMode::OPTIMISTIC,
            $expectedVersion,
        );

        if ($invoice === null) {
            throw new RuntimeException(sprintf('Invoice %d no longer exists.', $id));
        }

        $invoice->rename($newTitle);

        try {
            // The UPDATE carries the version in its WHERE clause.
            $this->entityManager->flush();
        } catch (OptimisticLockException $e) {
            // Nothing was written. Like any failed flush, the manager is now closed.
            throw new RuntimeException('The invoice changed while you were editing it.', 0, $e);
        }

        return $invoice;
    }
}

When to use it

  • Stop two editors overwriting each other on a long form by carrying a version number through the page.
  • Hand out the last unit of limited stock without overselling it.
  • Serialise a balance update so two concurrent debits cannot both read the old total.

More examples

The version column on the entity

This is all the mapping optimistic locking needs. The property has no default: the value arrives when the row is inserted. Expose a getter so the editing screen can carry the version in a hidden field and hand it back on save, which is what turns a database-level check into a useful message for a user who had the page open for ten minutes. #[ORM\Version] is not allowed on an identifier property, and the column must be an integer or a datetime type.

Example · php
<?php
// src/Entity/Invoice.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

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

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

    // Doctrine owns this column. Application code reads it and never writes it.
    #[ORM\Version]
    #[ORM\Column(type: Types::INTEGER)]
    private int $version;

    public function __construct(string $title)
    {
        $this->title = $title;
    }

    public function getVersion(): int
    {
        return $this->version;
    }

    public function rename(string $title): void
    {
        $this->title = $title;
    }
}

A pessimistic lock inside a transaction

Allocating the last seat is the case where optimistic locking is the wrong tool: a conflict is likely and the caller cannot simply be told to try again later. The lock is held from the SELECT until the commit, so everything inside the callback should be fast and nothing in it should call a remote service. Concurrent callers queue; if the wait exceeds the database's limit they get LockWaitTimeoutException, which is worth catching and reporting as a busy message.

Example · php
<?php
// src/Service/SeatAllocator.php

declare(strict_types=1);

namespace App\Service;

use App\Entity\Seat;
use App\Entity\SeatPool;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use RuntimeException;

final class SeatAllocator
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function allocate(int $poolId, string $holder): Seat
    {
        return $this->entityManager->wrapInTransaction(
            function (EntityManagerInterface $em) use ($poolId, $holder): Seat {
                // Shorthand: load and lock in one statement (SELECT ... FOR UPDATE).
                $pool = $em->find(SeatPool::class, $poolId, LockMode::PESSIMISTIC_WRITE);

                // Longhand: load first, then lock. The lock arrives later, and
                // the already loaded values are not re-read:
                //   $pool = $em->find(SeatPool::class, $poolId);
                //   $em->lock($pool, LockMode::PESSIMISTIC_WRITE);

                if ($pool === null) {
                    throw new RuntimeException('Unknown seat pool.');
                }

                $seat = $pool->take($holder); // throws when the pool is empty
                $em->persist($seat);

                return $seat; // flush and commit happen on the way out
            },
        );
    }
}

Discussion

  • Be the first to comment on this lesson.