Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Idempotency Middleware Laravel Package

bluebeetle/idempotency-middleware

Laravel middleware for idempotent API endpoints that detects duplicate requests by idempotency key and replays the original response. Makes retried POST, payment, and webhook calls safe from double processing with minimal setup.

View on GitHub
Deep Wiki
Context7

Getting Started

bluebeetle/idempotency-middleware is a small, single-purpose package: it adds HTTP middleware that reads an Idempotency-Key header, remembers the first response for that key, and replays it on retries instead of letting the request execute twice. It is aimed squarely at the "client retried the payment and we charged them twice" class of bug, so treat it as infrastructure for mutating API endpoints (POST, PUT, PATCH, sometimes DELETE), not as a general-purpose cache.

Install and wire it up:

composer require bluebeetle/idempotency-middleware

The service provider should be auto-discovered. Publish any config the package ships (check the README for the exact --tag name — with a package this small it may not ship one at all):

php artisan vendor:publish --provider="Bluebeetle\IdempotencyMiddleware\IdempotencyServiceProvider"

Register the middleware alias in bootstrap/app.php (Laravel 11+) or app/Http/Kernel.php (Laravel 10 and below). Use whatever FQCN you find under the package's src/ — confirm it before copying anything from a blog post:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'idempotent' => \Bluebeetle\IdempotencyMiddleware\Http\Middleware\IdempotencyMiddleware::class,
    ]);
})

Where to look first, in order:

  1. The package's README.md — for the header name, config keys, and storage driver options.
  2. src/Http/Middleware/*.php — the handle() method is the whole story: where it reads the key, where it stores the record, and what it returns on a hit. With ~3 stars and no dependents, the source is the documentation.
  3. Any config file or Store/Repository class — this tells you whether it uses Cache, the database, or both, and what the TTL default is.

Your first use case: pick one endpoint that creates a resource with a side effect — a payment intent, an order, a subscription. Attach the middleware, hit it twice with curl using the same Idempotency-Key, and confirm you get the same response body and exactly one row in the database.

KEY=$(uuidgen)
curl -X POST https://api.test/payments \
  -H "Idempotency-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount": 4200, "currency": "EUR"}'
# run the exact same command again — expect the identical response, no second charge

If that works, you understand the package. Everything else is scoping and hardening.

Implementation Patterns

Apply at the route/group level, never globally. Middleware on GET routes caches reads you didn't ask to cache, and applying it to the whole web group means session/CSRF responses get stored. Attach it per-route or to an API sub-group:

Route::middleware(['auth:sanctum', 'idempotent'])->group(function () {
    Route::post('/payments', StorePaymentController::class);
    Route::post('/orders', StoreOrderController::class);
});

Order it after authentication. Idempotency keys should be scoped per user or per tenant; if the middleware runs before auth, two different users sharing a key (deliberately or by collision) can read each other's cached responses. Put it after auth, before your throttle if you want retries to bypass rate limits, or after throttle if you'd rather cap retry storms.

Let the client generate the key, and validate its shape. A UUID v4 per logical operation is the standard. Reject requests with no key (or fall back to a hash of the body, with caveats below) and cap the length so the key can't be used as a cache-busting payload:

// a small FormRequest or a route-level guard
'Idempotency-Key' => ['required', 'uuid'],

Pair the idempotency key with a request fingerprint. The classic failure mode isn't a duplicate request — it's a client reusing the same key with a different payload (a retry written against a mutated cart). Store method + path + sha256(body) alongside the response and return 409 Conflict (or 422) when a known key arrives with a mismatched fingerprint. If the package doesn't already do this, add it in a small decorator around the middleware or in the controller.

Use Redis for the store in production. Concurrency is the whole problem: two requests can arrive in the same millisecond, both miss the cache, and both execute. Only an atomic primitive saves you — a Cache::lock() around the execution, or a Redis SET NX on the key. Check the middleware source for a lock; if there isn't one, wrap the handler yourself:

return Cache::lock("idem:{$key}", 30)->block(10, fn () => $next($request));

A database-backed store gives you an audit trail (useful for finance) but a SELECT-then-INSERT is not atomic without a unique index on the key — make sure one exists.

Compose with database uniqueness, not instead of it. Idempotency middleware protects the HTTP layer; it does nothing for queued jobs, scheduled commands, or a webhook delivered while your cache is cold. Keep the unique constraint on payments.reference or an idempotency_keys table and treat the middleware as the fast path.

Test it as a first-class behaviour. The middleware's contract is easy to assert, and you should assert it on every mutating endpoint:

$key = (string) Str::uuid();
$payload = ['amount' => 4200, 'currency' => 'EUR'];

$this->withHeader('Idempotency-Key', $key)->postJson('/payments', $payload)->assertCreated();
$this->withHeader('Idempotency-Key', $key)->postJson('/payments', $payload)->assertCreated();

$this->assertDatabaseCount('payments', 1);

Add a second test for the mismatch case (same key, different body → 409) and a third for a concurrent pair if you can simulate it.

The endpoints that earn their keep: payment/charge creation, refunds, order submission, subscription starts, outbound webhook receivers (Stripe, Shopify, and friends retry aggressively and expect exactly this), and any endpoint that dispatches a queued job with external side effects.

Gotchas and Tips

This package is pre-traction — read it before trusting it. Three stars, zero dependents, and a release date in the future relative to most ecosystems means no battle-testing, no Stack Overflow corpus, and no guarantee of semver discipline. Pin the version in composer.json, skim the middleware once, and be prepared to fork. Budget an hour for that review; it's cheaper than a duplicate charge.

Cache flushes silently break idempotency. php artisan cache:clear or a Redis eviction wipes the record, and a retry then executes fresh. That's acceptable if the window matters less than the TTL, but know your exposure: align the TTL with the client's retry window (24 hours is the common industry figure, and Stripe's is 24h), and use a store that doesn't evict under memory pressure for money-touching endpoints.

In-flight requests are the hard case. If the first request is still executing when the retry arrives, a naive implementation sees a cache miss and runs it again. The correct behaviour is to return 409 ("a request with this key is in progress") or block briefly. Verify which the package does; if it's neither, add the lock above.

Decide explicitly what gets cached. Should a 500 be replayed? Almost certainly not — the client should be able to retry a transient failure with the same key. A 4xx validation error is arguable; caching it is defensible but means a corrected payload under the same key is rejected. Read the middleware's status-code filter and make the policy a config value, not an accident.

Response bodies have limits. File downloads, streams, and multi-megabyte JSON don't belong in a cache entry. Cap the stored body size and skip caching for non-JSON or StreamedResponse types.

Body hashing is fussy. JSON key ordering, whitespace, and multipart boundaries all change the hash for semantically identical requests, producing spurious 409s. Hash the parsed payload, or accept the raw body and document that clients must send byte-identical retries (which well-behaved SDKs do). File uploads are best excluded from fingerprinting entirely.

Don't double up with throttle. A client retrying six times with the same key still consumes six rate-limit hits unless idempotency runs first. Pick an order deliberately — idempotency before throttle gives you "retries are free," which is usually what you want.

Debugging checklist when a retry misbehaves: log the resolved key with the response status; inspect the raw cache/store entry (Cache::get($key)) to see whether it exists and what TTL remains; confirm the middleware is actually in the resolved stack (php artisan route:list -v); and check that a proxy or API gateway isn't stripping the Idempotency-Key header before Laravel sees it.

Extension points to plan for, whether or not the package exposes them today: a custom key resolver (header vs. body field vs. Idempotency-Key plus tenant prefix), a custom store implementing whatever interface the package defines, and events around hit/miss so you can emit metrics — a rising hit rate is exactly the signal you want when a client's retry logic goes haywire.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console