Complete API idempotency lifecycle management for Laravel - Prevent duplicate operations, ensure safe retries
- 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
- PHP 8.3+
- Laravel 11.x or 12.x
composer require grazulex/laravel-api-idempotencyPublish 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 migrateApply 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 requiredClient 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
HTTP/1.1 201 Created
Idempotency-Key: pay_abc123_unique_key
X-Idempotent-Replayed: falseOn 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// 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')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
}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();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',
]);# 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=20use Grazulex\ApiIdempotency\Events\IdempotentRequestProcessed;
use Grazulex\ApiIdempotency\Events\IdempotentRequestReplayed;
use Grazulex\ApiIdempotency\Events\IdempotentConflictDetected;
use Grazulex\ApiIdempotency\Events\IdempotentPayloadMismatch;// 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,
],
];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());
}# Run tests
composer test
# Code style (Laravel Pint)
composer pint
# Static analysis (PHPStan Level 5)
composer analyse| 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 |
Please see CHANGELOG for more information on what has changed recently.
Please see CONTRIBUTING for details.
If you discover any security-related issues, please email security@grazulex.dev instead of using the issue tracker.
The MIT License (MIT). Please see License File for more information.
- laravel-apiroute - API versioning lifecycle management
- laravel-api-kit - API-only Laravel starter kit