kasevvlad / idempotency
Idempotency-Key header support for Laravel and Symfony: safe retries for POST/PATCH requests
Requires
- php: ^8.2
- symfony/http-foundation: ^6.4 || ^7.0
Requires (Dev)
- orchestra/testbench: ^9.0 || ^10.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0
- symfony/cache: ^6.4 || ^7.0
- symfony/config: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
- symfony/lock: ^6.4 || ^7.0
- symfony/security-core: ^6.4 || ^7.0
Suggests
- laravel/framework: Use the idempotent middleware in a Laravel app
- symfony/framework-bundle: Use IdempotencyBundle in a Symfony app
- symfony/lock: Required by the Symfony store
Provides
None
Conflicts
None
Replaces
None
README
A client sends POST /payments, the network drops before the response arrives, the client retries — and the customer is charged twice.
This package makes such retries safe. The client attaches an Idempotency-Key header; the first request is executed and its response is stored, every repeat with the same key gets the stored response back without touching your controller. It follows the IETF draft The Idempotency-Key HTTP Header Field and the way Stripe's API behaves.
POST /payments HTTP/1.1 Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324 Content-Type: application/json {"amount": 1000}
What happens to a request
| Situation | Response |
|---|---|
| First request with a key | Controller runs, response is stored |
| Same key, same payload, first request finished | Stored response, plus Idempotent-Replayed: true |
| Same key while the first request is still running | 409 Conflict with Retry-After: 1 |
| Same key, different payload (body, path or query) | 422 Unprocessable Content |
| No key, route does not require it | Passes through untouched |
| No key, route requires it | 400 Bad Request |
| Key longer than 255 characters | 400 Bad Request |
Errors are returned as RFC 9457 application/problem+json.
What is not stored, so the client can retry with the same key:
5xxresponses and uncaught exceptions408,409,425,429— the request was not really processed- streamed and file responses
Set-Cookie is never replayed. Keys are scoped per authenticated user (or per IP for guests), so two clients can't collide on the same key or read each other's responses.
Only POST and PATCH are covered by default: GET, PUT and DELETE are idempotent by definition.
Installation
composer require kasevvlad/idempotency
Requires PHP 8.2+ and Laravel 11+ or Symfony 6.4+.
Laravel
The service provider is discovered automatically. Add the middleware to routes:
Route::post('/orders', [OrderController::class, 'store']) ->middleware('idempotent'); // reject requests without the header Route::post('/payments', [PaymentController::class, 'store']) ->middleware('idempotent:required'); // keep responses for one hour instead of 24 Route::post('/refunds', [RefundController::class, 'store']) ->middleware('idempotent:required,3600');
Put it after auth so keys are scoped by user, not by IP.
Responses and locks live in the cache, so the store must support atomic locks: redis, database, memcached, file or array. Redis is the right choice when you run several app servers.
To change the defaults:
php artisan vendor:publish --tag=idempotency-config
// config/idempotency.php return [ 'store' => env('IDEMPOTENCY_STORE'), // null means the default cache store 'header' => 'Idempotency-Key', 'methods' => ['POST', 'PATCH'], 'ttl' => 86400, // how long responses are kept 'lock_seconds' => 30, // how long a key is locked while the first request runs 'required' => false, ];
Symfony
Register the bundle:
// config/bundles.php return [ // ... Kasevvlad\Idempotency\Symfony\IdempotencyBundle::class => ['all' => true], ];
Mark controllers with the attribute:
use Kasevvlad\Idempotency\Symfony\Attribute\Idempotent; final class PaymentController { #[Route('/payments', methods: ['POST'])] #[Idempotent(required: true, ttl: 3600)] public function create(Request $request): JsonResponse { // ... } }
The attribute also works on the controller class. The bundle uses cache.app and lock.factory by default (composer require symfony/lock if you don't have it yet); for several servers point both at Redis:
# config/packages/idempotency.yaml idempotency: cache_pool: cache.app lock_factory: lock.factory header: Idempotency-Key methods: [POST, PATCH] ttl: 86400 lock_seconds: 30 required: false
Other frameworks
The core only depends on symfony/http-foundation, so it fits anything built on it. Implement Kasevvlad\Idempotency\Store\Store for your storage and call the three methods around your handler:
$idempotency = new Idempotency($store, new Options(required: true)); if ($early = $idempotency->begin($request, scope: 'user:'.$userId)) { return $early; // replay or error } try { $response = $handler($request); } catch (Throwable $e) { $idempotency->abort($request); // free the key for a retry throw $e; } $idempotency->complete($request, $response);
How it works
- The storage key is
sha256(scope + Idempotency-Key), the fingerprint issha256of method, path, query and body. - If a response is stored under the key, it is replayed — or
422if the fingerprint differs. - Otherwise the key is locked. A request that can't get the lock receives
409. - The store is checked once more under the lock: the first request may have finished in between.
- After the controller, the response is stored for
ttlseconds and the lock is released. On an exception the lock is just released.
The lock has its own timeout (lock_seconds), so a crashed worker doesn't block the key forever. Keep it longer than your slowest request.
Testing
composer test
composer analyse
License
MIT