PHPackages                             devaction-labs/idempotency - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. devaction-labs/idempotency

ActiveLibrary[HTTP &amp; Networking](/categories/http)

devaction-labs/idempotency
==========================

Elegant, production-ready idempotency middleware for Laravel APIs — smart payload hashing, per-user scoping, pluggable telemetry.

v3.0.0(3w ago)08MITPHPPHP ^8.5CI passing

Since Apr 22Pushed 1w agoCompare

[ Source](https://github.com/devaction-labs/Idempotency)[ Packagist](https://packagist.org/packages/devaction-labs/idempotency)[ RSS](/packages/devaction-labs-idempotency/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (4)Dependencies (13)Versions (5)Used By (0)

Idempotency for Laravel
=======================

[](#idempotency-for-laravel)

**Safely retry mutating HTTP requests. No double charges. No duplicated orders. No accidental side effects.**

[![Latest Version](https://camo.githubusercontent.com/a3374e60e773fe032b2d838a994c4b03563a2754df9b738ab68418ad2dd26b94/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f646576616374696f6e2d6c6162732f6964656d706f74656e63792e7376673f7374796c653d666c61742d737175617265266c6162656c3d7061636b6167697374)](https://packagist.org/packages/devaction-labs/idempotency)[![Tests](https://camo.githubusercontent.com/59de89c419bbced0d6760faa35a1f9026b692cd9123fcb5cc6edd00459924058/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f646576616374696f6e2d6c6162732f4964656d706f74656e63792f74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/devaction-labs/Idempotency/actions)[![PHPStan](https://camo.githubusercontent.com/fa63e0381a93ba9755a46ec197198ef973137dca1643836d06b3d6263c9aa7c8/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f5048505374616e2d6c6576656c2532306d61782d627269676874677265656e3f7374796c653d666c61742d737175617265)](phpstan.neon)[![Code Style](https://camo.githubusercontent.com/3b3c737aea496c6e1d101e71596a571b3cb6f4e999ae19f620f7c7a1a211ba23/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f636f64652532307374796c652d70696e742d6f72616e67653f7374796c653d666c61742d737175617265)](pint.json)[![PHP](https://camo.githubusercontent.com/fa309d786b255bb6f956f04c3ec5be84b927433f059d0e19fc691335b88ce6bc/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f7068702d762f646576616374696f6e2d6c6162732f6964656d706f74656e63793f7374796c653d666c61742d737175617265)](composer.json)[![License](https://camo.githubusercontent.com/cd928353e9ccbf27b548edced1a0f46280a9b2f2a774c5f64a49ef1a7fe4abd2/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f646576616374696f6e2d6c6162732f4964656d706f74656e63793f7374796c653d666c61742d737175617265)](LICENSE.md)

---

Why this exists
---------------

[](#why-this-exists)

Your payment endpoint is a bomb waiting to go off. A mobile client's Wi-Fi hiccups, the request retries, and now you've charged the customer twice. You add a `requests` table with a unique constraint. A month later, a webhook consumer takes 31 seconds to respond, the sender retries, and you've just shipped two of the same order.

Idempotency is the protocol-level answer: the client sends a unique `Idempotency-Key` header, the server guarantees the same key produces the same outcome exactly once — even under retries, concurrency, and network failures.

This package is that guarantee, made Laravel-native.

```
POST /api/payments HTTP/1.1
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000
Content-Type: application/json

{ "amount": 1000, "currency": "USD" }
```

- **First request** → processes and returns `201 Created` with `Idempotency-Status: Original`
- **Retry with same key + same payload** → returns the cached `201` with `Idempotency-Status: Repeated`
- **Retry with same key + different payload** → returns `422` (key reuse with different intent)
- **Concurrent retry while first is still processing** → returns `409` (another request in flight)

---

Table of contents
-----------------

[](#table-of-contents)

- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start](#quick-start)
- [How it works](#how-it-works)
- [Per-route configuration](#per-route-configuration)
- [Configuration reference](#configuration-reference)
- [Scoping](#scoping)
- [Payload fingerprinting](#payload-fingerprinting)
- [Events and alerts](#events-and-alerts)
- [Telemetry](#telemetry)
- [Custom resolvers](#custom-resolvers)
- [Client integration](#client-integration)
- [Artisan commands](#artisan-commands)
- [Testing](#testing)
- [Deployment &amp; hardening](#deployment--hardening)
- [FAQ](#faq)
- [License](#license)

---

Requirements
------------

[](#requirements)

DependencyVersionPHP**8.3, 8.4, 8.5**Laravel**11.x / 12.x / 13.x** (Laravel 10 reached security EOL in Aug 2025)Cache storeany driver with atomic locks — `redis`, `memcached`, `database`, `dynamodb`Installation
------------

[](#installation)

```
composer require devaction-labs/idempotency
php artisan vendor:publish --tag=idempotency-config
```

The service provider auto-registers and aliases the middleware as `idempotent`.

Quick start
-----------

[](#quick-start)

Wrap any mutating route in the `idempotent` middleware:

```
// routes/api.php
Route::middleware(['auth:api', 'idempotent'])->group(function () {
    Route::post('/payments',  [PaymentController::class, 'store']);
    Route::post('/refunds',   [RefundController::class, 'store']);
    Route::delete('/orders/{order}', [OrderController::class, 'destroy']);
});
```

Clients opt in by sending a UUID (or ULID, or any shape you configure) in the `Idempotency-Key` header. Done.

---

How it works
------------

[](#how-it-works)

```
┌───────────────┐     ┌──────────────────┐     ┌──────────────┐
│  Client sends │ ──► │  Acquire atomic  │ ──► │  Replay from │
│ Idempotency-  │     │  lock + check    │     │  cache if    │
│ Key header    │     │  cache           │     │  we have it  │
└───────────────┘     └──────────────────┘     └──────────────┘
                              │
                              ▼
                      ┌───────────────────┐
                      │ First time: run   │
                      │ handler, cache    │
                      │ response, release │
                      │ lock              │
                      └───────────────────┘

```

1. Validate the key format (UUID by default).
2. Compute a scope-aware cache key (`idempotency:{scope}:{key}:response`).
3. Acquire an atomic lock; if already held, wait up to `lock.wait` seconds.
4. Check the cache. If present, validate the payload matches and replay.
5. Otherwise, execute the route and cache the response if its status code is in range.
6. Release the lock.

Every step is instrumented — see [Events](#events-and-alerts) and [Telemetry](#telemetry).

---

Per-route configuration
-----------------------

[](#per-route-configuration)

Middleware parameters let you tune behaviour per route without touching config:

```
// Allow the route to be called without a key (e.g. public webhook probe)
Route::post('/webhooks/stripe', $handler)
    ->middleware('idempotent:optional');

// Short TTL for ephemeral actions
Route::post('/votes', $handler)
    ->middleware('idempotent:ttl=60');

// Stricter scope for tenant-partitioned operations
Route::post('/charges', $handler)
    ->middleware('idempotent:ttl=900,scope=user');
```

ParameterEffect`optional`Missing key is allowed — route runs without idempotency`ttl=`Override the default TTL for this route`scope=`Override the scope strategy for this route---

Configuration reference
-----------------------

[](#configuration-reference)

The full file lives at [`config/idempotency.php`](config/idempotency.php). The important knobs:

```
return [
    'enabled'     => env('IDEMPOTENCY_ENABLED', true),
    'header_name' => env('IDEMPOTENCY_HEADER_NAME', 'Idempotency-Key'),
    'methods'     => ['POST', 'PUT', 'PATCH', 'DELETE'],

    'cache_store' => env('IDEMPOTENCY_CACHE_STORE', null),  // null = default
    'ttl'         => (int) env('IDEMPOTENCY_TTL', 86_400),   // seconds

    'scope' => env('IDEMPOTENCY_SCOPE', 'user_route'),
    //       global | user | route | ip | user_route | FQCN

    'validation' => [
        'pattern'        => env('IDEMPOTENCY_KEY_PATTERN', 'uuid'),
        'max_key_length' => (int) env('IDEMPOTENCY_KEY_MAX_LENGTH', 255),
    ],

    'payload' => [
        'algo'          => env('IDEMPOTENCY_HASH_ALGO', 'sha256'),
        'sort_keys'     => true,
        'ignore'        => ['timestamp', 'client_request_id'],
        'include_files' => true,
    ],

    'cacheable_status' => [
        'min'     => 200,
        'max'     => 499,
        'exclude' => [408, 409, 425, 429],  // transient errors not replayed
    ],

    'lock' => [
        'timeout' => (int) env('IDEMPOTENCY_LOCK_TIMEOUT', 30),
        'wait'    => (int) env('IDEMPOTENCY_LOCK_WAIT', 5),
    ],

    'alerts' => [
        'hit_threshold' => (int) env('IDEMPOTENCY_ALERT_HIT_THRESHOLD', 5),
        'cooldown'      => (int) env('IDEMPOTENCY_ALERT_COOLDOWN', 3_600),
    ],

    'telemetry' => [
        'enabled'             => env('IDEMPOTENCY_TELEMETRY_ENABLED', true),
        'driver'              => env('IDEMPOTENCY_TELEMETRY_DRIVER', 'null'),
        'custom_driver_class' => null,
    ],
];
```

---

Scoping
-------

[](#scoping)

Scoping is the invisible safety net that stops user A's key from ever matching user B's cached response.

ScopeKey partitionWhen to pick it`global`noneInternal / trusted clients only`user`authenticated user idUser-level idempotence across their own routes`route`route name or URIPublic endpoints that shouldn't cross-pollinate`ip`client IPAnonymous POSTs from the same caller`user_route` *(default)*user id + routeMost apps want thisNeed something custom (tenant id, API key, device id)? Implement `ScopeResolver` — see [Custom resolvers](#custom-resolvers).

---

Payload fingerprinting
----------------------

[](#payload-fingerprinting)

The package guards against "same key, different body" by hashing the payload and comparing. The hash is:

- Deterministic — keys are recursively sorted before hashing, so `{a:1,b:2}` and `{b:2,a:1}` match.
- Configurable — pick your algorithm (`sha256`, `xxh128`, …) via `payload.algo`.
- File-aware — uploaded files are fingerprinted by name + size + mime + content hash.
- Redactable — `payload.ignore` lets you strip volatile fields (`timestamp`, `client_request_id`) before hashing.

```
'payload' => [
    'algo'               => 'sha256',
    'sort_keys'          => true,
    'ignore'             => ['timestamp', 'captured_at'],
    'include_files'      => true,
    'hash_file_contents' => true,
    'max_payload_bytes'  => null,
    'max_file_bytes'     => null,
],
```

For public upload endpoints, set `max_payload_bytes` and `max_file_bytes` to fail closed with `413` before the middleware spends unbounded CPU or disk I/O on hashing. If file metadata is enough for a route, set `hash_file_contents` to `false` and keep `include_files` enabled.

---

Response replay headers
-----------------------

[](#response-replay-headers)

The default serializer stores response status, body, and replay-safe headers. It does not store sensitive or hop-by-hop headers such as `Set-Cookie`, `Authorization`, `WWW-Authenticate`, `Connection`, or `Transfer-Encoding`.

Add application-specific headers to the strip list when publishing the config:

```
'response' => [
    'strip_headers' => ['X-Internal-Token'],
],
```

If an endpoint must replay a normally stripped header, bind a custom `ResponseSerializer` for that application.

---

Events and alerts
-----------------

[](#events-and-alerts)

The package dispatches `IdempotencyAlertFired` whenever something interesting happens. Listen for it and route to logs, Sentry, Slack — whatever:

```
use DevactionLabs\Idempotency\Events\IdempotencyAlertFired;
use DevactionLabs\Idempotency\Logging\EventType;

Event::listen(IdempotencyAlertFired::class, function (IdempotencyAlertFired $event): void {
    match ($event->eventType) {
        EventType::PAYLOAD_MISMATCH    => logger()->warning('idempotency.mismatch', $event->context),
        EventType::CONCURRENT_CONFLICT => logger()->info('idempotency.concurrent',   $event->context),
        EventType::SIZE_WARNING        => logger()->notice('idempotency.large',      $event->context),
        default                        => logger()->debug('idempotency.'.$event->eventType->value, $event->context),
    };
});
```

Full event catalogue (`DevactionLabs\Idempotency\Logging\EventType`):

CaseFires when`RESPONSE_DUPLICATE`Hit count on a key exceeds `alerts.hit_threshold``PAYLOAD_MISMATCH`Same key reused with a different request body`CONCURRENT_CONFLICT`A second request hits while the first is still processing`LOCK_INCONSISTENCY`Lock acquisition failed and no processing marker was found`SIZE_WARNING`Cached response exceeds `size_warning` bytes`EXCEPTION_THROWN`Cache or handler threw during processing`MISSING_KEY` / `INVALID_KEY_FORMAT`Client supplied a bad or absent keyAlerts have a built-in cooldown (`alerts.cooldown`, defaults to 1h) so a bad client can't flood your logs. By default, alert context redacts raw idempotency keys into `idempotency_key_hash` and suppresses exception messages. You can opt out with `alerts.redact_context=false` or `alerts.include_exception_messages=true` if your logging pipeline is already scoped for sensitive data.

---

Telemetry
---------

[](#telemetry)

Shipped drivers: `null` (default) and `inspector`.

```
# To use Inspector
composer require inspector-apm/inspector-laravel
```

```
IDEMPOTENCY_TELEMETRY_DRIVER=inspector
```

The driver records: request counts, cache hit/miss, lock acquisition time, processing time, response size.

Write your own by implementing `DevactionLabs\Idempotency\Contracts\TelemetryDriver` and pointing `telemetry.custom_driver_class` at it.

---

Custom resolvers
----------------

[](#custom-resolvers)

Every piece of logic sits behind a contract. Swap any of them:

```
use DevactionLabs\Idempotency\Contracts\{
    KeyValidator,
    PayloadHasher,
    ScopeResolver,
    ResponseSerializer,
    TelemetryDriver,
};

// In your AppServiceProvider
public function register(): void
{
    $this->app->bind(ScopeResolver::class, TenantScopeResolver::class);
    $this->app->bind(PayloadHasher::class, WebhookAwareHasher::class);
}
```

A tenant-aware scope, for example:

```
final class TenantScopeResolver implements ScopeResolver
{
    public function resolve(Illuminate\Http\Request $request): string
    {
        $tenant = $request->header('X-Tenant-ID') ?? 'public';
        $user   = $request->user()?->getAuthIdentifier() ?? 'guest';

        return "t:{$tenant}:u:{$user}";
    }
}
```

---

Client integration
------------------

[](#client-integration)

The server is only half of the contract — the client has to do three things correctly:

1. **Generate one key per logical operation**, not per HTTP attempt.
2. **Send the same key on every retry** of that operation.
3. **Only retry on transient failures** (network errors, `408`, `409`, `429`, `5xx`).

Below are drop-in patterns for the stacks you'll actually encounter.

### Key generation

[](#key-generation)

Use a UUID v4/v7 — `crypto.randomUUID()` is in every modern browser and Node runtime:

```
// src/idempotency.ts
export const newIdempotencyKey = (): string =>
    (globalThis.crypto && 'randomUUID' in globalThis.crypto)
        ? globalThis.crypto.randomUUID()
        : fallbackUuidV4();

function fallbackUuidV4(): string {
    const b = new Uint8Array(16);
    crypto.getRandomValues(b);
    b[6] = (b[6] & 0x0f) | 0x40;
    b[8] = (b[8] & 0x3f) | 0x80;
    const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('');
    return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}
```

**Rule of thumb**: bind the key to the user's intent, not the request. A "Pay" button click creates **one** key; every retry of that click reuses it. If the user clicks Pay again after seeing a final error, that's a new intent — new key.

### fetch + AbortController + retry

[](#fetch--abortcontroller--retry)

```
type IdempotentOptions = {
    url: string;
    body: unknown;
    key?: string;
    signal?: AbortSignal;
    maxAttempts?: number;
};

const RETRY_ON = new Set([408, 409, 425, 429, 500, 502, 503, 504]);

export async function idempotentPost({
    url,
    body,
    key = newIdempotencyKey(),
    signal,
    maxAttempts = 4,
}: IdempotentOptions): Promise {
    let lastError: unknown;

    for (let attempt = 1; attempt  setTimeout(resolve, delay));
    }

    throw lastError;
}

class HttpError extends Error {
    constructor(public status: number, public body: string) {
        super(`HTTP ${status}`);
    }
}
```

Usage:

```
const payment = await idempotentPost({
    url: '/api/payments',
    body: { amount: 1000, currency: 'USD', order_id: order.id },
});
```

### Axios interceptor

[](#axios-interceptor)

An interceptor that attaches an idempotency key to every mutating request, and retries on transient failures reusing the same key:

```
// src/http.ts
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { newIdempotencyKey } from './idempotency';

const MUTATING = new Set(['post', 'put', 'patch', 'delete']);
const RETRY_ON = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
const MAX_ATTEMPTS = 4;

export const http = axios.create({ baseURL: '/api' });

http.interceptors.request.use((config) => {
    const method = (config.method ?? 'get').toLowerCase();

    if (MUTATING.has(method) && !config.headers['Idempotency-Key']) {
        config.headers['Idempotency-Key'] = newIdempotencyKey();
    }

    return config;
});

http.interceptors.response.use(undefined, async (error: AxiosError) => {
    const config = error.config as AxiosRequestConfig & { __attempt?: number };
    if (!config) throw error;

    const status = error.response?.status;
    const networkError = !error.response;
    const shouldRetry = networkError || (status !== undefined && RETRY_ON.has(status));
    if (!shouldRetry) throw error;

    config.__attempt = (config.__attempt ?? 0) + 1;
    if (config.__attempt >= MAX_ATTEMPTS) throw error;

    const delay = Math.min(8_000, 2 ** config.__attempt * 250) * Math.random();
    await new Promise(r => setTimeout(r, delay));

    return http.request(config); // same Idempotency-Key header travels with the config
});
```

Call sites are oblivious to any of it:

```
const { data: order } = await http.post('/orders', payload);
```

### React — useIdempotentMutation

[](#react--useidempotentmutation)

A hook that guarantees one key per mount-and-submit cycle, regenerated only after success or definitive failure:

```
import { useCallback, useRef, useState } from 'react';

type State =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: unknown };

export function useIdempotentMutation(
    url: string,
) {
    const keyRef = useRef(null);
    const [state, setState] = useState({ status: 'idle' });

    const submit = useCallback(async (body: TBody): Promise => {
        keyRef.current ??= newIdempotencyKey();
        setState({ status: 'loading' });

        try {
            const data = await idempotentPost({ url, body, key: keyRef.current });
            setState({ status: 'success', data });
            keyRef.current = null; // next call is a new intent
            return data;
        } catch (error) {
            setState({ status: 'error', error });
            // Key stays so the user can retry the same logical action
            throw error;
        }
    }, [url]);

    const reset = useCallback(() => {
        keyRef.current = null;
        setState({ status: 'idle' });
    }, []);

    return { state, submit, reset };
}
```

```
function PayButton({ amount }: { amount: number }) {
    const { state, submit } = useIdempotentMutation('/api/payments');

    return (
         submit({ amount })}
        >
            {state.status === 'loading' ? 'Processing…' : 'Pay'}

    );
}
```

### cURL / raw HTTP

[](#curl--raw-http)

For CLI tests, Postman collections, or platform docs:

```
KEY=$(uuidgen)

curl -X POST https://api.example.com/payments \
    -H "Authorization: Bearer $TOKEN" \
    -H "Idempotency-Key: $KEY" \
    -H "Content-Type: application/json" \
    -d '{"amount": 1000, "currency": "USD"}'
```

Run the exact same command again — the second response will include `Idempotency-Status: Repeated`.

### Checklist

[](#checklist)

- One key per logical intent (not per click, per retry, or per render).
- Key is UUID v4/v7 by default (or matches your `validation.pattern`).
- Only retry on network errors, `408`, `409`, `425`, `429`, `5xx`.
- Never retry on `400`/`401`/`403`/`422` — those are definitive.
- Exponential backoff with jitter, cap retries at ~4.
- Inspect `Idempotency-Status` header in dev tools when debugging.

---

Artisan commands
----------------

[](#artisan-commands)

```
# Flush everything we know about one key (response, metadata, lock, payload hash)
php artisan idempotency:flush 123e4567-e89b-12d3-a456-426614174000

# Scoped keys need the scope prefix you used when writing
php artisan idempotency:flush 123e4567-e89b-12d3-a456-426614174000 --scope=u42

# Validate production-critical configuration before deploying
php artisan idempotency:doctor
```

The doctor command checks the hash algorithm, TTL ranges, lock TTL consistency, and whether the configured cache store can acquire atomic locks.

You can also reach the same behaviour programmatically through the facade:

```
use DevactionLabs\Idempotency\Facades\Idempotency;

Idempotency::flush('123e4567-e89b-12d3-a456-426614174000', scope: 'u42');
Idempotency::has('123e4567-e89b-12d3-a456-426614174000');
```

---

Testing
-------

[](#testing)

```
composer test        # Pest suite
composer analyse     # PHPStan at level max
composer format      # Laravel Pint
```

The bundled Pest suite covers cache hit/miss, lock contention, payload mismatch, scope isolation, streamed-response skipping, header name override, and alert threshold firing. Run it as a living spec for how the middleware behaves.

---

Deployment &amp; hardening
--------------------------

[](#deployment--hardening)

The middleware is safe by default, but a few production choices change the threat model. This section flags them so you don't learn about them in an incident.

### 1. Isolate the cache store

[](#1-isolate-the-cache-store)

The response cache lives in whatever Laravel cache store you configure. If that store is **shared with other applications** (common with a team Redis), those apps can write keys under `idempotency:*` and your API will serve them.

Three options, pick one:

```
# A) Dedicated store — best
IDEMPOTENCY_CACHE_STORE=idempotency
```

```
// config/cache.php
'stores' => [
    'idempotency' => [
        'driver'     => 'redis',
        'connection' => 'idempotency',  // a distinct Redis DB or instance
    ],
],
```

```
// B) Shared store but prefixed — good enough
'cache' => [
    'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME'), '_').'_cache'),
],
```

```
# C) Do nothing — only safe when the cache store belongs to this app alone

```

A cross-app key collision cannot RCE you — the serializer constructs `JsonResponse`/`Response` explicitly, never `new $class()` — but it **can** serve an attacker-controlled 200 body to your clients. Isolation is the fix.

### 2. Trust your proxies (if using `scope=ip`)

[](#2-trust-your-proxies-if-using-scopeip)

`DefaultScopeResolver` calls `$request->ip()`. Behind a reverse proxy (nginx, ALB, Cloudflare), that returns the proxy's IP unless Laravel knows to trust it.

```
// bootstrap/app.php — Laravel 11+
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*'); // or specific subnets
})
```

Without this, every request from any user looks like the same IP and their keys collide.

### 3. Pair with `RateLimiter` for abuse-resistant endpoints

[](#3-pair-with-ratelimiter-for-abuse-resistant-endpoints)

Idempotency prevents duplicate *processing*. It does **not** prevent key-space flooding — an attacker can still fill your cache with random keys until the TTL saves you. Combine with Laravel's rate limiter for anything public:

```
// routes/api.php
Route::post('/payments', [PaymentController::class, 'store'])
    ->middleware(['auth:api', 'throttle:payments', 'idempotent']);
```

```
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('payments', function (Request $request) {
        return Limit::perMinute(60)
            ->by($request->user()?->id ?: $request->ip())
            ->response(fn () => response()->json(['error' => 'Too many requests'], 429));
    });
}
```

Order matters: `throttle` first keeps abusers out of idempotency storage entirely.

### 4. Don't run `scope=global` in production with auth

[](#4-dont-run-scopeglobal-in-production-with-auth)

`scope=global` collapses the key namespace across all users — user A's `Idempotency-Key` and user B's collide. The middleware logs a warning the first time it sees an authenticated request under this scope, but **do not ship it** unless your API is truly single-tenant and unauthenticated.

```
IDEMPOTENCY_SCOPE=user_route   # default and recommended
```

### 5. Lock driver reality check

[](#5-lock-driver-reality-check)

Auto-merge's atomic locks need a cache store that supports them. Quick rundown:

DriverLocks?Notes`redis`YesBest. Use a dedicated DB.`memcached`YesFine.`database`YesWorks, but contention is worse under load. Use when you already have a DB and no Redis.`dynamodb`YesFine, watch your provisioned capacity.`array`Yes — per processNever across workers. Tests only.`file`NoWill throw at runtime.### 6. Octane / long-running workers

[](#6-octane--long-running-workers)

The middleware holds no cross-request state. The only static state is a one-shot "warned about global scope" flag in `DefaultScopeResolver` — that re-emits correctly after each Octane worker reload. No action needed.

### 7. Do not log payloads in alerts

[](#7-do-not-log-payloads-in-alerts)

The `IdempotencyAlertFired` event ships `context` which already excludes request bodies. If you extend it with your own listener, avoid dumping the full payload — those events go to whatever sink you configured and may contain PII or secrets.

---

FAQ
---

[](#faq)

**Do I need Redis?** No, any cache store with atomic locks works (`redis`, `memcached`, `database`, `dynamodb`). The `array` driver is OK for tests.

**What happens if the client doesn't send a key?** A 400 by default. Use `idempotent:optional` on routes where the key is advisory.

**What if my handler throws?** The lock releases, the processing marker is cleared, nothing is cached, and an `EXCEPTION_THROWN` event fires. The retry starts fresh.

**Does it cache 4xx?** 400–499 are cached by default (with `408/409/425/429` excluded as transient). Adjust via `cacheable_status`.

**Does it cache 5xx?** No. Server errors are never cached — the retry runs fresh.

**What about file uploads?** Included in the hash by default (name + size + mime + xxh128 of contents). Disable via `payload.include_files`.

**ULID support?** `'pattern' => 'ulid'`. Or supply a regex. Or a class implementing `KeyValidator`.

**Octane-safe?** Yes — no request-scoped state is held on the middleware between requests.

---

License
-------

[](#license)

The MIT License (MIT). See [LICENSE.md](LICENSE.md).

Original package by [@infinitypaul](https://github.com/infinitypaul). v2 rewrite maintained at [devaction-labs/Idempotency](https://github.com/devaction-labs/Idempotency).

###  Health Score

43

—

FairBetter than 89% of packages

Maintenance97

Actively maintained with recent releases

Popularity6

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity54

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~12 days

Total

3

Last Release

23d ago

Major Versions

v2.0.1 → v3.0.02026-05-17

PHP version history (2 changes)v2.0.0PHP ^8.3

v3.0.0PHP ^8.5

### Community

Maintainers

![](https://www.gravatar.com/avatar/b118fca24c478a6378ac5ec98bc4124c345fa6e398367665d121ca6f7bfe8a84?d=identicon)[alexnogueirasilva](/maintainers/alexnogueirasilva)

---

Top Contributors

[![alexnogueirasilva](https://avatars.githubusercontent.com/u/29835529?v=4)](https://github.com/alexnogueirasilva "alexnogueirasilva (41 commits)")

---

Tags

httpphpmiddlewareapilaravelidempotency

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/devaction-labs-idempotency/health.svg)

```
[![Health](https://phpackages.com/badges/devaction-labs-idempotency/health.svg)](https://phpackages.com/packages/devaction-labs-idempotency)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.4k51.0M7.4k](/packages/larastan-larastan)[laravel/pulse

Laravel Pulse is a real-time application performance monitoring tool and dashboard for your Laravel application.

1.7k14.1M120](/packages/laravel-pulse)[roots/acorn

Framework for Roots WordPress projects built with Laravel components.

9732.3M121](/packages/roots-acorn)[spatie/laravel-responsecache

Speed up a Laravel application by caching the entire response

2.8k8.7M64](/packages/spatie-laravel-responsecache)[laravel/mcp

Rapidly build MCP servers for your Laravel applications.

76318.2M110](/packages/laravel-mcp)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
