Search by

slash-dw / idempotency-kit

slash-dw

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

Package info

github.com/slash-dw/idempotency-kit

pkg:composer/slash-dw/idempotency-kit

Statistics

Installs: 44

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

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

This package is auto-updated.

Last update: 2026-09-04 04:50:06 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