Skip to content

Distributed Lock Middleware

The distributed lock middleware prevents concurrent modifications to the same resource. It uses Laravel’s Cache lock (Redis-based) to ensure that only one request can modify a resource at a time, preventing race conditions and data corruption.

ComponentLocationPurpose
LockRequestInterfaceapp/Support/DistributedLock/Interface for controllers to declare locks
LockRequestMiddlewareapp/Support/DistributedLock/Middleware for acquiring locks
DistributedLockapp/Support/DistributedLock/Lock wrapper around Laravel’s Cache::lock()
LockKeyManagerapp/Support/DistributedLock/Centralized lock key generation
AcquireLockFailedExceptionapp/Support/DistributedLock/Exceptions/Exception thrown when lock fails

File: app/Support/DistributedLock/LockRequestInterface.php

Interface that controllers implement to specify which resources should be locked.

interface LockRequestInterface
{
/**
* @param \Illuminate\Http\Request $request
*
* @return \App\Support\DistributedLock\DistributedLock|null
*/
public function getLockOnRequest(Request $request): DistributedLock|null;
}
  • Returns a DistributedLock instance (not a configuration object)
  • Return null if no lock is needed for the request

File: app/Support/DistributedLock/DistributedLock.php

Wrapper class around Laravel’s Cache::lock() with additional release management features.

class DistributedLock
{
private readonly string $lock_key;
private readonly int $lock_seconds;
private int $option_max_wait_seconds = 0;
private Lock $lock;
public function __construct(string $key, int $seconds)
{
$this->lock_key = $key;
$this->lock_seconds = $seconds;
$this->lock = Cache::lock($key, $seconds);
}
public static function make(string $key, int $seconds): self
{
return new self($key, $seconds);
}
public function maxWait(int $seconds): self
{
$this->option_max_wait_seconds = $seconds;
return $this;
}
public function acquire(): bool
{
$is_acquired = false;
try {
$this->lock->block($this->option_max_wait_seconds);
$is_acquired = true;
} catch (LockTimeoutException) {
}
return $is_acquired;
}
public function acquireOrFail(string $message = 'Resource is currently locked by another process. Please retry later.'): self
{
$is_acquired = $this->acquire();
if (!$is_acquired) {
throw new AcquireLockFailedException($this->lock_seconds, $message);
}
return $this;
}
public function release(): bool
{
return $this->lock->release();
}
public function releaseOnHttpResponse(): self
{
Context::pushHidden(ContextServiceProvider::CONTEXT_KEY_LOCKS_RELEASE_ON_HTTP_RESPONSE, $this);
return $this;
}
public function releaseOnJobProcessedOrFailed(): self
{
Context::pushHidden(ContextServiceProvider::CONTEXT_KEY_LOCKS_RELEASE_ON_JOB_PROCESSED_OR_FAILED, $this);
return $this;
}
public function cancelReleaseOnHttpResponse(): self
{
// Removes this lock from the release list
$locks = Context::getHidden(ContextServiceProvider::CONTEXT_KEY_LOCKS_RELEASE_ON_HTTP_RESPONSE, []);
$locks = array_filter($locks, fn (DistributedLock $lock): bool => $lock !== $this);
Context::addHidden(ContextServiceProvider::CONTEXT_KEY_LOCKS_RELEASE_ON_HTTP_RESPONSE, $locks);
return $this;
}
public static function getLocksShouldBeReleasedOnHttpResponse(): array
{
return Context::getHidden(ContextServiceProvider::CONTEXT_KEY_LOCKS_RELEASE_ON_HTTP_RESPONSE, []);
}
}
MethodPurpose
make(key, seconds)Factory method to create lock
maxWait(seconds)Set maximum wait time when lock is held
acquire()Try to acquire lock (returns bool)
acquireOrFail(message)Acquire or throw exception
release()Release the lock
releaseOnHttpResponse()Mark lock for auto-release after HTTP response
releaseOnJobProcessedOrFailed()Mark lock for auto-release after job completes
cancelReleaseOnHttpResponse()Cancel auto-release after HTTP response
/**
* SingleFlight - ensures only one execution even with concurrent requests
*
* @template T
* @param \Closure():T $callback
* @param int|Closure(T):int $result_ttl
* @return T
*/
public function singleFlight(Closure $callback, int|Closure $result_ttl): mixed
{
$result_key = $this->lock_key . ':result';
return Cache::get($result_key, fn () => $this->lock->block(
$this->lock_seconds,
fn () => Cache::remember($result_key, $result_ttl, $callback),
));
}

File: app/Support/DistributedLock/LockKeyManager.php

Centralized lock key generation for consistency.

final class LockKeyManager
{
private const string LOCK_KEY_PREFIX = 'lock:';
public static function generateCategoryModificationKey(int $organization_id): string
{
return self::generateKey('CategoryModification', $organization_id);
}
public static function generatePersonCreationKey(int $household_id): string
{
return self::generateKey('PersonCreation', $household_id);
}
public static function generateIntegrationImportKey(string $request_uri, string $payload): string
{
return self::generateKey('IntegrationImport', hash('xxh64', "$request_uri\n$payload"));
}
public static function generateIntegrationAccessTokenKey(string $vendor): string
{
return self::generateKey('IntegrationAccessToken', $vendor);
}
public static function generateIntegrationCredentialsKey(int $integration_id): string
{
return self::generateKey('IntegrationCredentials', $integration_id);
}
public static function generateInternalSubscriptionCreationKey(string $billable_type, int $billable_id): string
{
return self::generateKey('InternalSubscriptionCreation', $billable_type, $billable_id);
}
private static function generateKey(string|int ...$args): string
{
return self::LOCK_KEY_PREFIX . implode(':', $args);
}
}
lock:CategoryModification:{organization_id}
lock:PersonCreation:{household_id}
lock:IntegrationImport:{hash}
lock:IntegrationAccessToken:{vendor}
lock:IntegrationCredentials:{integration_id}
lock:InternalSubscriptionCreation:{billable_type}:{billable_id}

Controllers implement LockRequestInterface to declare their locking requirements.

final class PersonController extends ModelController implements LockRequestInterface
{
#[\Override]
public function getLockOnRequest(BaseRequest $request): DistributedLock|null
{
return match ($request->method()) {
Request::METHOD_POST => DistributedLock::make(
LockKeyManager::generatePersonCreationKey(
$request->route()->decryptedParameter('household'),
),
ONE_MINUTE_IN_SECONDS,
),
default => null,
};
}
}
final class CategoryController extends Controller implements LockRequestInterface
{
public function getLockOnRequest(Request $request): DistributedLock|null
{
return match ($request->method()) {
Request::METHOD_POST,
Request::METHOD_PUT,
Request::METHOD_DELETE => DistributedLock::make(
LockKeyManager::generateCategoryModificationKey(
$request->route()->decryptedParameter('organization'),
),
ONE_MINUTE_IN_SECONDS,
),
default => null,
};
}
}

File: app/Support/DistributedLock/LockRequestMiddleware.php

Middleware that acquires locks before request processing and releases them after.

final class LockRequestMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$route = $request->route();
$lock = null;
if ($route !== null) {
$controller = $route->getController();
if ($controller instanceof LockRequestInterface) {
$lock = $controller->getLockOnRequest($request);
$lock?->releaseOnHttpResponse();
}
}
try {
if ($lock !== null) {
$lock->acquireOrFail();
// Store lock in Context for developer access
Context::addHidden(ContextServiceProvider::CONTEXT_KEY_HTTP_REQUEST_LOCK, $lock);
}
return $next($request);
} catch (AcquireLockFailedException $e) {
throw new TooManyRequestsHttpException($e->retry_after, $e->msg, $e);
} finally {
foreach (DistributedLock::getLocksShouldBeReleasedOnHttpResponse() as $lock_to_be_released) {
$lock_to_be_released->release();
}
}
}
}
  • Checks if controller implements LockRequestInterface
  • Calls getLockOnRequest() to get lock configuration
  • Automatically calls releaseOnHttpResponse() to register for cleanup
  • Uses acquireOrFail() to acquire lock
  • Stores lock in Context for developer access via Context::getHttpRequestLock()
  • Throws TooManyRequestsHttpException (429) when lock fails
  • Uses finally block to release ALL locks registered for HTTP response

File: app/Support/DistributedLock/Exceptions/AcquireLockFailedException.php

final class AcquireLockFailedException extends Exception
{
public function __construct(public int|null $retry_after = null, public string $msg = '')
{
parent::__construct($msg);
}
}

{
"message": "Resource is currently locked by another process. Please retry later."
}

HTTP Status: 429 Too Many Requests

Headers:

Retry-After: {lock_seconds}

Request arrives
┌────────────────────────────┐
│ Controller implements │
│ LockRequestInterface? │
└────────────────────────────┘
│ │
Yes No → Continue normally
┌────────────────────────────┐
│ getLockOnRequest() │
│ returns DistributedLock? │
└────────────────────────────┘
│ │
Lock Null → Continue normally
┌────────────────────────────┐
│ releaseOnHttpResponse() │
│ Register for auto-cleanup │
└────────────────────────────┘
┌────────────────────────────┐
│ acquireOrFail() │
│ Cache::lock()->block() │
└────────────────────────────┘
│ │
Success Failed (after wait)
│ │
▼ ▼
┌────────┐ ┌─────────────────────┐
│ Store │ │ TooManyRequests 429 │
│ in │ │ with Retry-After │
│ Context│ └─────────────────────┘
└────────┘
┌────────────────────────────┐
│ Process request │
└────────────────────────────┘
┌────────────────────────────┐
│ finally: Release ALL locks │
│ registered for HTTP response
└────────────────────────────┘

Developers can access the request lock via Context:

use Illuminate\Support\Facades\Context;
use App\Providers\ContextServiceProvider;
// Get the lock acquired by LockRequestMiddleware
$lock = Context::getHidden(ContextServiceProvider::CONTEXT_KEY_HTTP_REQUEST_LOCK);
// Or using the helper method
$lock = Context::getHttpRequestLock();
// Cancel auto-release if needed
$lock?->cancelReleaseOnHttpResponse();

DistributedLock supports serialization for queue job scenarios:

public function __serialize(): array
{
return [
'lock_key' => $this->lock_key,
'lock_seconds' => $this->lock_seconds,
'lock_owner' => $this->lock->owner(),
];
}
public function __unserialize(array $data): void
{
$this->lock_key = $data['lock_key'];
$this->lock_seconds = $data['lock_seconds'];
$this->lock = Cache::lock($this->lock_key, $this->lock_seconds, $data['lock_owner']);
}

This allows locks to be passed to queue jobs and released later.


// Automatically called by middleware
$lock->releaseOnHttpResponse();
// For queue jobs that need to hold lock
$lock->releaseOnJobProcessedOrFailed();
// If you need to hold lock beyond HTTP response
$lock->cancelReleaseOnHttpResponse();
$lock->release();

ComponentPurpose
LockRequestInterfaceController declares locking needs (returns DistributedLock|null)
DistributedLockLock wrapper around Cache::lock()
LockKeyManagerCentralized key generation with prefix lock:
LockRequestMiddlewareAcquires/releases locks, throws 429 on failure
AcquireLockFailedExceptionException with retry_after for 429 response
  1. Use LockKeyManager - Keep lock key generation centralized
  2. Only lock when needed - Check request method before returning lock
  3. Use appropriate TTL - Short for quick operations (ONE_MINUTE_IN_SECONDS)
  4. Handle 429 errors - Frontend should handle lock failures with Retry-After
  5. Use SingleFlight - For expensive operations that should not run concurrently