slash-dw / idempotency-kit
HTTP-layer idempotency middleware for Laravel with atomic locking, path validation, payload fingerprinting, and alert system
Requires
- php: ^8.5
- illuminate/cache: ^13.0
- illuminate/contracts: ^13.0
- illuminate/http: ^13.0
- illuminate/support: ^13.0
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- phpunit/phpunit: ^12.0
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
- The client sends a mutating request (POST, PUT, PATCH) with an
Idempotency-Keyheader containing a UUID v4. - If the same key + path + payload was already processed, the cached response is returned with
Idempotency-Replayed: true. - If the same key arrives while the first request is still processing, the second request receives
HTTP 409 Conflictwith aRetry-After: 1header. The client waits one second and retries; it then receives the cached response. - 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:
408Request Timeout429Too Many Requests503Service Unavailable504Gateway 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
/paymentscannot 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