Driver FixRecommendedSound, Wi-Fi or graphics acting up? Check drivers firstFind missing or outdated drivers fast.Check DriversOctober DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsPC HealthRecommendedCrashes, freezes, slowdowns? Check your PC nowSpot repairable issues before they interrupt work.Check PC×
Skip to content
World desk10 min

API Idempotency Keys in Laravel: Stopping Duplicate Writes on Retry

A timed-out POST can be retried safely only if the server recognizes the retry. Here is a Laravel design for idempotency keys covering scope, fingerprints, unique-index claims, replay rules, locks, expiry and external side effects.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A client that times out on POST /api/orders does not know whether the order was created. The safe answer is to let it retry, and the server must then recognize the retry and return the outcome of the first attempt rather than create a second record. In a Laravel API, that recognition comes from an application-level idempotency key: the client sends a unique identifier with the write, the server claims that key atomically, stores a fingerprint of the request and the eventual response, and replays the stored response for any later attempt with the same key. HTTP method semantics alone do not solve this for POST.

Why a retry can duplicate a write

The failure happens between the server finishing its work and the client reading the answer. The server inserts the row, commits, and starts writing the response; the connection drops, a load balancer times out, or a mobile network disappears. From the client’s side, the request simply produced no response. Retrying a plain POST then asks the server to perform the operation again, and nothing in the request tells the server that this is the same operation.

As an Amazon Associate I earn from qualifying purchases.

Network timeouts are the usual trigger, but they are not the only one. Queue workers that re-run an HTTP call, SDKs with automatic retry middleware, and users who double-click a submit button all produce the same shape of problem: several requests that represent one intended operation.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Two kinds of idempotency that are easy to confuse

RFC 7231, Section 4.2.2 defines idempotent request methods in terms of intended effect: repeating an identical request should have the same effect on the server as sending it once. GET, PUT and DELETE are defined that way, and POST is not. RFC 9110 later replaced RFC 7231 and carries the same concept forward. Method idempotency describes what a method is supposed to mean. It does not tell your application that two POST requests came from one user action.

An application idempotency key is a separate mechanism. The client supplies an identifier for one logical operation, and the server uses it to recognize retries. The IETF document draft-ietf-httpapi-idempotency-key-header describes an Idempotency-Key header for this purpose. It is an Internet-Draft, not a finalized RFC, so its names and details may change. Treat it as a well-reasoned pattern to follow, and document your own contract for clients.

The key does not replace authorization, validation or database constraints. A key that is valid but belongs to another tenant must still be rejected by your access rules, and a duplicate email address should still fail a unique index. The key answers one question only: has this operation already been attempted?

Designing the key

Scope the key to a principal and an operation

A key that is unique across the whole table is fragile, because two unrelated clients could generate the same value by accident, and a malicious caller could probe other callers’ keys. A common and practical design scopes each key to the authenticated principal or tenant plus the HTTP method and route. Laravel does not impose this scope; it is a design recommendation. Build the scope string from the values your application already trusts, such as $request->user() and the route name, rather than from client-controlled body fields.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Validate format and require the key where it matters

Accept keys only within a bounded length and character set. The IETF draft recommends random, UUID-like identifiers, so a practical rule is a 36-character UUID or a similar high-entropy string up to a documented maximum such as 255 characters. Reject anything else with a 422 response that names the header. Require the key only on the operations that create or change money, inventory, or other records where a duplicate is costly, and document which ones those are. A generic client cannot assume every endpoint honors the header, so the published API contract is the source of truth.

Fingerprint the request

Store a fingerprint of the request body with the key. The draft lists whole-payload checksums, selected fields, field matches and request digests as possible approaches. For most JSON APIs, a SHA-256 hash of a canonical form works well. Canonicalization matters: if you hash the raw body, two semantically identical payloads with different key order or whitespace will look like different requests. Sort object keys recursively, encode with a fixed set of JSON flags, and hash the result. When a key arrives again with a different fingerprint, the request is a conflict, not a replay. Return an error that tells the client it reused a key for different data.

Storage: the schema that makes replay possible

The table needs a unique constraint on the scope and key, a status, the stored response, and timing fields for expiry. The migration below is one reasonable shape; the column choices are application decisions, not Laravel defaults.

Schema::create('idempotency_keys', function (Blueprint $table) {
    $table->id();
    $table->string('scope', 191);
    $table->string('key', 255);
    $table->char('fingerprint', 64);
    $table->string('status', 20);            // in_progress or completed
    $table->unsignedSmallInteger('response_status')->nullable();
    $table->longText('response_body')->nullable();
    $table->timestamps();

    $table->unique(['scope', 'key']);
    $table->index('created_at');
});

The unique index is the actual safety mechanism. Application code that checks for an existing row and then inserts one has a race window between the check and the insert, and two concurrent requests can both pass the check.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Request flow: claim, write, store, replay

Wrap the claim, the domain write and the response update in one database transaction when they all live in the same database. Laravel’s DB::transaction groups query-builder and Eloquent work and rolls it back when an exception escapes the closure, which means a failed write also rolls back its claim. The sequence below assumes MySQL or PostgreSQL, where a second insert on the same unique key waits for the first transaction to finish.

  1. Read the Idempotency-Key header. If it is missing on an endpoint that requires it, return 400 or 422 with a message that names the header. Validate its format.
  2. Build the scope from the authenticated tenant, the method and the route, and compute the payload fingerprint from the canonical body.
  3. Inside a transaction, insert the claim row with status in_progress. If the insert succeeds, this request owns the key.
  4. Run the domain write, build the response, then update the claim row to completed with the status code and body. Commit.
  5. If the insert hits the unique index, the key already exists. Load the existing row and go to the replay rules below. Do not run the domain write.
public function store(Request $request, string $key, OrderWriter $writer): JsonResponse
{
    $scope = $request->user()->tenant_id . '|' . $request->method() . '|' . $request->path();
    $fingerprint = hash('sha256', $this->canonicalize($request->all()));

    try {
        return DB::transaction(function () use ($scope, $key, $fingerprint, $request, $writer) {
            $claim = IdempotencyKey::create([
                'scope' => $scope,
                'key' => $key,
                'fingerprint' => $fingerprint,
                'status' => 'in_progress',
            ]);

            $response = $writer->createFrom($request);

            $claim->update([
                'status' => 'completed',
                'response_status' => $response->getStatusCode(),
                'response_body' => $response->getContent(),
            ]);

            return $response;
        });
    } catch (QueryException $e) {
        if (! in_array($e->getCode(), ['23000', '23505'], true)) {
            throw $e;
        }
        return $this->replay($scope, $key, $fingerprint);
    }
}

The example is a sketch. SQLSTATE class 23000 is a general integrity-violation code, so a production version should confirm that the violation comes from your unique index before replaying. The replay() method, which compares fingerprints and returns the stored response or a conflict, is where the policy below is implemented.

Concurrency: locks and durable storage do different jobs

A transaction and a unique index already serialize duplicate claims in one database. A cache lock is useful for a different purpose: stopping two requests from running an expensive side effect at the same moment, or coordinating work across several application processes when the database is not the only shared resource. Laravel’s atomic locks, exposed through Cache::lock(), are documented in the cache configuration reference. A lock is only meaningful when every application instance uses the same supported backend. A local file or array store cannot coordinate requests that land on different servers.

$lock = Cache::lock("idempotency:{$scope}:{$key}", 30);

if (! $lock->get()) {
    return response()->json(['message' => 'A request with this key is already in progress.'], 409);
}

try {
    return $this->store($request, $key, $writer);
} finally {
    $lock->release();
}

Choose the lock duration from the operation’s real worst-case duration, not a guess. If the lock expires while the first request is still running, a second request can enter. The lock is not a replay history, so keep the durable claim row even when you use a lock. Laravel’s withoutOverlapping middleware for queued jobs solves a related problem for scheduled and queued work and should not be confused with HTTP key handling.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Replay rules for each case

The table below lists the decisions an API must make. The “Stripe” column reflects Stripe’s documented behavior as of the API reference consulted for this article; it is one provider’s choice, not a standard. The “Suggested” column is a design recommendation for a Laravel application.

Case Suggested behavior Stripe behavior (per its API reference)
Same key, same fingerprint, operation completed Return the stored status and body, and mark the response as a replay with a header such as Idempotent-Replayed: true Returns the stored first result
Same key, different fingerprint Reject with 422 and a clear message that the key was reused for a different payload Compares reused-key parameters; the documented handling of a mismatch is not restated here
Same key, first request still running Return 409 Conflict and let the client retry later; do not execute the write Conflicts with an executing request are not stored
First attempt failed with a validation error Do not store; the client can correct the input and resend with the same key Validation failures are not stored
First attempt failed after the write began Decide explicitly. Storing the error means a retry gets the same error; not storing it means a retry runs the write again Stores the first status and body, including errors after execution begins
Key older than the retention window Treat as new and document it; a replay after expiry may execute again Keys may be pruned once at least 24 hours old

The “failed after the write began” row is the decision most teams get wrong. If your transaction rolls back on exception, the claim row disappears with the write, and a retry runs the operation again. That is safe for database-only work. It is not safe if the write called an external service before the exception, which the section on external effects covers.

Retention and expiry

Keys cannot be stored forever without a cost, and every expiry policy has a trade-off. Once a row is deleted, a late retry with the same key looks new and can create a second record. The IETF draft states that the resource owner is responsible for the lifecycle of keys and should publish its expiry policy, so put the window in your API reference, not only in code. Choose a window longer than your clients’ realistic retry horizon, including backoff and queue delays. Prune with a scheduled command that deletes completed rows older than that window, and leave in-progress rows alone unless their lock has clearly expired, so a crashed request does not block the key forever.

External side effects need their own design

A local database transaction cannot atomically commit a call to a remote payment provider, email service or shipping API. If the provider charges the card and the process dies before your commit, rolling back the local claim does not undo the charge. Two patterns cover this. The first is to pass an idempotency key to the provider itself, derived from your key, so the provider deduplicates its side. The second is an outbox: record the intent to call the provider in the same transaction as the local claim, then send it from a worker that retries and reconciles against the provider’s records. Neither pattern is provided by Laravel’s transaction helper, and the API-level key alone does not cover the external call.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

In practice, the goal is not “exactly once.” It is one intended business effect for one identified operation, within the key scope and retention window you have documented, with explicit handling for the cases above. Say that precisely in your client documentation.

Checks before shipping an endpoint

  • Send the same request twice with one key concurrently, and confirm only one domain record exists afterward.
  • Reuse a key with a changed payload, and confirm the response is a conflict rather than a replay.
  • Force an exception after the domain write, and confirm the behaviour matches the failure rule you documented.
  • Confirm the lock backend is shared by every instance that serves the endpoint.
  • Confirm the pruning command runs and that the retention window appears in the public documentation.

”

The Bottom Line

For Laravel APIs, the dependable pattern is a client-supplied key scoped to the principal and operation, a unique database index that makes the claim atomic, a request fingerprint that rejects reuse with different data, and a stored response that replays the first outcome. Cache locks help with concurrent execution but do not replace durable storage, and external side effects need their own idempotency or outbox design.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
PC Slower Than It Used to Be?Free scan - under a minute
Crashes, No Sound, or Screen Glitches?Free driver scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.