PHP cookbook · Advanced

Retry deadlocked transactions in PHP

Handle deadlocks and serialization failures gracefully in high-concurrency write paths.

Approach

Deadlocks and serialization failures are normal under concurrency; the database aborts one transaction, which can be safely retried. Wrap the unit of work in a helper that retries a few times with jittered backoff on specific SQLSTATE codes (40001 serialization failure, 40P01 PostgreSQL deadlock; MySQL reports deadlocks as 40001/error 1213).

Solution

<?php
declare(strict_types=1);

/**
 * @template T
 * @param callable(PDO): T $work
 * @return T
 */
function transactional(PDO $pdo, callable $work, int $maxAttempts = 3): mixed
{
    for ($attempt = 1; ; $attempt++) {
        $pdo->beginTransaction();
        try {
            $result = $work($pdo);
            $pdo->commit();
            return $result;
        } catch (PDOException $e) {
            $pdo->rollBack();
            $retryable = in_array($e->getCode(), ['40001', '40P01'], true);
            if (!$retryable || $attempt >= $maxAttempts) {
                throw $e;
            }
            usleep(random_int(20_000, 100_000) * $attempt);   // jittered backoff
        } catch (Throwable $e) {
            $pdo->rollBack();
            throw $e;
        }
    }
}

$orderId = transactional($pdo, function (PDO $pdo) use ($cart) {
    reserveStock($pdo, $cart);
    return createOrder($pdo, $cart);
});

Pitfalls

  • Retrying work with external side effects (emails, charges) inside the closure.
  • Retrying forever.
  • Not reducing deadlock likelihood: lock rows in a consistent order, keep transactions short.

Variations

  • Doctrine DBAL: $connection->transactional() plus catching RetryableException.
  • Laravel: DB::transaction(fn () => ..., attempts: 3).

Further reading: PostgreSQL: Serialization failure handling

Reference: https://www.postgresql.org/docs/current/mvcc-serialization-failure-handling.html

More PHP recipes