DEV Community

Cover image for Pessimistic vs Optimistic Locking in Hexagonal PHP
Gabriel Anhaia
Gabriel Anhaia

Posted on

Pessimistic vs Optimistic Locking in Hexagonal PHP


You ship an order-confirmation endpoint on a Friday afternoon. By Monday the support inbox has three tickets with the same shape: a customer hit Confirm on their checkout, the page hung for a second, they hit it again, and now the order shows the wrong shipping address. Or the wrong total. Or the wrong status. The audit log shows two confirm calls 180 milliseconds apart. Both read the order from the database. Both wrote it back. The second write overwrote the first.

This is the lost-update problem. Two concurrent confirmations of one Order, and the second one wins silently. The database does not warn you. PHP does not warn you. The customer notices in production, where it costs the most.

Two fixes exist. Pessimistic locking grabs the row for the duration of a transaction with SELECT ... FOR UPDATE. Optimistic locking adds a version column and compares-and-sets at write time. Both work, in different places in a hexagonal codebase. Pick the wrong one and you trade a silent race condition for a deadlock storm.

A single order row being read twice and written twice in overlapping transactions, with the second write overwriting the first

The order, the use case, the race

Before the locks, the shape of the code. In a hex layout, the domain owns the Order. The use case ConfirmOrder orchestrates one verb. The port is OrderRepository. The adapter is Postgres.

<?php

declare(strict_types=1);

namespace App\Domain\Order;

final class Order
{
    public function __construct(
        public readonly string $id,
        public readonly string $customerId,
        public OrderStatus $status,
        public string $shippingAddress,
        public int $totalCents,
        public int $version,
    ) {}

    public function confirm(string $shippingAddress): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new OrderAlreadyConfirmed($this->id);
        }
        $this->shippingAddress = $shippingAddress;
        $this->status = OrderStatus::Confirmed;
    }
}
Enter fullscreen mode Exit fullscreen mode

The use case is mechanical. Load the order, mutate it, save it.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\OrderRepository;

final class ConfirmOrder
{
    public function __construct(
        private OrderRepository $orders,
    ) {}

    public function __invoke(ConfirmOrderInput $in): void
    {
        $order = $this->orders->byId($in->orderId);
        $order->confirm($in->shippingAddress);
        $this->orders->save($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the race. Two requests arrive 180 milliseconds apart. Both call byId. Both see status = Pending. Both call confirm. Both call save. The first save lands. The second save overwrites it with whatever address the second request carried. Neither request fails. The order is now in the wrong shape.

The fix lives below the use case, at the boundary between domain and database. The port and adapter exist for exactly this.

Pessimistic locking: SELECT FOR UPDATE inside the transaction

Pessimistic locking says: when I read the row, lock it. Any other transaction that wants the same row waits at the gate until I commit or roll back. In Postgres that is SELECT ... FOR UPDATE inside a transaction.

The port grows a method that signals intent. The application layer is the one that decides "I am about to write this row," so the port exposes that intent in domain language.

<?php

declare(strict_types=1);

namespace App\Domain\Order;

interface OrderRepository
{
    public function byId(string $id): Order;

    public function byIdForUpdate(string $id): Order;

    public function save(Order $order): void;
}
Enter fullscreen mode Exit fullscreen mode

The use case wraps its work in a transaction and asks for the locked read. The port for the unit of work also lives in the domain. Doctrine, PDO, whatever the adapter wires, sits below.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\OrderRepository;
use App\Domain\TransactionManager;

final class ConfirmOrderPessimistic
{
    public function __construct(
        private OrderRepository $orders,
        private TransactionManager $tx,
    ) {}

    public function __invoke(ConfirmOrderInput $in): void
    {
        $this->tx->run(function () use ($in): void {
            $order = $this->orders->byIdForUpdate($in->orderId);
            $order->confirm($in->shippingAddress);
            $this->orders->save($order);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The Postgres adapter implements the lock with raw SQL. The FOR UPDATE clause is the load-bearing line.

<?php

declare(strict_types=1);

namespace App\Adapter\Postgres;

use App\Domain\Order\Order;
use App\Domain\Order\OrderNotFound;
use App\Domain\Order\OrderRepository;
use App\Domain\Order\OrderStatus;
use PDO;

final class PostgresOrderRepository implements OrderRepository
{
    public function __construct(private PDO $pdo) {}

    public function byIdForUpdate(string $id): Order
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, customer_id, status, shipping_address,
                    total_cents, version
               FROM orders
              WHERE id = :id
              FOR UPDATE'
        );
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row === false) {
            throw new OrderNotFound($id);
        }
        return new Order(
            id: $row['id'],
            customerId: $row['customer_id'],
            status: OrderStatus::from($row['status']),
            shippingAddress: $row['shipping_address'],
            totalCents: (int) $row['total_cents'],
            version: (int) $row['version'],
        );
    }

    public function save(Order $order): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE orders
                SET status = :status,
                    shipping_address = :addr,
                    total_cents = :total,
                    version = version + 1
              WHERE id = :id'
        );
        $stmt->execute([
            'status' => $order->status->value,
            'addr'   => $order->shippingAddress,
            'total'  => $order->totalCents,
            'id'     => $order->id,
        ]);
    }

    public function byId(string $id): Order
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, customer_id, status, shipping_address,
                    total_cents, version
               FROM orders
              WHERE id = :id'
        );
        $stmt->execute(['id' => $id]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);
        if ($row === false) {
            throw new OrderNotFound($id);
        }
        return new Order(
            id: $row['id'],
            customerId: $row['customer_id'],
            status: OrderStatus::from($row['status']),
            shippingAddress: $row['shipping_address'],
            totalCents: (int) $row['total_cents'],
            version: (int) $row['version'],
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The transaction manager is its own port. The Postgres adapter wraps beginTransaction / commit / rollBack.

<?php

declare(strict_types=1);

namespace App\Adapter\Postgres;

use App\Domain\TransactionManager;
use PDO;
use Throwable;

final class PdoTransactionManager implements TransactionManager
{
    public function __construct(private PDO $pdo) {}

    public function run(callable $work): mixed
    {
        $this->pdo->beginTransaction();
        try {
            $result = $work();
            $this->pdo->commit();
            return $result;
        } catch (Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Pessimistic, done. Two concurrent confirmations now serialize. The second SELECT ... FOR UPDATE blocks at the gate, waits for the first to commit, and reads the post-commit state. When it then calls confirm, the status is already Confirmed and OrderAlreadyConfirmed fires. The race is dead.

What pessimistic locking costs:

  • Every conflicting transaction blocks until the leader commits. A slow confirm (say it calls a payment gateway inside the transaction) means everyone behind it waits.
  • Lock-ordering bugs become deadlocks. If ConfirmOrder locks orders then inventory and ReleaseHold locks inventory then orders, Postgres picks a victim and kills the transaction.
  • Connection pressure. Every locked transaction holds a connection for the duration.

Pessimistic locking is the right tool when the critical section is short, the contention is real, and you genuinely need other writers to wait. Avoid it when the work inside the transaction is slow or talks to external services.

Optimistic locking: a version column and a domain exception

Optimistic locking says: don't grab the row. Read it normally. Remember the version you saw. When you write back, the UPDATE carries WHERE id = ? AND version = ?. If no row matched, somebody else got there first and you raise a domain exception. The caller decides whether to retry, surface, or merge.

The domain grows an exception. The use case does not change in shape, only in the error path it handles.

<?php

declare(strict_types=1);

namespace App\Domain\Order;

final class OptimisticLockException extends \RuntimeException
{
    public function __construct(public readonly string $orderId)
    {
        parent::__construct(
            "order {$orderId} was modified by another request"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The adapter's save becomes a compare-and-set. The WHERE version = ? clause is the load-bearing line, and the row-count check is what turns a silent overwrite into a thrown exception.

public function save(Order $order): void
{
    $stmt = $this->pdo->prepare(
        'UPDATE orders
            SET status = :status,
                shipping_address = :addr,
                total_cents = :total,
                version = version + 1
          WHERE id = :id
            AND version = :version'
    );
    $stmt->execute([
        'status'  => $order->status->value,
        'addr'    => $order->shippingAddress,
        'total'   => $order->totalCents,
        'id'      => $order->id,
        'version' => $order->version,
    ]);

    if ($stmt->rowCount() === 0) {
        throw new OptimisticLockException($order->id);
    }
}
Enter fullscreen mode Exit fullscreen mode

The use case stays clean. No transaction wrapper, no FOR UPDATE, no holding a connection for the duration of a payment call.

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\OrderRepository;

final class ConfirmOrderOptimistic
{
    public function __construct(
        private OrderRepository $orders,
    ) {}

    public function __invoke(ConfirmOrderInput $in): void
    {
        $order = $this->orders->byId($in->orderId);
        $order->confirm($in->shippingAddress);
        $this->orders->save($order);
    }
}
Enter fullscreen mode Exit fullscreen mode

When two confirmations race now, both load version 7. Both compute their changes. The first UPDATE lands and the row is at version 8. The second UPDATE runs WHERE version = 7 and zero rows match. rowCount() returns 0. The adapter throws OptimisticLockException. The use case lets it bubble. The inbound adapter (HTTP, queue, CLI) decides what to do.

A retry loop at the use-case layer is fine when the operation is idempotent and the conflict is rare:

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Order\OptimisticLockException;
use App\Domain\Order\OrderRepository;

final class ConfirmOrderWithRetry
{
    public function __construct(
        private OrderRepository $orders,
        private int $maxAttempts = 3,
    ) {}

    public function __invoke(ConfirmOrderInput $in): void
    {
        for ($attempt = 1; ; $attempt++) {
            try {
                $order = $this->orders->byId($in->orderId);
                $order->confirm($in->shippingAddress);
                $this->orders->save($order);
                return;
            } catch (OptimisticLockException $e) {
                if ($attempt >= $this->maxAttempts) {
                    throw $e;
                }
                usleep(random_int(10_000, 50_000));
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

That loop lives at the application layer because retry policy is an application concern, not a domain rule. The domain knows something raced; the adapter knows zero rows matched; OptimisticLockException is the bridge.

What optimistic locking costs:

  • The caller has to handle a new failure mode. OptimisticLockException is not optional; every write path that could race needs a story for what to do when it fires.
  • Under high contention, retry storms can hurt worse than FOR UPDATE. Five clients all retrying three times is fifteen reads and fifteen failed writes when one FOR UPDATE would have served them in order.
  • The schema needs a version column on every row that can race, and every UPDATE has to carry it. Forgetting the predicate on one write path silently disables the protection.

Optimistic locking fits when contention is low, the critical path is long, or the write happens after a slow external call. Skip it when conflicts are the common case and retries cost more than waiting.

Two writers both reading version 7, the first UPDATE bumping to 8, the second UPDATE matching zero rows and raising OptimisticLockException

Where each lock lives in the hex layout

Both strategies sit at the same place in the diagram, between the application's save call and the database row. The difference is which side of the port owns the lock.

Pessimistic locking lives in the adapter. The use case asks for byIdForUpdate and wraps the work in a TransactionManager.run. Everything else (the FOR UPDATE clause, the BEGIN / COMMIT, the connection lifetime) is infrastructure. The domain never names Postgres, never names a connection, and never thinks about lock modes. It just says "I am about to write this; load it accordingly."

Optimistic locking lives in the domain and the adapter together. The version field on the entity and the OptimisticLockException type belong to the domain. The WHERE version = ? predicate and the rowCount() check that translates "no rows matched" into the domain exception belong to the adapter. Retry policy belongs to the application layer.

Both keep the use case ignorant of SQL. That is the test: if ConfirmOrder mentions BEGIN, FOR UPDATE, rowCount, or any string of SQL, the boundary leaked. Push the SQL down into the adapter, push the intent up into the port name (byIdForUpdate) or the entity field (version), and let the use case stay readable.

How to pick

Pessimistic fits when conflicts are common, the critical section is short, and external calls live outside the transaction. Reach for optimistic when conflicts are rare, the work between read and write is long, or the operation crosses a network boundary the database does not know about. Mixing both inside one codebase is fine; they target different shapes of contention, not different philosophies.

The right one depends on whether you can afford to wait, or whether you can afford to retry. The wrong one is doing nothing and hoping last-write-wins is a good policy. It never is.


If this was useful

The full chapter on transactions, locks, and unit-of-work in PHP lives in Decoupled PHP, including the parts where the locking strategy interacts with retries, idempotency keys, and outbox writes inside the same hex layout. The Database Playbook goes further on the storage side: when row-level locks stop scaling, when to reach for advisory locks or queues, and when the answer is a different store entirely.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)