Skip to content
Tech

Writing Idempotent API Endpoints: A Practical Guide for Laravel

How to make POST endpoints safe to retry using idempotency keys, without duplicating orders, charges or emails.

Writing Idempotent API Endpoints: A Practical Guide for Laravel
Writing Idempotent API Endpoints: A Practical Guide for Laravel
In this article

    Network calls fail in ways that are genuinely ambiguous. A client sends a request to create an order, the server processes it successfully, but the response never makes it back before the connection drops. The client, seeing no response, retries. Without a mechanism to recognize that retry as "the same request," the server creates a second order, charges a second payment, or sends a second confirmation email.

    Why Idempotency Matters

    Idempotency means that performing an operation multiple times has the same effect as performing it once. GET, PUT and DELETE are naturally idempotent by HTTP convention — repeating them should leave the resource in the same state. POST is explicitly not, which is exactly why it is the operation most likely to need explicit protection: creating a resource, charging a card, or sending a notification.

    The standard fix is an idempotency key: the client generates a unique token for a logical operation and sends it with every attempt, including retries. The server uses that key to recognize a retry and return the original result instead of repeating the side effect.

    Idempotency Keys for POST Requests

    The client is responsible for generating the key, typically a UUID, and must reuse the exact same key for retries of the same logical request.

    // Migration
    Schema::create('idempotency_keys', function (Blueprint $table) {
        $table->string('key')->primary();
        $table->unsignedBigInteger('user_id');
        $table->unsignedSmallInteger('response_status');
        $table->json('response_body');
        $table->timestamp('created_at');
    });

    Storing the original response alongside the key lets the server reply with the exact same payload on a retry, rather than just silently no-op'ing, which keeps client-side error handling consistent either way.

    Implementing an Idempotency Middleware

    A small middleware keeps this logic out of every controller that needs it.

    class EnsureIdempotent
    {
        public function handle(Request $request, Closure $next): Response
        {
            $key = $request->header('Idempotency-Key');
    
            if (! $key) {
                return $next($request);
            }
    
            $existing = DB::table('idempotency_keys')->where('key', $key)->first();
    
            if ($existing) {
                return response(
                    json_decode($existing->response_body, true),
                    $existing->response_status
                );
            }
    
            /** @var Response $response */
            $response = $next($request);
    
            if ($response->isSuccessful()) {
                DB::table('idempotency_keys')->insert([
                    'key' => $key,
                    'user_id' => $request->user()->id,
                    'response_status' => $response->getStatusCode(),
                    'response_body' => $response->getContent(),
                    'created_at' => now(),
                ]);
            }
    
            return $response;
        }
    }

    Only successful responses are cached against the key. A request that failed validation or hit a transient error should be free to retry with a genuinely new attempt rather than being locked into a failure.

    Handling Concurrent Duplicate Requests

    A client occasionally fires the same request twice in quick succession — a doubled click, a retry that races the original. The naive implementation above has a gap: both requests can check for an existing key, find nothing, and proceed before either has written its row. A unique constraint on the key column plus a database-level lock closes that gap.

    try {
        DB::table('idempotency_keys')->insert([
            'key' => $key,
            'user_id' => $request->user()->id,
            'response_status' => 0,
            'response_body' => '{}',
            'created_at' => now(),
        ]);
    } catch (QueryException $e) {
        if ($e->getCode() === '23000') {
            abort(409, 'Request already in progress.');
        }
        throw $e;
    }

    Inserting a placeholder row first, before the real work runs, turns the race into a database-enforced conflict instead of a silent duplicate. The real response then overwrites the placeholder once processing finishes.

    Tip: Expire idempotency keys after a reasonable window, such as 24 hours, with a scheduled cleanup job. Keeping them forever is rarely necessary and the table will otherwise grow unbounded.

    What About PUT, PATCH and DELETE

    PUT and DELETE are idempotent by specification, but that only holds if your implementation actually behaves that way. A PUT that recalculates a value relative to its current state — incrementing a counter rather than setting an absolute value — breaks the guarantee. Audit these endpoints for hidden relative operations, since that is where idempotency violations quietly creep into code that looks safe on the surface.

    An endpoint is only as idempotent as its least obvious side effect.

    Wrapping Up

    Idempotency keys turn "did that request actually go through?" from a guessing game into a solved problem. Apply the pattern to any endpoint with a real side effect — payments, order creation, notification sends — store enough to replay the original response, and close the race condition with a unique constraint rather than an application-level check alone.

    Written by Rajasekar

    Founder & Editor

    I write HorizonDesk: practical, no-hype articles on technology, money, business and the vehicles we drive. Everything here is something I’ve researched, tested or wanted explained clearly myself.