inani/laravel-same-request
A lightweight Laravel package for detecting duplicate or repeated requests. It fingerprints incoming requests so you can identify identical submissions and handle them safely, helping prevent double form submissions, duplicate writes, and race conditions with minimal setup.
Install and confirm discovery
composer require inani/laravel-same-request:^1.3
php artisan about # confirm the service provider is listed
php artisan vendor:publish --tag=same-request-config # if the package ships a config
Pin ^1.3 rather than a bare require: the 1.3.0 release is explicitly "Laravel 10 Support", which means earlier tags almost certainly constrained illuminate/support below 10. If you were already running this on Laravel 10, you were likely on a --ignore-platform-reqs install or a fork — upgrade and re-run your tests before trusting the existing behavior.
This is a small (10★) package with a one-line release note, so the first thing to do after install is read the actual source: the middleware class, the config file, and the composer.json extra.laravel block. That tells you more in five minutes than any blog post — the README is thin and the API surface is intentionally small.
Register the middleware
bootstrap/app.php under ->withMiddleware(fn (Middleware $m) => $m->alias([...])), or attach the class directly to routes.app/Http/Kernel.php, $routeMiddleware (or $middlewareAliases).First use case
The canonical fit is a double-submit guard on a non-idempotent POST. Put the middleware on a form-submission route:
Route::post('/orders', StoreOrderController::class)
->middleware(['auth', 'same-request']);
A user double-clicking "Place order", a browser retry after a timeout, or a mobile client re-sending the same payload will hit the same fingerprint within the TTL window; the middleware is what turns that into a single side effect instead of two rows in orders.
Start with one route, watch the cache keys it writes, then widen the scope.
Fingerprint-scoped middleware on write routes. Attach it to POST/PUT/PATCH/DELETE route groups (API or web), not to GET. The value is entirely in protecting state-changing endpoints. Leave reads on your existing throttle middleware.
Scoped by authenticated user. If the package's fingerprint is request-content only, two different users posting the same body (e.g. a common search term or {status: "active"}) could collide. Wrap the route group with auth before the same-request middleware, and verify the implementation includes the user/session ID in the key — otherwise extend the key yourself via a custom closure or by overriding the class.
Idempotency-key passthrough. For API clients, pair it with an Idempotency-Key header: the client sends a UUID on retry, and you key on that instead of the body hash. This is more robust than payload hashing, because a retried request often differs slightly (timestamps, nonce fields). Even if the package doesn't support a custom key natively, its detection logic is easy to wrap in your own decorator that reads the header first and falls back to the package behavior.
Layered with throttle and WithoutMiddleware. Order matters. A reasonable stack is:
Route::middleware(['auth', 'throttle:api', 'same-request'])->group(...);
throttle sheds abusive clients cheaply; same-request catches the honest double-click that throttle would happily let through. Do not rely on throttle alone for this — its window and counting semantics are wrong for the job.
Cache store selection. Point it at a shared store (Redis or database) if you run more than one app server. The file and array drivers are per-process and give you exactly zero protection across a load-balanced pair. Verify which store the config points to before you trust it in production.
Responding to a duplicate. Decide the contract once and document it:
409 Conflict or 429 for API clients that should back off.302 back to the form with a flash message for web forms.Pick one, and make sure App\Exceptions\Handler / bootstrap/app.php doesn't swallow the exception into a generic 500.
Logging for observability. On a duplicate hit, log method, path, user ID, and — for debugging — the cache key. Without the key you will be unable to tell a false positive from a real double-submit.
Testing. With the array cache driver, Cache::flush() between tests (RefreshDatabase does not clear it). Write two tests: one asserting a single POST succeeds, and one asserting two identical POSTs in sequence produce the duplicate response and only one DB row. Run that suite on Laravel 10 specifically — that's the framework version the 1.3.0 release was cut for, and it's the only one with an explicit compatibility claim.
Payload hashing is brittle. If the fingerprint includes the raw body:
{"a":1,"b":2} and {"b":2,"a":1} may hash differently, defeating the guard.Normalize before hashing (sorted keys, strip volatile fields) by overriding the package's normalization step — that's the primary extension point to look for in the source.
TTL is a judgment call. Too short and a slow retry escapes the window; too long and a legitimate second submission (user adds a second item, resubmits) is silently blocked. 30–120 seconds covers retry behavior; anything measured in minutes starts blocking real work. Make it config-driven, not hard-coded.
Middleware ordering with auth/auth:sanctum. If same-request runs before authentication, it can't scope by user and may key on session-less data. Put it after.
Livewire and Inertia bypass your mental model. Livewire round-trips are XHRs with generated payloads that will rarely collide, so the middleware gives you little there — handle double-submit in the component. Inertia's form helper already disables submit buttons; the middleware is a backstop, not the primary defense.
False positives from polling/auto-save. Any client that sends identical bodies on a timer (heartbeats, autosave) will get blocked. Explicitly exclude those routes rather than tuning the TTL down globally.
Package age and Laravel compatibility. 1.3.0 ("Laravel 10 Support") is the first release that names a framework version at all, which is a good sign but a narrow one: it says nothing about Laravel 11/12, and a one-line changelog is not a support policy. Before adopting:
illuminate/support constraint in 1.3.0's composer.json. If it caps at ^10.0, you cannot install on 11/12 without --ignore-platform-reqs.app/Http/Middleware — it is small enough that forking is cheaper than waiting for the next release. Given the release cadence, assume you are the maintainer.Debugging workflow. When a request is unexpectedly blocked: (1) confirm the cache store via php artisan tinker → Cache::getStore(); (2) inspect keys matching the package's prefix; (3) log the computed hash in both requests and diff them. Nine times out of ten the body normalized differently than you assumed.
Configuration quirks. If the config isn't published, you're running on defaults baked into the package — check for mergeConfigFrom in the service provider so a missing publish doesn't silently pin you to a 60-second TTL or the default cache store. After upgrading to 1.3.0, re-publish or diff the config stub: a version bump is the most common moment for a new key to appear that your published copy silently lacks.
Extension points worth knowing. Look for (a) a configurable key/closure for fingerprint generation, (b) a status-code or response callback, (c) an exclude-list. If none exist, subclass the middleware and override the hashing method — that's the cleanest seam and keeps you upgradable if a new release ever lands.
How can I help you explore Laravel packages today?