Laravel Magazine
Building an Idempotency-Key Middleware for Safe Payment API Retries

Building an Idempotency-Key Middleware for Safe Payment API Retries

Eric Van Johnson ·

Why idempotency keys matter

Picture a client calling POST /api/charges on PhpArchitect's billing API to charge a customer's card. The request hits the server, the payment processor charges the card, and then the network drops before the response makes it back. The client sees a timeout. What does it do? It retries. That's the correct, expected behavior for any client written by a reasonable engineer.

The problem is that your server already processed the first request. The card already got charged. If the retry just runs the controller again, the customer gets charged twice for one purchase, and now you're refunding money and apologizing.

An idempotency key fixes this. The client generates a unique token before it sends the request and attaches it as a header, typically Idempotency-Key: <uuid>. If the server sees the same key again, it doesn't repeat the side effect — it just returns the same response it already computed. The retry becomes safe. This is exactly how Stripe's API works, and it's straightforward to build the same pattern in Laravel with middleware and a cache store.

Step 1: Design the storage key

We need a cache key that's unique per user, per route, and per idempotency key. Two different users sending the same UUID by coincidence shouldn't collide, and the same key reused on a different endpoint shouldn't replay the wrong response.

$cacheKey = sprintf(
    'idempotency:%s:%s:%s',
    $request->user()->id,
    $request->route()->getName() ?? $request->path(),
    $idempotencyKey
);

Step 2: Write the middleware

Create the middleware with php artisan make:middleware EnsureIdempotencyKey:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;

class EnsureIdempotencyKey
{
    public function handle(Request $request, Closure $next): SymfonyResponse
    {
        $idempotencyKey = $request->header('Idempotency-Key');

        if (! $idempotencyKey) {
            return response()->json([
                'message' => 'The Idempotency-Key header is required for this request.',
            ], 400);
        }

        $cacheKey = $this->cacheKeyFor($request, $idempotencyKey);

        if ($cached = Cache::get($cacheKey)) {
            return $this->replay($cached);
        }

        // Prevent two concurrent requests with the same key from both
        // executing the underlying action. Only one process gets the lock;
        // the other waits briefly, then reads whatever got cached.
        $lock = Cache::lock("lock:{$cacheKey}", 10);

        try {
            $lock->block(5);
        } catch (\Illuminate\Contracts\Cache\LockTimeoutException) {
            return response()->json([
                'message' => 'A request with this Idempotency-Key is already being processed.',
            ], 409);
        }

        try {
            // Another request may have finished while we waited for the lock.
            if ($cached = Cache::get($cacheKey)) {
                return $this->replay($cached);
            }

            /** @var SymfonyResponse $response */
            $response = $next($request);

            // Only cache successful, deterministic responses. Don't cache
            // 5xx errors — a genuine server failure should be retryable.
            if ($response->getStatusCode() < 500) {
                Cache::put($cacheKey, [
                    'status' => $response->getStatusCode(),
                    'headers' => $this->cacheableHeaders($response),
                    'body' => $response->getContent(),
                ], now()->addHours(24));
            }

            return $response;
        } finally {
            $lock->release();
        }
    }

    private function cacheKeyFor(Request $request, string $idempotencyKey): string
    {
        return sprintf(
            'idempotency:%s:%s:%s',
            $request->user()?->id ?? 'guest',
            $request->route()?->getName() ?? $request->path(),
            $idempotencyKey
        );
    }

    private function cacheableHeaders(SymfonyResponse $response): array
    {
        return collect($response->headers->all())
            ->only(['content-type'])
            ->all();
    }

    private function replay(array $cached): Response
    {
        return response($cached['body'], $cached['status'], $cached['headers'])
            ->header('Idempotent-Replayed', 'true');
    }
}

A few things worth calling out:

  • Cache::lock() uses an atomic lock under the hood (backed by Redis or your configured cache store) so two concurrent requests carrying the same key can't both slip through and both charge the card. The second request blocks for up to 5 seconds waiting for the first to finish, then checks the cache again before giving up.
  • We deliberately skip caching 5xx responses. If the payment processor's API itself failed, the client should be free to retry cleanly rather than getting a cached failure forever.
  • The Idempotent-Replayed header is optional but genuinely useful for debugging — it tells you at a glance whether a response came from cache or from a live execution.

Step 3: Register the middleware

Add an alias in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'idempotent' => \App\Http\Middleware\EnsureIdempotencyKey::class,
    ]);
})

Then apply it to the routes that perform non-idempotent, money-moving actions:

Route::middleware(['auth:sanctum', 'idempotent'])->group(function () {
    Route::post('/charges', [ChargeController::class, 'store']);
    Route::post('/refunds', [RefundController::class, 'store']);
});

Don't put this on GET routes or anything already naturally idempotent — it adds latency and cache reads for no benefit there.

Step 4: Generate the key on the client

The middleware only works if callers actually send a well-formed, unique key. A typical client-side implementation generates a UUID once per logical operation and reuses it across retries of that same operation:

use Illuminate\Support\Str;
use Illuminate\Support\Facades\Http;

$idempotencyKey = (string) Str::uuid();

$attemptRequest = fn () => Http::withHeaders([
    'Idempotency-Key' => $idempotencyKey,
])->post('https://billing.phparchitect.com/api/charges', [
    'amount' => 4900,
    'currency' => 'usd',
    'source' => $paymentMethodId,
]);

$response = retry(3, $attemptRequest, 200);

The important detail: $idempotencyKey is generated once, before the first attempt, and the same value is reused across all retries of that specific charge attempt. If you generate a new UUID on every retry, you've defeated the entire mechanism.

A note on TTL and cleanup

24 hours is a reasonable default TTL for payment endpoints — long enough to cover retries from flaky mobile networks or queued background jobs, short enough that stale cache entries don't accumulate forever. If you're on Redis, Cache::put() with an expiration is all you need; expired keys are evicted automatically, no cron job required.

If you're using the database or file cache driver instead, remember those don't always purge expired entries proactively — Laravel's cache table can accumulate stale rows until something reads (and evicts) them, so consider a scheduled cache:prune-stale-tags call or just moving idempotency storage to Redis, which is the better fit for this use case anyway given the locking requirement.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.