pyaesoneaung/atomic-locks-middleware
Laravel middleware that prevents overlapping requests by wrapping routes in atomic cache locks. Guard critical endpoints from concurrent execution with configurable keys, wait times, and automatic release—no boilerplate needed.
Requirements check. The package wraps Laravel's atomic lock primitives, so it inherits their constraints: Laravel 10/11+ and a cache store that implements Illuminate\Contracts\Cache\LockProvider (redis, memcached, database, dynamodb, array). The default file store does not support locks — this is the single most common reason the middleware appears to "do nothing."
Install and verify discovery.
composer require pyaesoneaung/atomic-locks-middleware
Laravel package discovery registers the service provider automatically. Confirm by checking vendor/pyaesoneaung/atomic-locks-middleware/src/ for the middleware class and any config file, then read the class's handle() signature to see which parameters it accepts (lock key, timeout, wait time, and rejection behavior are the usual suspects).
Where to look first, in order:
src/ — the middleware class (the whole package is likely < 150 lines; read it).config/atomic-locks.php or similar — for default lock TTL and driver overrides.README for the exact alias name (atomic-lock, lock, etc.) and parameter syntax.First use case: a webhook or payment handler. The canonical day-one scenario is a route that mutates shared state and must never run twice concurrently:
Route::post('/webhooks/stripe', StripeWebhookController::class)
->middleware('atomic-lock:stripe-webhook');
Register the alias if the package doesn't auto-register it. In Laravel 11+ (bootstrap/app.php):
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'atomic-lock' => \PyaeSoneAung\AtomicLocksMiddleware\AtomicLock::class,
]);
})
If the request is already in flight, the middleware short-circuits with a 423 Locked (or 429) response instead of queueing. That's the correct behavior for payment webhooks — the sender retries.
Per-resource locking via route parameter. The most useful day-to-day pattern is a lock key composed from a route or request value, so the same lock doesn't block unrelated users:
Route::post('/orders/{order}/confirm', [OrderController::class, 'confirm'])
->middleware('atomic-lock:order-confirm:{order}');
If the package doesn't interpolate route parameters, do it manually:
Route::post('/orders/{order}/confirm', [OrderController::class, 'confirm'])
->middleware('atomic-lock:order-' . request()->route('order'));
For controllers that need the key from the request body, a small middleware wrapper reading $request->input('id') is often cleaner than fighting the parameter parser.
Route group protection for singleton workers. For endpoints that scrape, sync, or rebuild something global:
Route::middleware('atomic-lock:cron-sync')->group(function () {
Route::get('/cron/pull-orders', ...);
Route::get('/cron/push-inventory', ...);
});
Use separate keys per job — sharing one key across unrelated jobs means a slow report blocks your webhook.
Combining with throttle. They solve different problems and compose cleanly:
Route::post('/api/import', ImportController::class)
->middleware(['throttle:10,1', 'atomic-lock:import']);
throttle limits frequency; the lock limit concurrency. Applying both prevents a 429-loop from users hammering an endpoint whose previous request is still running.
Queued work: prefer job middleware over route middleware. For jobs, the package's route middleware doesn't apply. Use Laravel's built-in WithoutOverlapping job middleware, and reserve this package for HTTP entry points. Don't stack both — you'll get double-rejection semantics and confusing logs.
Fallback / "try then queue" pattern. If the middleware rejects with 423, an API client should honor Retry-After or re-enqueue. Wrap the call site:
$response = Http::post('/orders/'.$id.'/confirm');
if ($response->status() === 423) {
ConfirmOrderJob::dispatch($id)->delay(now()->addSeconds(5));
}
This is where the package earns its keep — it converts an invisible race condition into a retryable, observable status code.
Testing. Locks serialize requests, so parallel test suites can deadlock unexpectedly. Use Cache::lock() against the array store in tests, or bypass the middleware entirely in feature tests:
$this->withoutMiddleware(\PyaeSoneAung\AtomicLocksMiddleware\AtomicLock::class);
Add one dedicated test that asserts two concurrent calls produce one 200 and one 423.
The cache store is the whole ballgame. On local file cache, the lock silently fails to acquire — every request passes. On array cache, the lock is per-PHP-process, so under PHP-FPM it provides zero protection (each worker has its own array). Only redis, memcached, database, or dynamodb give you multi-server correctness. Verify with:
Cache::store()->getStore() instanceof \Illuminate\Contracts\Cache\LockProvider;
If this is false, the middleware is decorative.
Lock TTL vs. request timeout. The lock must expire after the request could possibly finish, otherwise a slow request loses its lock mid-flight and a second request starts processing the same thing. Set the TTL comfortably above max_execution_time and any Http::timeout() inside the handler. A lock TTL of 10s around a call that can take 30s is worse than no lock — it gives you a false sense of safety at exactly the moment you need it.
Lock key collisions are silent. atomic-lock:export used by two different reports will serialize them — the second user gets a 423 for no reason. Namespace keys with the model, action, and tenant: report-export:{tenant}:{report}. Keep keys under 64 chars for Redis sanity.
Response code customization. Most lock middleware returns 423 Locked. Browsers and some HTTP clients handle 423 oddly. If you're serving a form-based app, override the rejection response (the class is small — extend it or read the config for a status/redirect option) to redirect back with an error rather than showing a raw error page.
Exception safety. The middleware should release the lock in a finally block. If a 500 inside your controller leaves the lock held until TTL expiry, you get a mysterious "everything returns 423 for N seconds" incident. Read handle() once during onboarding to confirm; if it doesn't release on exception, keep TTLs short or subclass.
Debugging a lock that never fires. In order:
php artisan route:list -v).Log::info('lock acquired', ['key' => ...]) — most "it doesn't work" reports are a lock key that varies per request (e.g. includes a timestamp or session ID).php artisan config:clear (and route:clear after alias changes).Octane interaction. Locks themselves are fine under Octane because they live in Redis, but any config-file changes need a worker restart. Also avoid holding a lock across Http:: calls that might be swapped by a fake — a faked HTTP client returns instantly while the lock is still held, masking timing bugs in tests.
Distinct from uniqueness constraints. If your real requirement is "exactly one row," a unique index is the correct tool — this middleware is best-effort and can't survive a cache outage. Use it to prevent wasted work and duplicate side effects (double emails, double charges), and pair it with idempotency keys on the mutation itself.
Small-package risk. At 34 stars and zero dependents, budget an hour to read the source before production. It's short enough to audit fully, and the surface area you'd need to fork (lock acquisition, TTL, response) is a class or two — vendor-patching is not a scary outcome here. Pin the version and check 2025-05-31 release notes for any signature changes.
How can I help you explore Laravel packages today?