May 25, 2026

Preventing Double Charges: Building Idempotent APIs in Laravel

By Paresh Prajapati • Lead Architect

Preventing Double Charges: Building Idempotent APIs in Laravel

The Network Retry Catastrophe

When building a B2B SaaS platform at Smart Tech Devs that processes financial transactions, you cannot rely on perfect network conditions. Imagine a client sends a POST /api/invoices/123/pay request. Your Laravel server receives it, charges their credit card via Stripe, and sends back a 200 OK response. But right before the response reaches the client, their Wi-Fi drops.

The client's application assumes the request failed and automatically retries it. Your server receives the exact same payload again, processes it, and charges the customer a second time. You now have a furious client, a chargeback dispute, and a massive architectural liability. To solve this, your mutation endpoints must be Idempotent.

The Enterprise Solution: Idempotency Keys

Idempotency guarantees that making multiple identical requests has the same effect as making a single request. We achieve this by requiring the client to send a unique Idempotency-Key header (usually a UUID) with every POST or PATCH request.

When Laravel receives the request, it checks Redis. If the key doesn't exist, we process the payment and save the HTTP response in Redis against that key. If the client retries the request with the same key, we bypass the controller entirely and simply return the cached HTTP response.

Architecting the Idempotency Middleware

We implement this strictly at the Middleware layer so our controllers remain clean and unaware of the caching mechanics.


namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class RequireIdempotencyKey
{
    public function handle(Request $request, Closure $next)
    {
        // Only require idempotency on mutation requests
        if ($request->isMethodSafe()) {
            return $next($request);
        }

        $key = $request->header('Idempotency-Key');

        if (!$key) {
            return response()->json(['error' => 'Idempotency-Key header is required.'], 400);
        }

        // We scope the cache key to the authenticated user to prevent collisions
        $cacheKey = "idempotency:{$request->user()->id}:{$key}";

        // 1. Check if a response already exists for this key
        if (Cache::has($cacheKey)) {
            $cachedResponse = Cache::get($cacheKey);
            
            // Reconstruct the cached Laravel response
            return response($cachedResponse['content'], $cachedResponse['status'])
                ->withHeaders($cachedResponse['headers']);
        }

        // 2. Lock the key to prevent rapid-fire concurrent requests (Race Conditions)
        $lock = Cache::lock("idempotency_lock:{$cacheKey}", 10);
        
        if (!$lock->get()) {
            return response()->json(['error' => 'Request is already processing.'], 409);
        }

        try {
            // 3. Process the actual controller logic
            $response = $next($request);

            // 4. Cache the exact HTTP response for 24 hours
            if ($response->isSuccessful()) {
                Cache::put($cacheKey, [
                    'content' => $response->getContent(),
                    'status'  => $response->getStatusCode(),
                    'headers' => $response->headers->all(),
                ], now()->addHours(24));
            }

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

The Engineering ROI

By enforcing an Idempotency Middleware on your critical endpoints, you build APIs that behave exactly like Stripe’s world-class architecture. You completely eliminate the risk of accidental double-charges, protect your database from rapid-fire retry storms, and provide external developers with the confidence that your API is fail-safe under extreme network volatility.

Paresh Prajapati
Lead Architect, Smart Tech Devs