Introduction

Retries are one of those things that look harmless until they are not.

A user clicks the checkout button twice. A mobile client loses the response after sending the request. A load balancer retries a connection. A queue worker calls an internal API again because the first attempt timed out. The client is not trying to break your application. It is just trying to recover from an uncertain state.

But if the endpoint is not designed for retries, that uncertainty can become very expensive.

You can end up with:

  • two orders for the same cart
  • two payment attempts for the same invoice
  • duplicated webhook processing
  • multiple subscription changes
  • repeated email notifications
  • inconsistent audit trails

That is where idempotency becomes important.

Idempotency is the idea that an operation can be performed multiple times while producing the same final effect as performing it once. In HTTP APIs, it usually means this: if a client retries the same write request with the same idempotency key, your application should not perform the write again.

In this article, let's do a deep dive into idempotency and how to apply it in a Laravel application using my package: wendelladriel/laravel-idempotency.

We will cover the theory first, because idempotency is not just a middleware you throw at every route. Then we will move into a practical Laravel implementation with:

  • idempotency keys
  • request fingerprints
  • cached response replay
  • atomic locks for concurrent requests
  • key scopes
  • route middleware and controller attributes
  • operational commands for inspecting and clearing cached entries
  • tests for the most important retry flows

The goal is not only to install a package. The goal is to understand the shape of the problem so you can decide where idempotency belongs in your application.

What Idempotency Actually Is

At its core, idempotency is a property of an operation.

If running an operation once or many times leaves the system in the same state, that operation is idempotent.

A simple example is updating a user's preferred language:

$user->update([
    'language' => 'en',
]);

Running this once or ten times leaves the user with the same language. The final state is stable.

Now compare that with incrementing credits:

$wallet->increment('credits', 100);

Running this once adds 100 credits. Running it ten times adds 1000 credits. The result depends on how many times the operation was executed, so it is not idempotent.

This distinction matters a lot in web applications because clients do not always know whether the server processed a request.

Imagine this flow:

sequenceDiagram
    participant C as Client
    participant A as Laravel API
    participant D as Database

    C->>A: POST /orders<br/>Idempotency-Key: checkout-123
    A->>D: Create order
    D-->>A: Order #1001 created
    A--xC: Network timeout before response arrives
    C->>A: Retry POST /orders<br/>Idempotency-Key: checkout-123
    A-->>C: Replay original 201 response

Idempotency lets a client safely retry when it does not know whether the first request succeeded.

Without idempotency, the retry may create another order. With idempotency, the retry receives the same response the first request produced.

The key detail is that the client sends a unique key that represents one intended operation. The server uses that key to decide whether the request is new or a retry.

HTTP Methods and Idempotency

HTTP already gives us some language around this topic.

GET, HEAD, OPTIONS, and DELETE are usually expected to be idempotent. Calling DELETE /articles/1 twice should not delete two articles. The first call deletes the article, and the second call may return 404, 204, or another response depending on your design, but it should not produce an additional side effect.

POST is different. It usually means "create something" or "execute this command". Calling the same POST /orders endpoint twice often creates two orders.

PUT and PATCH are more nuanced. They often update existing state, so they can be naturally idempotent when they set absolute values. But they can also trigger side effects, dispatch jobs, call external services, or execute domain commands.

That is why the package focuses on write-oriented methods:

  • POST
  • PUT
  • PATCH

The package skips other methods because idempotency keys are mainly useful when the request can create or mutate state.

Idempotency Is Not Deduplication Only

A common mistake is treating idempotency as simple duplicate detection.

Duplicate detection asks:

Have I seen this key before?

Idempotency asks a better question:

Have I seen this same operation before, in the same scope, with the same request data, and can I safely return the same result?

That difference is important.

If a client sends the same key with different data, replaying the first response would hide a bug or create confusing behavior. The correct response is to reject the request.

For example, this should be accepted as a retry:

POST /orders
Idempotency-Key: checkout-123

{
    "cart_id": 10,
    "shipping_method": "standard"
}

And the same request again should replay the original response.

But this should not be treated as a retry:

POST /orders
Idempotency-Key: checkout-123

{
    "cart_id": 10,
    "shipping_method": "express"
}

The key is the same, but the operation is not. A good idempotency layer should detect that mismatch and return an error.

That is why request fingerprinting matters.

The Moving Parts

A practical idempotency implementation has a few parts working together.

flowchart TD
    A[Incoming write request] --> B{Has idempotency key?}
    B -->|No, required| C[400 Missing Header]
    B -->|No, optional| D[Run route normally]
    B -->|Yes| E[Resolve scope<br/>user, IP, or global]
    E --> F[Build storage key]
    F --> G{Stored response exists?}
    G -->|Yes, same fingerprint| H[Replay stored response]
    G -->|Yes, different fingerprint| I[422 Key reused with different data]
    G -->|No| J{Acquire atomic lock}
    J -->|Lock unavailable| K[409 In-flight duplicate<br/>Retry-After: 1]
    J -->|Lock acquired| L[Execute controller]
    L --> M[Store response with TTL]
    M --> N[Release lock]

A complete idempotency flow needs key validation, request fingerprinting, response replay, and concurrency protection.

Let's break this down.

Idempotency key

The idempotency key is a client-provided identifier for one intended operation.

It should be unique per operation, not unique per endpoint forever. A checkout attempt, a payment confirmation, or a webhook delivery can each have its own key.

The package uses the Idempotency-Key header by default:

Idempotency-Key: 4fda4d5c-6ff8-4f9c-8e9d-4c8f3b55e8a0

If your application needs a different header, you can configure it globally or per route.

Scope

The same idempotency key should not always mean the same thing for everyone.

If two authenticated users both send checkout-1, those should probably be two different keys in practice. If a payment provider sends webhook event IDs, you may want a global scope because the event ID itself is globally unique.

The package supports three scopes:

  • user: segment keys by authenticated user, with guests falling back to IP address
  • ip: segment keys by client IP address
  • global: reuse the same key across users and IP addresses

The scope prevents accidental collisions and lets you choose the correct boundary for each endpoint.

Request fingerprint

The fingerprint represents the actual operation.

The package builds it from the method, route identity, query string, request payload, and content type. For JSON payloads, it sorts keys recursively before hashing the body, so equivalent JSON objects are treated consistently even if key order changes.

This is what allows the package to say:

  • same key + same fingerprint = replay
  • same key + different fingerprint = reject with 422

Stored response

When the first request completes, the package stores the response in Laravel's cache for a limited time. On a retry, it returns that stored response instead of executing the route again.

The replayed response includes this header:

Idempotency-Replayed: true

That small header is useful when debugging client behavior or observing retry patterns.

Atomic lock

There is one more problem: concurrent duplicates.

Two identical requests can arrive at almost the same time, before the first response has been stored.

If the implementation only checks for a cached response, both requests may pass the check and both may execute the controller.

That is why the package uses Laravel's atomic locks. The first request acquires a lock for the storage key. If another matching request arrives while that first one is still processing, the package returns 409 Conflict with a Retry-After: 1 header.

This part is not optional in production. Your cache driver must support atomic locks, and all application servers must share the same central cache store.

Installing the Package

Now let's move into Laravel.

Install the package with Composer:

composer require wendelladriel/laravel-idempotency

Then publish the configuration file if you need to customize the defaults:

php artisan vendor:publish --tag=idempotency

This creates config/idempotency.php:

use WendellAdriel\Idempotency\Enums\IdempotencyScope;

return [
    'ttl' => env('IDEMPOTENCY_TTL', 3600),
    'lock_timeout' => env('IDEMPOTENCY_LOCK_TIMEOUT', 10),
    'required' => env('IDEMPOTENCY_REQUIRED', true),
    'scope' => env('IDEMPOTENCY_SCOPE', IdempotencyScope::User->value),
    'header' => env('IDEMPOTENCY_HEADER', 'Idempotency-Key'),
];

These defaults are a good starting point:

  • responses are stored for one hour
  • idempotency keys are required on protected routes
  • keys are scoped by authenticated user
  • the package reads the Idempotency-Key header
  • in-flight locks live for 10 seconds

You should adjust ttl and lock_timeout based on the endpoint. A checkout endpoint may need a shorter or longer replay window than an internal reconciliation endpoint.

Applying Idempotency with Route Middleware

The most direct way to use the package is route middleware.

Imagine a simple order creation endpoint:

use App\Http\Controllers\StoreOrderController;
use Illuminate\Support\Facades\Route;
use WendellAdriel\Idempotency\Http\Middleware\Idempotent;

Route::post('/orders', StoreOrderController::class)
    ->name('orders.store')
    ->middleware(Idempotent::class);

Now the client must send an idempotency key:

POST /orders
Idempotency-Key: checkout-01HZX9G6M2M4N9Z5F3R4N7

{
    "cart_id": 15,
    "shipping_method": "standard"
}

The first request executes StoreOrderController and stores the response. A retry with the same key and payload replays the original response.

If you prefer middleware aliases, the package registers idempotent too:

Route::post('/orders', StoreOrderController::class)
    ->name('orders.store')
    ->middleware('idempotent');

For many applications, this is the best integration style because it keeps retry behavior visible at the route level.

Customizing an Endpoint

Some endpoints need different behavior.

For example, a guest checkout flow may be better scoped by IP address, may accept a custom header, and may need a shorter TTL:

use App\Http\Controllers\ChargePaymentController;
use WendellAdriel\Idempotency\Enums\IdempotencyScope;
use WendellAdriel\Idempotency\Http\Middleware\Idempotent;

Route::post('/payments', ChargePaymentController::class)
    ->name('payments.store')
    ->middleware(Idempotent::using(
        ttl: 600,
        lockTimeout: 30,
        required: true,
        scope: IdempotencyScope::Ip,
        header: 'X-Idempotency-Key',
    ));

Now this endpoint stores replayable responses for 10 minutes, uses a 30-second lock, scopes keys by IP address, and reads X-Idempotency-Key instead of the default header.

The important part is that these options are local to this route. You can keep sane global defaults and only override the endpoints that need a different boundary.

Applying Idempotency with Attributes

If your application groups related write actions inside a controller, attributes can make the intent cleaner.

namespace App\Http\Controllers;

use App\Models\Order;
use Symfony\Component\HttpFoundation\Response;
use WendellAdriel\Idempotency\Attributes\Idempotent;

#[Idempotent]
final class OrderController
{
    public function store(): Response
    {
        // Create the order...
    }

    public function update(Order $order): Response
    {
        // Update the order...
    }
}

You can also combine class-level defaults with method-level overrides:

namespace App\Http\Controllers;

use WendellAdriel\Idempotency\Attributes\Idempotent;
use WendellAdriel\Idempotency\Enums\IdempotencyScope;

#[Idempotent]
final class PaymentController
{
    #[Idempotent(ttl: 600, lockTimeout: 30, scope: IdempotencyScope::Ip, header: 'X-Idempotency-Key')]
    public function store()
    {
        // Charge the payment...
    }

    public function update()
    {
        // Uses the class-level idempotency defaults...
    }
}

And because the attribute extends Laravel's controller middleware attribute, you can limit it with only and except:

#[Idempotent(except: ['show'])]
final class OrderController
{
    // ...
}

I like route middleware when the behavior belongs to one or two endpoints. I like attributes when a whole controller represents retry-safe write operations.

Generating Keys

Most of the time, the client should generate the idempotency key.

That is especially true for frontend and mobile flows because the client knows when it is retrying the same intended operation. A checkout screen can generate one key when the user starts the checkout attempt and reuse it for retries.

But there are cases where your Laravel application needs to generate a key. The package provides a small helper for that:

use WendellAdriel\Idempotency\Idempotency;

$key = Idempotency::key();

This returns a random 64-character string.

One practical use case is an internal service calling another internal endpoint. The caller can generate a key, attach it to the request, and keep that key in its own retry context.

Designing Idempotent Controllers

The package protects the HTTP boundary, but your controller still needs to be designed carefully.

Idempotency middleware prevents duplicate execution for the same key. It does not magically make bad domain design safe.

Let's look at an order endpoint:

namespace App\Http\Controllers;

use App\Actions\Orders\CreateOrder;
use App\Http\Requests\StoreOrderRequest;
use Illuminate\Http\JsonResponse;

final readonly class StoreOrderController
{
    public function __construct(
        private CreateOrder $createOrder,
    ) {}

    public function __invoke(StoreOrderRequest $request): JsonResponse
    {
        $order = $this->createOrder->handle(
            user: $request->user(),
            cartId: $request->integer('cart_id'),
            shippingMethod: $request->string('shipping_method')->toString(),
        );

        return response()->json([
            'id' => $order->id,
            'number' => $order->number,
            'status' => $order->status,
        ], 201);
    }
}

This is a good fit for idempotency because the endpoint represents a single command: create an order from this cart.

But there are still domain rules you should enforce:

namespace App\Actions\Orders;

use App\Models\Cart;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use RuntimeException;

final class CreateOrder
{
    public function handle(User $user, int $cartId, string $shippingMethod): Order
    {
        return DB::transaction(function () use ($user, $cartId, $shippingMethod): Order {
            $cart = Cart::query()
                ->whereBelongsTo($user)
                ->whereKey($cartId)
                ->lockForUpdate()
                ->firstOrFail();

            if ($cart->checked_out_at !== null) {
                throw new RuntimeException('This cart has already been checked out.');
            }

            $order = Order::create([
                'user_id' => $user->id,
                'cart_id' => $cart->id,
                'shipping_method' => $shippingMethod,
                'status' => 'pending',
            ]);

            $cart->update([
                'checked_out_at' => now(),
            ]);

            return $order;
        });
    }
}

Notice the two layers:

  • the idempotency middleware protects retries with the same key
  • the domain transaction protects the cart invariant

You want both.

If a client uses the same key, the package replays the response. If a client intentionally sends a new key for a cart that was already checked out, your domain still rejects the invalid operation.

That separation is important. Idempotency is a retry-safety mechanism, not a replacement for business invariants.

Choosing the Right Scope

Scope is one of the most important design choices.

The default user scope works well for authenticated product flows:

Route::post('/orders', StoreOrderController::class)
    ->middleware(Idempotent::using(scope: IdempotencyScope::User));

With this scope, two different users can send the same client key without colliding. Guest requests fall back to IP address because there is no authenticated user identifier.

The ip scope can work for guest-heavy flows:

Route::post('/newsletter/subscriptions', SubscribeController::class)
    ->middleware(Idempotent::using(scope: IdempotencyScope::Ip));

It is not perfect because many users can share an IP address, but for some public endpoints it is a reasonable boundary.

The global scope is useful when the key itself is globally meaningful:

Route::post('/webhooks/payments', PaymentWebhookController::class)
    ->middleware(Idempotent::using(scope: IdempotencyScope::Global));

For webhooks, the provider often sends an event ID. If the same event arrives twice, you usually want the second delivery to be treated as a retry regardless of IP address or authenticated user.

So the rule I use is:

  • use user for authenticated user actions
  • use ip when guests need a retry boundary and collisions are acceptable
  • use global when the external event or command ID is globally unique

What Happens on Failures

The package intentionally returns different status codes for different problems.

If the idempotency header is required and missing, the response is 400 Bad Request:

HTTP/1.1 400 Bad Request

Missing required header: Idempotency-Key

If the same key is reused with different request data, the response is 422 Unprocessable Entity:

HTTP/1.1 422 Unprocessable Entity

Idempotency key already used with different request parameters.

If a duplicate request arrives while the first one is still processing, the response is 409 Conflict:

HTTP/1.1 409 Conflict
Retry-After: 1

A request with this idempotency key is currently being processed.

These distinctions help clients react correctly.

  • 400 means the client forgot the protocol requirement
  • 422 means the client reused a key incorrectly
  • 409 means the client should wait and retry after the in-flight request finishes

That is much better than returning a generic error for every case.

Observability and Maintenance

Idempotency becomes part of your production behavior, so you need a way to inspect it.

The package ships with two Artisan commands.

Use idempotency:list to inspect cached entries:

php artisan idempotency:list

You can filter by scope or identity:

php artisan idempotency:list --scope=user --id=5
php artisan idempotency:list --scope=global
php artisan idempotency:list --limit=20

This is useful when you are debugging a client that keeps receiving replayed responses or when you want to confirm that a critical endpoint is storing entries as expected.

Use idempotency:forget to remove entries:

php artisan idempotency:forget --key=checkout-01HZX9G6M2M4N9Z5F3R4N7 --force
php artisan idempotency:forget --scope=user --id=5 --force
php artisan idempotency:forget --all --force

You should not need these commands every day, but they are very helpful during incident response, manual testing, or support investigations.

Testing the Retry Contract

Idempotency is important enough that I like testing it directly.

Here is a simple feature test for replay behavior:

use App\Models\Order;

it('replays the original response for matching retries', function (): void {
    $payload = [
        'cart_id' => 15,
        'shipping_method' => 'standard',
    ];

    $first = $this
        ->withHeader('Idempotency-Key', 'checkout-test-1')
        ->postJson('/orders', $payload);

    $second = $this
        ->withHeader('Idempotency-Key', 'checkout-test-1')
        ->postJson('/orders', $payload);

    $first->assertCreated();
    $second
        ->assertCreated()
        ->assertHeader('Idempotency-Replayed', 'true');

    expect(Order::query()->count())->toBe(1);
    expect($second->json())->toBe($first->json());
});

And here is a test for key misuse:

it('rejects the same idempotency key with different payloads', function (): void {
    $this
        ->withHeader('Idempotency-Key', 'checkout-test-2')
        ->postJson('/orders', [
            'cart_id' => 15,
            'shipping_method' => 'standard',
        ])
        ->assertCreated();

    $this
        ->withHeader('Idempotency-Key', 'checkout-test-2')
        ->postJson('/orders', [
            'cart_id' => 15,
            'shipping_method' => 'express',
        ])
        ->assertUnprocessable();
});

Those two tests cover the main contract:

  • retries with the same operation replay
  • retries with different data are rejected

If you have long-running endpoints, add a test around 409 Conflict too. That is the behavior that protects you from concurrent duplicate execution before the first response is stored.

Common Mistakes

There are a few mistakes I would avoid.

Using the same key for multiple operations

An idempotency key should represent one intended operation. Do not use a stable user ID, cart ID, or endpoint name as the key by itself.

Use something unique to the attempt:

Idempotency-Key: checkout-cart-15-attempt-01HZX9G6M2M4N9Z5F3R4N7

Applying idempotency everywhere

Do not add idempotency middleware to every route just because it sounds safer.

Use it for write endpoints where retries are expected and duplicate execution is dangerous.

Forgetting cache infrastructure

Atomic locks depend on the cache store. In production, use a cache driver that supports locks and is shared by all application servers.

If each server uses its own local cache, idempotency will not protect you across the cluster.

Treating idempotency as authorization or validation

Idempotency does not decide whether the user is allowed to perform an action. It also does not replace validation, unique constraints, transactions, or domain invariants.

It solves one specific problem: safe retries for the same intended write operation.

Conclusion

Idempotency is one of those concepts that becomes more important as your application gets closer to real users, real money, and real distributed systems.

Networks fail. Browsers retry. Mobile clients lose responses. Webhook providers deliver the same event more than once. If your write endpoints cannot handle those scenarios, duplicate side effects will eventually happen.

With wendelladriel/laravel-idempotency, you can add a clear retry contract to Laravel endpoints:

  • clients send an idempotency key
  • the package scopes and fingerprints the request
  • the first response is stored
  • matching retries replay the original response
  • mismatched retries are rejected
  • in-flight duplicates are blocked with atomic locks

That gives your application a much safer HTTP boundary while still letting your domain model keep its own invariants.

Start with the endpoints where duplicate execution would hurt the most: checkout, payments, subscriptions, webhooks, and important state transitions. Add idempotency there first, test the retry contract, and make sure your cache infrastructure supports atomic locks.

If you want to try it in your own application, check the package on GitHub: github.com/WendellAdriel/laravel-idempotency. Install it, protect one important write endpoint, and if it helps you, give the repository a star so more Laravel developers can find it too.

I hope that you liked this article and if you do, don't forget to share this article with your friends!!! See ya!