Wendell Adriel released Laravel Idempotency, a package that adds HTTP idempotency to write-oriented Laravel routes. When a POST, PUT, or PATCH request is retried with the same idempotency key and the same payload, the package replays the original cached response instead of running the route handler again — a common requirement for payment endpoints, order creation, and any API where clients may retry on network failure.
Applying the Middleware
The package provides two ways to attach idempotency to a route. The first is a standard route middleware:
use WendellAdriel\Idempotency\Http\Middleware\Idempotent; Route::post('/orders', StoreOrderController::class)->middleware(Idempotent::class);
The middleware expects an Idempotency-Key header. When the same key is sent again with identical request data, the original response is returned with an Idempotency-Replayed: true header added.
Per-route configuration is available via Idempotent::using():
Route::post('/payments', ChargePaymentController::class)->middleware( Idempotent::using( ttl: 600, lockTimeout: 30, required: false, scope: \WendellAdriel\Idempotency\Enums\IdempotencyScope::Ip, header: 'X-Idempotency-Key', ));
The second option is a PHP attribute, which works at the class or method level and accepts the same options:
use WendellAdriel\Idempotency\Attributes\Idempotent;use WendellAdriel\Idempotency\Enums\IdempotencyScope; #[Idempotent]class PaymentController{ #[Idempotent(ttl: 600, lockTimeout: 30, scope: IdempotencyScope::Ip)] public function store() { // ... }}
Since the attribute extends Laravel's built-in controller middleware attribute, only and except work as expected.
Key Scoping
Idempotency keys can be scoped three ways, configured globally in config/idempotency.php or overridden per route:
| Scope | Behavior |
|---|---|
user |
Keys are segmented by authenticated user; guest requests fall back to client IP |
ip |
Keys are segmented by client IP address |
global |
The same key applies across all users and IP addresses |
Conflict Detection
The package handles two conflict scenarios. If a request arrives with the same key but a different payload, it returns 422 Unprocessable Entity. If a second matching request arrives while the first is still being processed — a true in-flight duplicate — it returns 409 Conflict with a Retry-After: 1 header. Both behaviors work through Laravel's cache atomic locks, so a cache driver with lock support (Redis, Memcached) is required.
Artisan Commands
Two commands let you inspect and clear cached entries without touching the cache directly.
idempotency:list renders a table of active entries with scope, identifier, key, route, status code, and expiry:
php artisan idempotency:list --scope=user --id=5
idempotency:forget removes entries by scope, identifier, or key. Destructive calls prompt for confirmation unless --force is passed:
# remove all entries for a specific userphp artisan idempotency:forget --scope=user --id=5 --force # remove every entry for a given client-provided keyphp artisan idempotency:forget --key=checkout-1 --force
You can find Laravel Idempotency on GitHub.