devaction-labs/idempotency

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

Maintainers

Package info

github.com/devaction-labs/Idempotency

pkg:composer/devaction-labs/idempotency

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.1 2026-04-22 16:54 UTC

This package is auto-updated.

Last update: 2026-04-22 16:55:44 UTC


README

Idempotency for Laravel

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

Latest Version Tests PHPStan Code Style PHP License

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

Requirements

Dependency Version
PHP 8.3, 8.4, 8.5
Laravel 11.x / 12.x / 13.x (Laravel 10 reached security EOL in Aug 2025)
Cache store any driver with atomic locks — redis, memcached, database, dynamodb

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

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

┌───────────────┐     ┌──────────────────┐     ┌──────────────┐
│  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 and Telemetry.

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');
Parameter Effect
optional Missing key is allowed — route runs without idempotency
ttl=<seconds> Override the default TTL for this route
scope=<name> Override the scope strategy for this route

Configuration reference

The full file lives at 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<ScopeResolver>

    '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 is the invisible safety net that stops user A's key from ever matching user B's cached response.

Scope Key partition When to pick it
global none Internal / trusted clients only
user authenticated user id User-level idempotence across their own routes
route route name or URI Public endpoints that shouldn't cross-pollinate
ip client IP Anonymous POSTs from the same caller
user_route (default) user id + route Most apps want this

Need something custom (tenant id, API key, device id)? Implement ScopeResolver — see Custom resolvers.

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,
],

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):

Case Fires 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 key

Alerts have a built-in cooldown (alerts.cooldown, defaults to 1h) so a bad client can't flood your logs.

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

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

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

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

type IdempotentOptions<T> = {
    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<T>({
    url,
    body,
    key = newIdempotencyKey(),
    signal,
    maxAttempts = 4,
}: IdempotentOptions<T>): Promise<T> {
    let lastError: unknown;

    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            const res = await fetch(url, {
                method: 'POST',
                signal,
                headers: {
                    'Content-Type': 'application/json',
                    'Accept': 'application/json',
                    'Idempotency-Key': key,
                },
                body: JSON.stringify(body),
            });

            if (res.ok) return res.json() as Promise<T>;

            if (!RETRY_ON.has(res.status)) {
                // 400, 401, 403, 422, etc. are definitive — don't retry, don't regenerate the key
                throw new HttpError(res.status, await res.text());
            }

            lastError = new HttpError(res.status, await res.text());
        } catch (err) {
            if (signal?.aborted) throw err;
            lastError = err;
        }

        // Exponential backoff with full jitter — caps at 8s
        const delay = Math.min(8_000, 2 ** attempt * 250) * Math.random();
        await new Promise(resolve => 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

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

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<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: T }
    | { status: 'error'; error: unknown };

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

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

        try {
            const data = await idempotentPost<TResult>({ 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<{ amount: number }, Payment>('/api/payments');

    return (
        <button
            disabled={state.status === 'loading'}
            onClick={() => submit({ amount })}
        >
            {state.status === 'loading' ? 'Processing…' : 'Pay'}
        </button>
    );
}

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

  • 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

# 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

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

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 & 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

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)

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

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

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

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

Driver Locks? Notes
redis Yes Best. Use a dedicated DB.
memcached Yes Fine.
database Yes Works, but contention is worse under load. Use when you already have a DB and no Redis.
dynamodb Yes Fine, watch your provisioned capacity.
array Yes — per process Never across workers. Tests only.
file No Will throw at runtime.

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

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

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

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

Original package by @infinitypaul. v2 rewrite maintained at devaction-labs/Idempotency.