grazulex / laravel-api-idempotency
RFC-compliant idempotency support for Laravel APIs - Prevent duplicate operations, ensure safe retries
Package info
github.com/Grazulex/laravel-api-idempotency
pkg:composer/grazulex/laravel-api-idempotency
Requires
- php: ^8.3
- illuminate/cache: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.22
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.8|^4.0
- pestphp/pest-plugin-laravel: ^3.2|^4.0
- phpstan/phpstan: ^2.0
- rector/rector: ^2.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-18 16:12:45 UTC
README
Complete API idempotency lifecycle management for Laravel - Prevent duplicate operations, ensure safe retries
Tip
What Laravel API Idempotency does for you โ Stop duplicate charges, double orders and repeated side effects when a client retries a request. Add one middleware and your API safely returns the same response for the same Idempotency-Key โ no more defensive code in every controller.
This package is free and maintained on my own time. If it saves you hours, a small contribution helps me keep it going: ๐ GitHub Sponsors ยท โ Buy Me a Coffee ยท PayPal
Features
- RFC Draft Compliant - Follows IETF Idempotency-Key Header Draft
- Multiple Storage Drivers - Cache, Redis, Database, or DynamoDB
- Payload Fingerprinting - SHA256 verification prevents key reuse with different data
- Conflict Detection - Handles concurrent requests with wait/reject strategies
- Scoping - Scope keys to user, tenant, IP, or custom resolver
- Zero Configuration - Works out of the box with sensible defaults
- Artisan Commands - Stats, cleanup, and management tools
- Testing Helpers - Fluent testing API with
IdempotencyFake
Requirements
- PHP 8.3+
- Laravel 12.x or 13.x
Installation
composer require grazulex/laravel-api-idempotency
Publish the configuration:
php artisan vendor:publish --tag="api-idempotency-config"
For database driver, publish migrations:
php artisan vendor:publish --tag="api-idempotency-migrations"
php artisan migrate
Quick Start
Apply the middleware to routes:
// routes/api.php Route::post('/payments', [PaymentController::class, 'store']) ->middleware('idempotent'); Route::post('/orders', [OrderController::class, 'store']) ->middleware('idempotent:required'); // Key required
Client request with idempotency key:
curl -X POST https://api.example.com/payments \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pay_abc123_unique_key" \ -d '{"amount": 9999, "currency": "EUR"}'
First request: Executes normally, response cached Same key again: Returns cached response, no re-execution
Response Headers
HTTP/1.1 201 Created Idempotency-Key: pay_abc123_unique_key X-Idempotent-Replayed: false
On replay:
HTTP/1.1 201 Created Idempotency-Key: pay_abc123_unique_key X-Idempotent-Replayed: true X-Original-Request-Time: 2025-01-15T10:30:00+00:00
Middleware Options
// Custom TTL (seconds) ->middleware('idempotent:ttl=172800') // 48 hours // Require key (returns 400 if missing) ->middleware('idempotent:required') // Custom scope ->middleware('idempotent:scope=team') // Combined options ->middleware('idempotent:required,ttl=3600')
PHP Attributes
use Grazulex\ApiIdempotency\Attributes\Idempotent; use Grazulex\ApiIdempotency\Attributes\IdempotentExcept; #[Idempotent] class PaymentController extends Controller { public function store(Request $request) { /* ... */ } #[IdempotentExcept] public function index() { /* ... */ } // Excluded }
Programmatic Usage
use Grazulex\ApiIdempotency\Facades\Idempotency; // Check if already processed if ($cached = Idempotency::get($key)) { return $cached->toResponse(); } // Store manually Idempotency::store($key, response()->json($data, 201)); // Skip caching (e.g., for validation errors) Idempotency::skip();
Key Generation
use Grazulex\ApiIdempotency\Support\IdempotencyKey; // Generate unique key $key = IdempotencyKey::generate(); // "idem_01HQ3K4M..." $key = IdempotencyKey::generate('pay'); // "pay_01HQ3K4M..." // Deterministic key from data $key = IdempotencyKey::fromData([ 'user_id' => 123, 'action' => 'create_payment', ]);
Artisan Commands
# View statistics php artisan idempotency:stats # Cleanup expired keys php artisan idempotency:cleanup # Remove specific key php artisan idempotency:forget pay_abc123 # List recent keys php artisan idempotency:list --limit=20
Events
use Grazulex\ApiIdempotency\Events\IdempotentRequestProcessed; use Grazulex\ApiIdempotency\Events\IdempotentRequestReplayed; use Grazulex\ApiIdempotency\Events\IdempotentConflictDetected; use Grazulex\ApiIdempotency\Events\IdempotentPayloadMismatch;
Configuration
// config/api-idempotency.php return [ 'enabled' => env('API_IDEMPOTENCY_ENABLED', true), 'header' => env('API_IDEMPOTENCY_HEADER', 'Idempotency-Key'), 'key' => [ 'required' => false, 'min_length' => 10, 'max_length' => 255, 'pattern' => '/^[a-zA-Z0-9_-]+$/', ], // Drivers: cache, redis, database, dynamodb 'driver' => env('API_IDEMPOTENCY_DRIVER', 'cache'), 'drivers' => [ 'cache' => [ 'store' => 'default', 'prefix' => 'idempotency:', ], 'redis' => [ 'connection' => 'default', 'prefix' => 'idempotency:', ], 'database' => [ 'connection' => null, 'table' => 'idempotency_keys', ], 'dynamodb' => [ 'table' => 'idempotency_keys', 'region' => 'eu-west-1', ], ], 'ttl' => env('API_IDEMPOTENCY_TTL', 86400), // 24 hours 'conflict' => [ 'strategy' => 'wait', // or 'reject' 'wait_timeout' => 10, 'retry_interval' => 100, ], 'fingerprint' => [ 'enabled' => true, 'algorithm' => 'sha256', 'include_path' => true, 'include_method' => true, 'include_body' => true, 'exclude_fields' => ['timestamp', 'nonce'], ], 'scope' => [ 'enabled' => true, 'resolver' => 'user', // user, tenant, ip, or callable ], 'logging' => [ 'enabled' => true, 'log_hits' => true, 'log_conflicts' => true, ], ];
Testing
use Grazulex\ApiIdempotency\Facades\Idempotency; public function test_idempotency(): void { Idempotency::fake(); // Your test code... Idempotency::assertStored('expected_key'); Idempotency::assertReplayed('expected_key'); Idempotency::assertStoredCount(5); }
Integration testing:
public function test_payment_is_idempotent(): void { $key = 'test_key_' . uniqid(); $payload = ['amount' => 9999]; $response1 = $this->postJson('/api/payments', $payload, [ 'Idempotency-Key' => $key, ]); $response1->assertStatus(201) ->assertHeader('X-Idempotent-Replayed', 'false'); $response2 = $this->postJson('/api/payments', $payload, [ 'Idempotency-Key' => $key, ]); $response2->assertStatus(201) ->assertHeader('X-Idempotent-Replayed', 'true') ->assertJson($response1->json()); }
Quality Tools
# Run tests composer test # Code style (Laravel Pint) composer pint # Static analysis (PHPStan Level 5) composer analyse
Error Responses
| Code | Status | Description |
|---|---|---|
IDEMPOTENCY_KEY_MISSING |
400 | Key required but not provided |
IDEMPOTENCY_KEY_INVALID |
400 | Key format invalid |
IDEMPOTENCY_PAYLOAD_MISMATCH |
422 | Same key, different payload |
IDEMPOTENCY_CONFLICT |
409 | Request in progress with same key |
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Security
If you discover any security-related issues, please email security@grazulex.dev instead of using the issue tracker.
Credits
Support This Package
Laravel API Idempotency is free, open source and maintained on my own time. If it saves you hours, here is how you can give back:
- โญ Star the repository โ it helps other developers find it
- ๐ฆ Share it with your team and network
- ๐ Sponsor on GitHub, buy me a coffee or donate via PayPal โ every contribution funds maintenance, new features and Laravel upgrades
License
The MIT License (MIT). Please see License File for more information.
See Also
- laravel-apiroute - API versioning lifecycle management
- laravel-api-kit - API-only Laravel starter kit