DEV Community

Cover image for Idempotency Keys in PHP: Stop Charging Customers Twice
Gabriel Anhaia
Gabriel Anhaia

Posted on

Idempotency Keys in PHP: Stop Charging Customers Twice


The mobile app sends POST /payments. The request takes nine seconds. At second eight, the user's LTE drops. The phone never sees the response, so it retries with the same payload. Your API takes the second call, runs it as a fresh request, and charges the card again. Two debits hit the bank statement.

Now swap the actors. A background worker processes a queue, the broker redelivers because the ack timed out, and your charge-card use case fires twice for one message. Same outcome.

Both stories end with a refund, a support ticket, and a postmortem. The fix is the same in both cases, and it has a name: idempotency keys. Stripe, Adyen, and GoCardless all use them. It's an HTTP-adapter pattern that any PHP service taking money, sending email, or creating records can adopt in a day.

This post walks the implementation in PHP 8.3: middleware, a Postgres table, the unique constraint that makes the race condition impossible, and where the boundary sits between the adapter and the domain. The headline rule: the use case does not know idempotency exists.

What an idempotency key actually does

An idempotency key is a client-generated string (usually a UUID) sent on the request:

POST /payments HTTP/1.1
Idempotency-Key: 8f3a91b2-7e4d-4a1c-9c5e-2a8f0d1e6b3c
Content-Type: application/json

{"customer_id":"cust_42","amount_cents":1999,"currency":"EUR"}
Enter fullscreen mode Exit fullscreen mode

The server's contract:

  1. The first time it sees this key, it runs the request, stores the response, and returns it.
  2. Every later request with the same key returns the stored response, byte-for-byte. The use case never runs again.
  3. If a second request shows up with the same key but a different body, the server rejects it with 422. That catches client bugs where the same key got reused for a different operation.

The window for "later" is usually 24 hours. Long enough to cover retry storms, network reconnects, and queue redelivery, but short enough that storage cost stays bounded.

The point worth chewing on: this is a transport-layer concern. The HTTP request was retried; the customer never asked to be charged twice. So the deduplication belongs in the layer that owns HTTP, not in the layer that owns money.

Idempotency-Key flow showing first request running the use case, retry returning the cached response

Where it lives in a hexagonal PHP app

The shape of the service:

src/
├── Domain/                  # pure PHP, no Symfony, no Doctrine
│   └── Payment/
│       ├── Payment.php
│       └── PaymentGateway.php          # port
├── Application/
│   └── UseCase/
│       └── ChargeCustomer.php          # the use case
└── Infrastructure/
    └── Http/
        ├── PaymentsController.php
        ├── IdempotencyMiddleware.php   # the new piece
        └── IdempotencyStore.php        # port + Postgres adapter
Enter fullscreen mode Exit fullscreen mode

The use case ChargeCustomer knows nothing about HTTP, headers, or retries. It takes a ChargeRequest value object, calls the PaymentGateway port, and returns a Payment aggregate. If you call it twice with the same arguments, it charges the customer twice. That is the correct behavior for a use case: it does what you ask.

The middleware sits in front of the controller. It reads Idempotency-Key, checks the store, and either short-circuits the response or lets the request proceed and captures the result on the way out. The controller and use case never see the header.

Why this split matters: tomorrow you add a CLI command that runs ChargeCustomer from a CSV. The CSV doesn't have HTTP headers, and it doesn't need them. The CSV importer can do its own dedupe. The day after, you add a queue worker: same use case, different inbound adapter, with the message ID standing in for the header. The domain stays clean because the deduplication never crawled into it.

The storage table

Postgres, with a unique constraint that is doing the actual work:

CREATE TABLE idempotency_records (
    key              TEXT        PRIMARY KEY,
    request_hash     TEXT        NOT NULL,
    response_status  SMALLINT,
    response_body    JSONB,
    response_headers JSONB,
    state            TEXT        NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    completed_at     TIMESTAMPTZ
);

CREATE INDEX idempotency_created_at_idx
    ON idempotency_records (created_at);
Enter fullscreen mode Exit fullscreen mode

Three pieces:

  • key is the client's UUID, primary key. The uniqueness here is the whole point.
  • request_hash is sha256(method + path + body). Catches the "same key, different payload" mistake.
  • state is in_progress or completed. Set to in_progress when the request starts, flipped to completed when the response is written. If you find an in_progress row, the original request is still running (or crashed mid-flight) and you handle it deliberately, usually with 409 Conflict.

The unique primary key on key is the race-condition killer. Two simultaneous requests with the same key both try to INSERT. One wins. The other gets a unique-violation error from Postgres, which the adapter translates into "this key already exists, read it." Postgres' B-tree primary key serializes the race for free.

The middleware

PSR-15 middleware in PHP 8.3. Works under any framework that adapts to PSR-15 (Laravel via laravel/http-server, Symfony via the PSR-15 bridge).

<?php

declare(strict_types=1);

namespace App\Infrastructure\Http;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

final readonly class IdempotencyMiddleware implements MiddlewareInterface
{
    public function __construct(
        private IdempotencyStore $store,
        private ResponseFactory $responses,
    ) {}

    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $key = $request->getHeaderLine('Idempotency-Key');

        if ($key === '' || !$this->isMutating($request)) {
            return $handler->handle($request);
        }

        $hash = $this->hashRequest($request);
        $existing = $this->store->reserve($key, $hash);

        if ($existing instanceof Conflict) {
            return $this->responses->json(422, [
                'error' => 'idempotency_key_reused_with_different_body',
            ]);
        }

        if ($existing instanceof InProgress) {
            return $this->responses->json(409, [
                'error' => 'request_in_progress',
            ]);
        }

        if ($existing instanceof Completed) {
            return $this->responses->replay(
                $existing->status,
                $existing->body,
                $existing->headers,
            );
        }

        $response = $handler->handle($request);

        $this->store->complete(
            $key,
            $response->getStatusCode(),
            (string) $response->getBody(),
            $response->getHeaders(),
        );

        return $response;
    }

    private function isMutating(ServerRequestInterface $request): bool
    {
        return in_array(
            $request->getMethod(),
            ['POST', 'PUT', 'PATCH', 'DELETE'],
            true,
        );
    }

    private function hashRequest(ServerRequestInterface $request): string
    {
        $body = (string) $request->getBody();
        $request->getBody()->rewind();

        return hash('sha256',
            $request->getMethod()
            . '|' . $request->getUri()->getPath()
            . '|' . $body,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Three outcomes, three branches, plus the happy path. Note what is not here: business validation, payment gateway calls, transaction management. The middleware is purely about the HTTP-level dedupe. Everything else is downstream.

The store and the unique-constraint trick

The IdempotencyStore interface is a port, defined in Infrastructure\Http because that is where the concept lives. The Postgres adapter implements it.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Http;

interface IdempotencyStore
{
    public function reserve(string $key, string $hash): ReservationResult;

    public function complete(
        string $key,
        int $status,
        string $body,
        array $headers,
    ): void;
}

abstract class ReservationResult {}
final class Fresh extends ReservationResult {}
final class InProgress extends ReservationResult {}
final class Conflict extends ReservationResult {}

final class Completed extends ReservationResult
{
    public function __construct(
        public readonly int $status,
        public readonly string $body,
        public readonly array $headers,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The Postgres adapter:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Http;

use PDO;
use PDOException;

final readonly class PostgresIdempotencyStore implements IdempotencyStore
{
    public function __construct(private PDO $pdo) {}

    public function reserve(string $key, string $hash): ReservationResult
    {
        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO idempotency_records
                    (key, request_hash, state)
                 VALUES (:k, :h, \'in_progress\')',
            );
            $stmt->execute(['k' => $key, 'h' => $hash]);

            return new Fresh();
        } catch (PDOException $e) {
            if ($e->getCode() !== '23505') {
                throw $e;
            }
        }

        $stmt = $this->pdo->prepare(
            'SELECT request_hash, state,
                    response_status, response_body, response_headers
             FROM idempotency_records WHERE key = :k',
        );
        $stmt->execute(['k' => $key]);
        $row = $stmt->fetch(PDO::FETCH_ASSOC);

        if ($row['request_hash'] !== $hash) {
            return new Conflict();
        }

        if ($row['state'] === 'in_progress') {
            return new InProgress();
        }

        return new Completed(
            status: (int) $row['response_status'],
            body: (string) $row['response_body'],
            headers: json_decode(
                $row['response_headers'],
                associative: true,
            ),
        );
    }

    public function complete(
        string $key,
        int $status,
        string $body,
        array $headers,
    ): void {
        $stmt = $this->pdo->prepare(
            'UPDATE idempotency_records
             SET state = \'completed\',
                 response_status = :s,
                 response_body = :b::jsonb,
                 response_headers = :h::jsonb,
                 completed_at = now()
             WHERE key = :k',
        );
        $stmt->execute([
            'k' => $key,
            's' => $status,
            'b' => $body,
            'h' => json_encode($headers),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The race-condition logic lives in one place: the INSERT either succeeds (first-writer wins, returns Fresh) or fails with SQLSTATE 23505 (unique violation). On violation, you read the existing row and figure out what state it is in. Postgres handles concurrency for you; no SELECT ... FOR UPDATE or advisory locks needed.

If you are on MySQL 8, the same INSERT + duplicate-key path works. The port hides the SQLSTATE detail.

Two simultaneous requests racing the unique constraint, one wins, the other reads the cached response

Failure modes the naive version misses

Three traps that catch people the first time they write this:

1. Caching errors as if they were successes. If the use case throws (payment gateway down, validation failed) and you write the error response into the store, the client retries and gets the same 500 forever. Decide per status code: 4xx is usually cacheable (the request was bad and will be bad again), 5xx is usually not (transient infra). A reasonable rule: only complete() for status codes < 500. For 5xx, delete the row so a retry can run fresh.

2. The crash-mid-flight case. The middleware reserves the key, the worker dies before it can write the response, the row stays in_progress forever. Two mitigations: a sweeper job that deletes in_progress rows older than 60 seconds, and the InProgress → 409 Conflict response in the middleware so a retry inside that window does not run the use case again. Both together stop the double-charge while letting normal retries succeed.

3. Hashing the body but ignoring the path. A client sends POST /payments with key X, then sends POST /refunds with the same key X. If your hash only covers the body, the second call gets the first call's response back: a refund response for a payment endpoint. Always include method and path in the hash. The example above does.

The same pattern for queue workers

The HTTP version uses the Idempotency-Key header. The queue version uses the message ID. Same store, same unique constraint, different adapter:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Queue;

use App\Application\UseCase\ChargeCustomer;
use App\Infrastructure\Http\IdempotencyStore;
use App\Infrastructure\Http\Fresh;
use App\Infrastructure\Http\Completed;

final readonly class ChargeCustomerWorker
{
    public function __construct(
        private ChargeCustomer $useCase,
        private IdempotencyStore $store,
    ) {}

    public function handle(Message $message): void
    {
        $key = 'queue:charge:' . $message->id;
        $hash = hash('sha256', $message->payload);

        $reservation = $this->store->reserve($key, $hash);

        if ($reservation instanceof Completed) {
            return;
        }

        if (!$reservation instanceof Fresh) {
            throw new ShouldRetryLater();
        }

        $payload = json_decode($message->payload, true);
        $result = $this->useCase->run(
            new ChargeRequest(
                customerId: $payload['customer_id'],
                amountCents: $payload['amount_cents'],
                currency: $payload['currency'],
            ),
        );

        $this->store->complete(
            $key,
            200,
            json_encode(['payment_id' => $result->id]),
            [],
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The use case is identical. The inbound adapter is what changes. That is the payoff of keeping idempotency out of the use case. You can add a third entry point next week (a webhook from a partner, a scheduled task) and the dedupe strategy is per-adapter, not per-business-operation.

What to test

Three tests buy you confidence:

public function test_first_call_runs_use_case(): void
{
    $response = $this->postPayment(
        key: 'k-1',
        body: ['customer' => 'c-1', 'amount' => 1999],
    );

    self::assertSame(201, $response->getStatusCode());
    self::assertCount(1, $this->paymentGateway->charges);
}

public function test_retry_with_same_key_returns_cached_response(): void
{
    $first = $this->postPayment(key: 'k-2', body: ['amount' => 999]);
    $second = $this->postPayment(key: 'k-2', body: ['amount' => 999]);

    self::assertSame(
        (string) $first->getBody(),
        (string) $second->getBody(),
    );
    self::assertCount(1, $this->paymentGateway->charges);
}

public function test_same_key_different_body_returns_422(): void
{
    $this->postPayment(key: 'k-3', body: ['amount' => 100]);
    $response = $this->postPayment(key: 'k-3', body: ['amount' => 999]);

    self::assertSame(422, $response->getStatusCode());
}
Enter fullscreen mode Exit fullscreen mode

The third one is the one people forget, and it is the one that catches the worst client bugs in code review.

The line you should not cross

Resist the urge to add IdempotencyKey as a parameter on ChargeCustomer::run(). The day someone does, you have:

  • A use case that lies about its contract (it claims to charge, but actually it might no-op).
  • Domain code that needs to know about HTTP headers to do its job from a CSV.
  • Two ways to dedupe (one in the middleware, one in the use case) that drift apart over six months.

The use case charges. The adapter decides when to call the use case. Keep that line clean and the pattern scales to every inbound surface you bolt on later.


If this was useful

This is one of the patterns in Decoupled PHP. The inbound-adapter chapter walks through HTTP, CLI, and queue entry points all calling the same use case, with the dedupe and retry logic kept at the edges. If your Laravel or Symfony app has business rules tangled up with controllers, the book is the long version of this argument with refactor steps you can actually run on Monday.

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)