slash-dw/idempotency-kit

HTTP-layer idempotency middleware for Laravel with atomic locking, path validation, payload fingerprinting, and alert system

Maintainers

Package info

github.com/slash-dw/idempotency-kit

pkg:composer/slash-dw/idempotency-kit

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.0.2 2026-05-04 04:10 UTC

This package is auto-updated.

Last update: 2026-05-04 04:10:10 UTC


README

HTTP-layer idempotency middleware for Laravel. Prevents duplicate processing of mutating requests by caching responses keyed to client-provided idempotency keys, with atomic locking, path validation, payload fingerprinting, and an alert system.

Requirements

  • PHP ^8.5
  • Laravel ^13.0
  • Cache store with atomic lock support (Redis recommended for production; Memcached, database and file drivers are also supported)

Installation

composer require slash-dw/idempotency-kit

Publish the configuration file:

php artisan vendor:publish --tag=idempotency-kit-config

The middleware is auto-registered with the alias idempotent.

How It Works

  1. The client sends a mutating request (POST, PUT, PATCH) with an Idempotency-Key header containing a UUID v4.
  2. If the same key + path + payload was already processed, the cached response is returned with Idempotency-Replayed: true.
  3. If the same key arrives while the first request is still processing, the second request receives HTTP 409 Conflict with a Retry-After: 1 header. The client waits one second and retries; it then receives the cached response.
  4. If the same key is sent with a different payload or to a different route, the response is HTTP 422 Unprocessable Entity. This protects against fraud and accidental misuse.

Basic Usage

Route Middleware

// routes/api.php
use SlashDw\IdempotencyKit\Http\Middleware\IdempotentMiddleware;

Route::post('/subscriptions', SubscriptionController::class)
    ->middleware('idempotent');

// With custom TTL (7 days for financial operations):
Route::post('/payments', PaymentController::class)
    ->middleware('idempotent:604800');

Fluent Configuration Helper

use SlashDw\IdempotencyKit\Http\Middleware\IdempotentMiddleware;

Route::post('/orders', OrderController::class)
    ->middleware(IdempotentMiddleware::using(ttl: 3600, scope: 'ip'));

Per-Route Override: required and enabled

The middleware accepts two boolean overrides that complement the global config and let individual routes deviate without changing application-wide defaults.

required: true — force the Idempotency-Key header to be mandatory on this route even when the global required config is false. Use this for critical mutating endpoints where the client absolutely must send a key.

// Reject /register requests that omit the Idempotency-Key header (HTTP 400)
Route::post('/register', RegisteredUserController::class)
    ->middleware(IdempotentMiddleware::using(required: true));

enabled: false — opt this route out of idempotency entirely even when the global enabled config is true. Use this for naturally idempotent endpoints (login, logout, heartbeat) where the protection adds no value.

// Login is naturally idempotent — skip the middleware for this route
Route::post('/login', AuthenticatedSessionController::class)
    ->middleware(IdempotentMiddleware::using(enabled: false));

Combine overrides when needed:

// 7-day TTL + mandatory header for payment operations
Route::post('/payments', PaymentController::class)
    ->middleware(IdempotentMiddleware::using(ttl: 604800, required: true));

The same required and enabled parameters are also available on the #[Idempotent] PHP attribute as metadata.

Global Middleware (All Enforced Methods)

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->api(append: [
        \SlashDw\IdempotencyKit\Http\Middleware\IdempotentMiddleware::class,
    ]);
})

PHP Attribute Metadata

The #[Idempotent] attribute documents intent and per-handler overrides for tooling and documentation generators. Middleware itself still has to be wired up via routes.

use SlashDw\IdempotencyKit\Http\Attributes\Idempotent;

#[Idempotent(ttl: 86400)]
class PaymentController extends Controller
{
    public function store(Request $request): JsonResponse { /* ... */ }
}

Client Usage

Generate a UUID v4 key per logical operation. The same key must be reused on every retry.

curl -X POST https://api.example.com/subscriptions \
  -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
  -H "Content-Type: application/json" \
  -d '{"plan_id": "pro-monthly"}'

Response Scenarios

Scenario Status Header
First request 200/201 Idempotency-Replayed: false
Retry, same key + body + path original status Idempotency-Replayed: true
Concurrent in-flight duplicate 409 Conflict Retry-After: 1
Same key, different body 422 Unprocessable Entity
Same key, different route 422 Unprocessable Entity
Missing key on enforced method 400 Bad Request
Invalid key format 400 Bad Request

Scope Configuration

Scoping isolates keys between callers to prevent cross-user replay.

// config/idempotency_kit.php

'scope' => 'user',   // authenticated user ID (fallback: IP)
'scope' => 'ip',     // requester's IP address
'scope' => 'global', // no isolation (use only when truly user-agnostic)

// Custom resolver (multi-tenant: scope by company):
'resolver' => App\Idempotency\CompanyScopeResolver::class,

Custom Scope Resolver

use SlashDw\IdempotencyKit\Contracts\ScopeResolverContract;
use Illuminate\Http\Request;

final class CompanyScopeResolver implements ScopeResolverContract
{
    public function resolve(Request $request): string
    {
        return (string) (auth()->user()?->company_id ?? $request->ip());
    }
}

Alert System

When the same idempotency key is replayed threshold times (default 5), the package dispatches an IdempotencyKeyAbused event. Subscribe a listener to react.

// app/Providers/EventServiceProvider.php
use SlashDw\IdempotencyKit\Events\IdempotencyKeyAbused;
use App\Listeners\NotifyIdempotencyAbuse;

protected $listen = [
    IdempotencyKeyAbused::class => [NotifyIdempotencyAbuse::class],
];
// app/Listeners/NotifyIdempotencyAbuse.php
use SlashDw\IdempotencyKit\Events\IdempotencyKeyAbused;

final class NotifyIdempotencyAbuse
{
    public function handle(IdempotencyKeyAbused $event): void
    {
        logger()->warning('Idempotency key replayed excessively', [
            'key'   => $event->idempotencyKey,
            'scope' => $event->scope,
            'hits'  => $event->hitCount,
            'route' => $event->route,
        ]);
    }
}

The event re-fires every time the hit count is a positive multiple of the threshold (5, 10, 15…).

Transient Errors

Responses with these HTTP status codes are not cached, so clients can safely retry once the transient condition resolves:

  • 408 Request Timeout
  • 429 Too Many Requests
  • 503 Service Unavailable
  • 504 Gateway Timeout

All other responses (2xx success, permanent 4xx errors) are cached. Configure the list in config/idempotency_kit.php → transient_error_codes.

Security Notes

  • Use UUID v4 keys. Sequential or timestamp-based keys are predictable and vulnerable to enumeration. UUID v4 provides 122 bits of entropy. The package validates the format by default.
  • Scope is mandatory. Keys are always namespaced (user / IP / account) so an attacker who learns one user's key cannot replay another user's operation.
  • Payload fingerprinting (SHA-256). The request body is hashed and verified on retry. Reusing the same key with different parameters (e.g., a different payment amount) is rejected with HTTP 422.
  • Path validation. A key generated for /payments cannot be replayed against /refunds.
  • Constant-time comparison. Internal key checks use hash_equals() to prevent timing-based information leakage.
  • Transient error caching is disabled. Failed-server responses do not poison the cache.

Configuration Reference

See config/idempotency_kit.php for complete inline documentation.

Key Default Purpose
enabled true Global on/off switch
header Idempotency-Key Request header (IETF RFC)
methods [POST, PUT, PATCH, DELETE] Enforced HTTP methods
cache_store null Laravel cache driver name
ttl 86400 (24 h) Response cache duration in seconds
lock_timeout 10 Atomic lock max hold (seconds)
scope user Built-in scope strategy
resolver null Custom ScopeResolverContract class
duplicate_behaviour replay replay or exception on duplicate
required true Reject requests without the header
key_validation.enabled true Validate key format
key_validation.pattern UUID v4 regex PCRE pattern for keys
key_validation.max_length 255 Maximum key length
transient_error_codes [408, 429, 503, 504] Status codes not cached
alert.enabled true Dispatch event on abuse
alert.threshold 5 Replay count before event fires

Development

composer install
composer run-script test      # PHPUnit
composer run-script lint      # Pint --test
composer run-script format    # Pint apply
composer run-script analyse   # PHPStan level 8
composer run-script ci        # All checks together

License

MIT