A customer taps Pay on your mobile app. The request reaches your Laravel API, your API asks the payment gateway to charge 150 EGP, and the gateway approves it. Then the customer's 4G connection drops for a few seconds, the app never receives your response, and after its timeout it shows "Something went wrong" with a Try again button.

The customer taps it. Your API receives a perfectly valid request, charges the card again, and creates a second order.

Nothing in your logs looks wrong. Both requests were valid and both got 201 Created. You only learn about it when the customer asks for a refund, or files a chargeback.

(The scenario is hypothetical, but this failure mode is exactly why payment APIs like Stripe and Adyen support idempotency keys.)

This article shows how to make that retry safe in Laravel and MySQL. It also shows what happens when you don't, using an experiment you can run yourself: 50 identical requests sent at the same moment. Without protection they created 50 charges. With the common "check first, then insert" approach they still created 24. With the design in this article they created exactly 1.

Bar chart: 50 identical simultaneous requests produced 50 charges with no protection, 24 with check-then-insert, and 1 with an idempotency key and a unique index
Charges created by 50 simultaneous identical requests, 5 runs per approach

Why retries are not optional#

You cannot fix this by asking clients to "just not retry". Retries are how distributed systems survive failure, and they come from many places at once:

  • Users double-tap buttons and press "Try again".
  • Mobile apps and SDKs retry automatically after timeouts.
  • Proxies, load balancers and API gateways can retry when an upstream connection fails.
  • Queue workers deliver jobs at least once, so a job that calls your API can run twice.
  • Your own code retries outgoing calls, for example Http::retry(3, 200).

The hard case is the timeout. When a client times out, it does not know whether the server never received the request, received it and failed, or received it, succeeded, and the response was lost. Stripe's error-handling guide says it directly: treat the outcome of a request that returns 500 as indeterminate.

The client has two bad options, retry and risk charging twice or give up and risk not charging at all, unless the server makes retrying safe. That is what idempotency means.

Idempotency in one minute#

An operation is idempotent if doing it many times has the same effect as doing it once.

RFC 9110, the HTTP semantics standard, defines it for methods: PUT, DELETE, and the safe methods (GET, HEAD, OPTIONS, TRACE) are idempotent. POST is not. Two identical POST /charges requests are allowed to create two charges, and by default they will.

An idempotency key is how you make a POST idempotent:

  1. The client generates a unique key (a UUID v4 is the usual choice) for one logical operation: "pay for order #1042".
  2. It sends the key in a header, Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324, and reuses the same key for every retry of that operation.
  3. The server remembers the key together with the result of the first request. A retry with the same key gets the stored result instead of running the operation again.

Two details separate a toy implementation from a production one:

  • Fingerprinting. If a client reuses a key with a different body (a different amount, for example), that is a bug in the client. The server must reject it, not replay an unrelated response.
  • Concurrency. The retry often arrives while the first request is still running. "Look up the key, and if it's not there, process the request" has a race condition. The experiment shows exactly how much damage that race does.

How Stripe, Adyen and the IETF draft handle it#

This isn't a pattern you need to invent. Large payment APIs implement it, and the IETF HTTPAPI working group is standardising the header.

Stripe (API v1) Adyen IETF draft-08
Header Idempotency-Key idempotency-key (headers are case-insensitive) Idempotency-Key, a Structured Field String ("…")
Key format Up to 255 characters; suggests V4 UUIDs Up to 64 characters; recommends UUID v4 UUID recommended; no length set
How long keys live Can be pruned after at least 24 hours 7 to 14 days Server defines and documents its policy
Same key, different payload Error Not stated on the idempotency page 422
Same key while first is running 409 (retry later with the same key) 422 or 409, error code 704; transient races marked retryable 409
Missing key Optional Optional 400 if the endpoint requires it
Server errors 500 responses are stored and replayed — —

Two takeaways:

  1. The status codes are converging: 409 for "still processing" and 422 for "same key, different request". This article uses them.
  2. Stripe stores even 500 responses, because once its endpoint started executing it treats the outcome as final for that key. Your API can choose differently. Which responses to store is covered under the design decisions below.

Duplicates enter at three boundaries#

Diagram of three boundaries: client to your API (idempotency key and unique index), your API to the gateway (outbound key derived from the payment ID), gateway to your API (webhook event ID)
Each boundary needs its own guard

A complete payment flow has three places where the same operation can arrive twice:

  1. Client → your API. This is what the Idempotency-Key header protects.
  2. Your API → the gateway. Your server is now the client. If your call to the gateway times out after the card was charged, your retry can double-charge, even if boundary 1 is perfect.
  3. Gateway → your API (webhooks). Providers deliver webhooks at least once, so you deduplicate them by the provider's event ID. I covered webhook processing in How we cut payment webhook latency by 80%.

Most tutorials stop at boundary 1. This one covers boundaries 1 and 2, because a timeout between your server and the gateway is the double charge that a middleware alone cannot prevent.

The experiment: three approaches, 50 simultaneous requests#

To see what each approach really does, I built a small Laravel 13 app with three versions of POST /charges and a fake gateway. The fake gateway takes 300 ms per charge, like a real card authorisation round trip, and records every charge it makes. Each row it records means a customer was charged.

  1. No protection. Every request charges.
  2. Check, then insert. Look for an existing payment with this idempotency key. If there is none, charge and insert. There's no unique index. This is the version that "looks right" in code review.
  3. Idempotency key + unique index: the middleware and payment layer described in this article.

For each version, 50 curl processes sent the same request with the same Idempotency-Key at the same moment. Each version ran 5 times.

Setup: Laravel 13.40 on PHP 8.4 with PHP-FPM (24 static workers) behind nginx, MySQL 8.0.28 (default REPEATABLE READ), Apple M1 Pro. The script is in the "Reproduce it" section below.

Approach Gateway charges per run (5 runs) Responses per run
No protection 50, 50, 50, 50, 50 50 × 201
Check, then insert 24, 24, 24, 24, 24 50 × 201
Idempotency key + unique index 1, 1, 1, 1, 1 1 × 201, 49 × 409

Three things stand out.

Check-then-insert failed exactly 24 times, the number of PHP-FPM workers. The first 24 requests were picked up at the same moment. All 24 ran SELECT … WHERE idempotency_key = ?, all 24 found nothing (nobody had inserted yet), and all 24 charged the card. The other 26 requests waited for a free worker, and by then the payment row existed. The bug grows with your capacity: more workers means more double charges.

The response codes hide the problem. Check-then-insert returned 50 successful responses. A monitoring dashboard would show a healthy endpoint.

The correct version charged once and told the other 49 requests to retry. They got 409 Conflict with Retry-After: 1. When they retry with the same key, they receive the stored 201 response. They don't get a new charge, and they don't get an error.

Building it in Laravel#

The design has three layers, and each one covers a gap in the others:

Layer Where What it guarantees
1. EnsureIdempotency middleware HTTP One request per key runs; retries get the stored response
2. Unique (user_id, idempotency_key) on payments Database Even if layer 1 is bypassed, one key means one payment row
3. Outbound key payment_{id} Gateway call A retried gateway call never creates a second charge

The code below was tested on Laravel 13 and also works on Laravel 11 and 12. It needs UniqueConstraintViolationException and createOrFirst(), both added in Laravel 10.

Step 1: The idempotency keys table#

database/migrations/xxxx_create_idempotency_keys_table.phpPHP
Schema::create('idempotency_keys', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();

    // ascii_bin: exact, case-sensitive matching. "Abc" and "abc" are different keys.
    $table->string('key', 255)->charset('ascii')->collation('ascii_bin');
    $table->char('request_fingerprint', 64);

    $table->timestamp('locked_at')->nullable();      // set while a request is in flight
    $table->timestamp('completed_at')->nullable();   // set once the response is stored
    $table->unsignedSmallInteger('response_status')->nullable();
    $table->json('response_headers')->nullable();
    $table->mediumText('response_body')->nullable();
    $table->timestamps();

    // The real guard: two requests cannot both own the same key.
    $table->unique(['user_id', 'key']);
    $table->index('created_at'); // for pruning
});

Three choices in this schema matter:

  • The unique index is the lock. MySQL guarantees that only one INSERT of a given (user_id, key) succeeds, whatever the concurrency. No SELECT can give you that guarantee.
  • Keys are scoped per user. With a global unique key, user B could send user A's key and receive user A's stored response, which would be a data leak. Scope keys to whoever is authenticated: a user, a merchant or an API client.
  • ascii_bin collation. Laravel's default utf8mb4_unicode_ci (like MySQL 8's utf8mb4_0900_ai_ci) is case-insensitive, so AbC and abc would collide. Idempotency keys are opaque strings and should compare byte-for-byte.

The model encrypts the stored response body, since responses can contain personal data, and uses Laravel's Prunable trait to delete old keys:

app/Models/IdempotencyKey.phpPHP
class IdempotencyKey extends Model
{
    use Prunable;

    /** How long a key is remembered. Publish this in your API docs. */
    public const RETENTION_HOURS = 24;

    protected $fillable = [
        'user_id', 'key', 'request_fingerprint', 'locked_at', 'completed_at',
        'response_status', 'response_headers', 'response_body',
    ];

    protected function casts(): array
    {
        return [
            'locked_at' => 'datetime',
            'completed_at' => 'datetime',
            'response_headers' => 'array',
            'response_body' => 'encrypted', // stored responses can contain personal data
        ];
    }

    /** `php artisan model:prune` (scheduled daily) deletes expired keys. */
    public function prunable(): Builder
    {
        return static::where('created_at', '<=', now()->subHours(self::RETENTION_HOURS));
    }
}

Step 2: The middleware#

Flowchart of the EnsureIdempotency middleware: validate the key, insert it, then either run the controller and store the response, or return 422, a replay, 409, or take over an abandoned key
Every POST goes through one INSERT that decides who owns the key

Here is the full middleware. Every branch in the flowchart maps to a few lines:

app/Http/Middleware/EnsureIdempotency.phpPHP
<?php

namespace App\Http\Middleware;

use App\Models\IdempotencyKey;
use Closure;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureIdempotency
{
    public const HEADER = 'Idempotency-Key';

    /** A request "in flight" for longer than this is treated as crashed. Keep it above your longest request. */
    private const LOCK_TIMEOUT_SECONDS = 60;

    /** Only these response headers are stored and replayed. */
    private const REPLAYED_HEADERS = ['Content-Type', 'Location'];

    public function handle(Request $request, Closure $next): Response
    {
        if (! in_array($request->method(), ['POST', 'PATCH'], true)) {
            return $next($request);
        }

        // Accept both `abc` (Stripe style) and `"abc"` (IETF draft: a Structured Field String).
        $key = trim((string) $request->header(self::HEADER), " \t\"");

        if ($key === '') {
            return $this->problem(400, 'Missing Idempotency-Key header.');
        }

        if (strlen($key) > 255 || ! preg_match('/^[A-Za-z0-9_\-:.]+$/', $key)) {
            return $this->problem(400, 'Idempotency-Key must be 1-255 characters: letters, digits, "-", "_", ":" or ".".');
        }

        $userId = $request->user()->getAuthIdentifier(); // keys are scoped per user, never global
        $fingerprint = $this->fingerprint($request);

        try {
            // Atomic claim: the unique index on (user_id, key) lets exactly one request win.
            $record = IdempotencyKey::create([
                'user_id' => $userId,
                'key' => $key,
                'request_fingerprint' => $fingerprint,
                'locked_at' => now(),
            ]);
        } catch (UniqueConstraintViolationException) {
            $existing = IdempotencyKey::where('user_id', $userId)->where('key', $key)->first();

            $outcome = $this->handleExisting($existing, $fingerprint);

            if ($outcome instanceof Response) {
                return $outcome;
            }

            $record = $outcome; // we took over an abandoned key
        }

        $response = $next($request);

        if ($this->shouldStore($response)) {
            $record->update([
                'locked_at' => null,
                'completed_at' => now(),
                'response_status' => $response->getStatusCode(),
                'response_headers' => $this->replayableHeaders($response),
                'response_body' => $response->getContent(),
            ]);
        } else {
            // 5xx, 409, 429 or a validation error: nothing final happened at this layer, so free the key.
            // A retry with the same key will run again. The payment layer below must make that safe.
            IdempotencyKey::whereKey($record->getKey())->whereNull('completed_at')->delete();
        }

        return $response;
    }

    /** Returns a response for the client, or the record if this request may take over. */
    private function handleExisting(?IdempotencyKey $existing, string $fingerprint): Response|IdempotencyKey
    {
        if ($existing === null) {
            // The first request failed and released the key between our insert and our select.
            return $this->problem(409, 'A request with this Idempotency-Key just finished. Retry.', retryAfter: 1);
        }

        if (! hash_equals($existing->request_fingerprint, $fingerprint)) {
            return $this->problem(422, 'This Idempotency-Key was already used with a different request.');
        }

        if ($existing->completed_at !== null) {
            return $this->replay($existing);
        }

        $staleBefore = now()->subSeconds(self::LOCK_TIMEOUT_SECONDS);

        if ($existing->locked_at->greaterThan($staleBefore)) {
            return $this->problem(409, 'A request with this Idempotency-Key is still being processed.', retryAfter: 1);
        }

        // The first request is older than the timeout: its worker probably died.
        // Compare-and-set so only one retry can take the key over.
        $tookOver = IdempotencyKey::whereKey($existing->getKey())
            ->whereNull('completed_at')
            ->where('locked_at', '<=', $staleBefore)
            ->update(['locked_at' => now()]);

        return $tookOver === 1
            ? $existing->refresh()
            : $this->problem(409, 'A request with this Idempotency-Key is still being processed.', retryAfter: 1);
    }

    private function replay(IdempotencyKey $record): Response
    {
        $response = new Response($record->response_body, $record->response_status, $record->response_headers ?? []);
        $response->headers->set('Idempotent-Replayed', 'true');

        return $response;
    }

    /** Same key must mean same request: method, path and payload (key order ignored). */
    private function fingerprint(Request $request): string
    {
        $payload = $request->isJson() ? $request->json()->all() : $request->post();

        return hash('sha256', implode("\n", [
            $request->method(),
            $request->path(),
            json_encode($this->sortKeys($payload), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
        ]));
    }

    private function sortKeys(array $data): array
    {
        if (! array_is_list($data)) {
            ksort($data);
        }

        return array_map(fn ($value) => is_array($value) ? $this->sortKeys($value) : $value, $data);
    }

    /** Store final answers (2xx and business 4xx such as 402 "card declined"). Release everything else. */
    private function shouldStore(Response $response): bool
    {
        $status = $response->getStatusCode();

        return $status < 500 && ! in_array($status, [409, 422, 429], true);
    }

    private function replayableHeaders(Response $response): array
    {
        return collect(self::REPLAYED_HEADERS)
            ->filter(fn (string $name) => $response->headers->has($name))
            ->mapWithKeys(fn (string $name) => [$name => $response->headers->get($name)])
            ->all();
    }

    private function problem(int $status, string $detail, ?int $retryAfter = null): JsonResponse
    {
        $headers = ['Content-Type' => 'application/problem+json'];

        if ($retryAfter !== null) {
            $headers['Retry-After'] = (string) $retryAfter;
        }

        return new JsonResponse(['status' => $status, 'detail' => $detail], $status, $headers);
    }
}

What each part does:

  • The create() inside try is the whole concurrency strategy. There is no SELECT before it. The database decides the winner, and every loser lands in the catch block.
  • The fingerprint hashes the method, the path and the payload with its keys sorted. A retry that serialises JSON in a different key order still matches, while a different amount, or the same key sent to a different endpoint, is rejected with 422.
  • 409 with Retry-After tells a well-behaved client to wait and send the same request again.
  • Stale takeover. If a worker crashes mid-request (killed by a deploy, out of memory), its key would stay locked forever. After LOCK_TIMEOUT_SECONDS, one retry may take the key over. The UPDATE … WHERE locked_at <= ? is a compare-and-set, so only one retry wins.
  • Laravel converts exceptions into responses before they reach route middleware, so a thrown exception arrives here as a 500 response, and the key is released.

Step 3: Register it#

bootstrap/app.phpPHP
->withMiddleware(function (Middleware $middleware): void {
    $middleware->alias([
        'idempotent' => \App\Http\Middleware\EnsureIdempotency::class,
    ]);
})
routes/api.phpPHP
Route::middleware(['auth:sanctum', 'idempotent'])->group(function () {
    Route::post('/charges', [ChargeController::class, 'store']);
    Route::post('/refunds', [RefundController::class, 'store']);
    Route::post('/orders', [OrderController::class, 'store']);
});

Apply it to every endpoint that moves money or creates something a customer would notice twice: charges, refunds, payouts, orders and transfers. Keep idempotent after your auth middleware, because it needs the user to scope keys.

Step 4: The payment layer (layers 2 and 3)#

app/Http/Controllers/ChargeController.phpPHP
public function store(Request $request, PaymentGateway $gateway): JsonResponse
{
    $data = $request->validate([
        'amount' => ['required', 'integer', 'min:1'],         // minor units
        'currency' => ['required', 'string', 'size:3'],
        'payment_method' => ['required', 'string', 'max:255'],
    ]);

    // Layer 2: the payment row is unique per (user, key). A retry finds the same payment.
    $payment = Payment::createOrFirst(
        ['user_id' => $request->user()->id, 'idempotency_key' => $request->header('Idempotency-Key')],
        ['amount' => $data['amount'], 'currency' => strtoupper($data['currency']), 'status' => 'pending'],
    );

    if ($payment->status === 'pending') {
        // Layer 3: the outbound key is derived from OUR payment id, so every retry of this
        // payment sends the same key to the provider and can never create a second charge.
        $result = $gateway->charge(
            amount: $payment->amount,
            currency: $payment->currency,
            paymentMethod: $data['payment_method'],
            idempotencyKey: 'payment_'.$payment->id,
        );

        $payment->update([
            'status' => $result->succeeded ? 'succeeded' : 'failed',
            'gateway_reference' => $result->reference,
        ]);
    }

    return response()->json([
        'id' => $payment->id,
        'status' => $payment->status,
        'amount' => $payment->amount,
        'currency' => $payment->currency,
        'gateway_reference' => $payment->gateway_reference,
    ], 201);
}

payments has its own unique(['user_id', 'idempotency_key']). createOrFirst() tries the INSERT first and falls back to a SELECT when it hits the unique constraint. That is the same insert-first pattern as the middleware, and it is safe under concurrency. firstOrCreate() does it the other way round and has the check-then-insert race from the experiment.

The outbound key is what saves you when the call to the gateway times out after the card was charged:

  1. Attempt 1 creates payment #42 (pending) and calls the gateway with key payment_42. The gateway charges the card, but the connection drops. Laravel returns 500, and the middleware releases the client's key.
  2. The client retries with the same Idempotency-Key. createOrFirst() finds payment #42, still pending, and calls the gateway again with the same key payment_42.
  3. The gateway recognises the key and returns the original charge instead of creating a new one. Payment #42 becomes succeeded. The customer was charged once.

With Stripe's PHP SDK, the outbound key is a request option:

Passing the outbound key to StripePHP
$intent = $stripe->paymentIntents->create(
    ['amount' => $payment->amount, 'currency' => strtolower($payment->currency), /* … */],
    ['idempotency_key' => 'payment_'.$payment->id],
);

Step 5: Clean up expired keys#

routes/console.phpPHP
use Illuminate\Support\Facades\Schedule;

Schedule::command('model:prune')->daily();

model:prune finds every model that uses Prunable and deletes rows matching prunable(). Keep the retention period at least as long as your clients might retry. Stripe's 24 hours is a sensible floor. Write the period down in your API documentation, as the IETF draft asks.

Step 6: The client side#

The server half is useless if the client generates a new key for every attempt. The key belongs to the operation, not to the HTTP request:

checkout.jsJavaScript
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function payOrder(order) {
  // One key per payment attempt. Persist it (with the cart or order) so a page reload reuses it.
  order.idempotencyKey ??= crypto.randomUUID();

  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      const response = await fetch('/api/charges', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': order.idempotencyKey },
        body: JSON.stringify({ amount: order.amountMinor, currency: order.currency, payment_method: order.paymentMethod }),
      });

      if (response.status === 409 || response.status >= 500) {
        const retryAfter = Number(response.headers.get('Retry-After')) || 0;
        await sleep(Math.max(retryAfter * 1000, 2 ** attempt * 300 + Math.random() * 300));
        continue; // same key, same body
      }

      return response; // 201 (new or replayed), 402 declined, 422 bug in the client…
    } catch (networkError) {
      await sleep(2 ** attempt * 300 + Math.random() * 300); // exponential backoff + jitter
    }
  }

  // Still unknown: do NOT ask the customer to pay again. Show "We're confirming your payment".
  throw new Error('Payment status unknown');
}

Only generate a new key when the customer starts a new payment attempt on purpose, for example after a decline when they enter a different card. That is a new operation with a different body, so it needs a new key.

A timeout, step by step#

Sequence diagram: attempt 1 claims the key and calls the gateway, the client times out, attempt 2 gets 409 while attempt 1 is still running, attempt 1 stores its response, attempt 3 gets the stored 201 with Idempotent-Replayed: true
The retries never reach the gateway

This is the scenario from the introduction, handled correctly:

  1. Attempt 1 claims key K1 and starts charging through the gateway with outbound key payment_42.
  2. The app times out and retries. Attempt 2 hits the unique index, sees that K1 is still locked, and gets 409 Conflict, Retry-After: 1. It never reaches the controller.
  3. Attempt 1 finishes. The gateway confirms the charge, and the middleware stores the 201 response and marks the key completed.
  4. Attempt 3 gets the stored 201 with Idempotent-Replayed: true. The app shows the same receipt that attempt 1 would have shown.

Testing it#

Test against the database you run in production. The behaviour under test is MySQL's unique index, and the ascii_bin collation in the migration doesn't exist in SQLite.

The tests use a FakeGateway that behaves like a real provider: it records every charge in a gateway_charges table and returns the existing charge when it sees a key again. All eight tests pass on MySQL 8.0:

tests/Feature/IdempotentChargesTest.phpPHP
class IdempotentChargesTest extends TestCase
{
    use RefreshDatabase; // point phpunit.xml at a dedicated test database

    private array $payload = ['amount' => 15000, 'currency' => 'EGP', 'payment_method' => 'pm_card_visa'];

    private function charge(string $key, ?array $payload = null, ?User $user = null)
    {
        return $this->actingAs($user ?? User::factory()->create())
            ->withHeader('Idempotency-Key', $key)
            ->postJson('/api/charges', $payload ?? $this->payload);
    }

    public function test_a_retry_returns_the_stored_response_and_charges_once(): void
    {
        $user = User::factory()->create();

        $first = $this->charge('key-1', user: $user)->assertCreated();
        $retry = $this->charge('key-1', user: $user)->assertCreated()->assertHeader('Idempotent-Replayed', 'true');

        $this->assertSame($first->json(), $retry->json());
        $this->assertSame(1, DB::table('gateway_charges')->count());
    }

    public function test_the_same_key_with_a_different_payload_is_rejected(): void
    {
        $user = User::factory()->create();
        $this->charge('key-1', user: $user)->assertCreated();

        $this->charge('key-1', [...$this->payload, 'amount' => 99], $user)->assertStatus(422);
    }

    public function test_keys_are_scoped_per_user(): void
    {
        $this->charge('shared-key')->assertCreated();
        $this->charge('shared-key')->assertCreated()->assertHeaderMissing('Idempotent-Replayed');
    }

    public function test_a_request_still_in_flight_gets_409(): void
    {
        $user = User::factory()->create();
        $this->charge('key-1', user: $user);
        IdempotencyKey::query()->update(['completed_at' => null, 'locked_at' => now()]); // pretend it is still running

        $this->charge('key-1', user: $user)->assertStatus(409)->assertHeader('Retry-After', '1');
    }

    public function test_a_timeout_after_the_gateway_charged_does_not_charge_twice_on_retry(): void
    {
        // The provider charges the card, but the connection drops before we read the answer.
        $this->app->instance(PaymentGateway::class, new class extends FakeGateway {
            public bool $dropNextResponse = true;

            public function charge(int $amount, string $currency, string $paymentMethod, string $idempotencyKey): ChargeResult
            {
                $result = parent::charge($amount, $currency, $paymentMethod, $idempotencyKey);

                if ($this->dropNextResponse) {
                    $this->dropNextResponse = false;
                    throw new ConnectionException('cURL error 28: Operation timed out');
                }

                return $result;
            }
        });

        $user = User::factory()->create();

        $this->charge('key-1', user: $user)->assertStatus(500);      // the client sees an error...
        $this->assertSame(0, IdempotencyKey::count());               // ...the key is released...
        $this->assertSame(1, DB::table('gateway_charges')->count()); // ...but the card WAS charged

        $this->charge('key-1', user: $user)->assertCreated()->assertJsonPath('status', 'succeeded');

        $this->assertSame(1, DB::table('gateway_charges')->count()); // the retry reused the outbound key
    }

    // + missing key (400), abandoned key takeover, and "5xx releases the key". See the full file.
}

The timeout test matters most. Remove 'payment_'.$payment->id and pass a random key to the gateway, and it fails with 2 charges. That is the bug the middleware alone cannot catch.

Design decisions and trade-offs#

Which responses should you store? This implementation stores 2xx and business 4xx (a 402 card decline is a final answer, and retrying it shouldn't run a second authorisation). It releases the key on 5xx, 409, 422 and 429, so the client can retry or fix its request. Stripe stores 500s too, because for Stripe a started execution is final. Storing 500s is simpler and more conservative, but it means a transient bug locks a key into failure for its whole lifetime. Releasing on 500 is friendlier, and it is safe only with layers 2 and 3 in place.

Database or Redis? You could claim keys with Redis SET key value NX PX 60000 or Laravel's Cache::lock(). A lock is a good addition in front of a hot path, but it shouldn't be the source of truth. Locks expire, Redis can evict keys under memory pressure, and a failover can lose recent writes. The stored response has to survive those, and your payment rows already live in MySQL. If you use a lock, keep the unique indexes as the final guard.

Key retention. Longer retention means safer late retries and more storage. Each row here is small (the response body is usually under a few KB), and model:prune keeps the table bounded. 24 hours covers mobile apps that retry after reconnecting. Partner integrations that replay from their own queues may need longer. Adyen keeps keys for 7 to 14 days.

Required or optional key? For money-moving endpoints, require it (400 without it). Stripe makes keys optional because it serves every kind of client. For your own API it's simpler to require one: a client that forgot the key would otherwise get the unsafe behaviour silently.

What not to store. Never put card numbers, CVVs or other sensitive authentication data in a stored response. Your API shouldn't be returning them anyway. Encrypting response_body (the encrypted cast) protects whatever personal data is left.

Same key on different endpoints. The path is part of the fingerprint, so reusing a key from /charges on /refunds returns 422. If you want keys to be unique per endpoint instead, add the path to the unique index.

What it costs#

The middleware adds one INSERT and one UPDATE (or DELETE) on an indexed table per request. To measure the cost, I compared two identical no-op endpoints, one with the middleware and one without, over 1,000 sequential requests each, with a fresh key per request and three rounds. The setup was the same as above.

p50 p95
Without middleware 5.5 – 7.4 ms 6.8 – 13.0 ms
With EnsureIdempotency 9.1 – 10.9 ms 14.6 – 17.7 ms

That is roughly 3.5–5 ms added at the median on a laptop, including two committed writes and response encryption. The fake gateway call alone takes 300 ms. The numbers will differ on your hardware and database, so measure on your own stack before deciding. For endpoints that move money, it's a cheap insurance policy.

Common mistakes#

Mistake Why it fails Fix
SELECT to check for the key, then INSERT Race condition: 24 charges in the experiment INSERT first and let a unique index decide
New key on every retry Every retry looks like a new operation One key per operation, persisted on the client
Global keys (not scoped by user) One user can replay another user's response Unique on (user_id, key) or (merchant_id, key)
Ignoring the payload A reused key with a new amount silently replays the old response Fingerprint the request, return 422 on mismatch
No outbound key to the gateway A timeout after a successful charge double-charges on retry Derive the key from your payment ID: payment_{id}
firstOrCreate() on payments Same race as check-then-insert createOrFirst() with a unique index
Case-insensitive collation Abc and abc collide ascii_bin for key columns
Keys that never expire The table grows forever Prunable and model:prune daily
Keys that expire too soon Late retries charge again Retention of at least 24 h, published in your docs
Testing with php artisan serve Requests are processed one at a time, so races are hidden Test with PHP-FPM or Octane and real parallel requests

Production checklist#

  • Every endpoint that moves money or creates orders requires an Idempotency-Key
  • Keys are claimed with an INSERT against a unique index, never with a SELECT first
  • Keys are scoped to the authenticated user, merchant or API client
  • Requests are fingerprinted; a key reused with a different payload gets 422
  • A request that's still running gets 409 with Retry-After
  • Replayed responses carry Idempotent-Replayed: true
  • Business tables (payments, orders, refunds) have their own unique key per operation
  • Every gateway call sends an outbound key derived from your own record ID
  • For gateways without keys, you check status by reference before re-charging
  • Abandoned keys can be taken over after a timeout longer than your longest request
  • Stored responses are encrypted and never contain card data
  • Old keys are pruned on a schedule, and the retention period is documented
  • Clients reuse one key per operation and back off with jitter on 409 and 5xx
  • A test covers "gateway charged, then the connection timed out"
  • You've load-tested with real parallel requests on a multi-process server

Key takeaways#

  • Retries are unavoidable, and a timeout leaves the client unable to tell whether the payment happened. Idempotency keys make retrying safe.
  • Only the database can settle a race. Check-then-insert looked correct and still charged 24 times out of 50 under concurrency. A unique index allowed exactly one.
  • Protect every boundary. The middleware protects client → API. An outbound key protects API → gateway. Event IDs protect webhooks.
  • Return clear answers: 409 means "still processing, retry", 422 means "you reused a key for a different request", and a replay means "here is the original result".
  • It's cheap. A few milliseconds per request is a small price for never explaining a double charge to a customer.

Reproduce it#

Everything you need is in this article: the migration, middleware, controller and tests above. Add a fake gateway that sleeps for 300 ms and records each charge in a gateway_charges table. Point a fresh Laravel app at an empty MySQL database, serve it with PHP-FPM, and fire 50 identical requests at once:

experiment.sh (core loop)Bash
key="exp-$RANDOM"
seq 50 | xargs -P 50 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -H "Idempotency-Key: $key" --data @body.json "$BASE/charges" | sort | uniq -c

Then count the rows in gateway_charges.

References and further reading#


Building or auditing a payment flow? Double charges, stuck "pending" payments and reconciliation gaps usually come from the same few boundary mistakes. If you'd like a second pair of eyes on your checkout or gateway integration, see how I can help. I help teams design payment systems that hold up under real traffic.