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.
Components
Section titled “Components”| Component | Location | Purpose |
|---|---|---|
| LockRequestInterface | app/Support/DistributedLock/ | Interface for controllers to declare locks |
| LockRequestMiddleware | app/Support/DistributedLock/ | Middleware for acquiring locks |
| DistributedLock | app/Support/DistributedLock/ | Lock wrapper around Laravel’s Cache::lock() |
| LockKeyManager | app/Support/DistributedLock/ | Centralized lock key generation |
| AcquireLockFailedException | app/Support/DistributedLock/Exceptions/ | Exception thrown when lock fails |
1. LockRequestInterface
Section titled “1. LockRequestInterface”File: app/Support/DistributedLock/LockRequestInterface.php
Interface that controllers implement to specify which resources should be locked.
Interface Definition
Section titled “Interface Definition”interface LockRequestInterface{ /** * @param \Illuminate\Http\Request $request * * @return \App\Support\DistributedLock\DistributedLock|null */ public function getLockOnRequest(Request $request): DistributedLock|null;}Key Behavior
Section titled “Key Behavior”- Returns a
DistributedLockinstance (not a configuration object) - Return
nullif no lock is needed for the request
2. DistributedLock
Section titled “2. DistributedLock”File: app/Support/DistributedLock/DistributedLock.php
Wrapper class around Laravel’s Cache::lock() with additional release management features.
Implementation
Section titled “Implementation”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, []); }}Key Methods
Section titled “Key Methods”| Method | Purpose |
|---|---|
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 Pattern
Section titled “SingleFlight Pattern”/** * 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), ));}3. LockKeyManager
Section titled “3. LockKeyManager”File: app/Support/DistributedLock/LockKeyManager.php
Centralized lock key generation for consistency.
Implementation
Section titled “Implementation”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 Key Patterns
Section titled “Lock Key Patterns”lock:CategoryModification:{organization_id}lock:PersonCreation:{household_id}lock:IntegrationImport:{hash}lock:IntegrationAccessToken:{vendor}lock:IntegrationCredentials:{integration_id}lock:InternalSubscriptionCreation:{billable_type}:{billable_id}4. Controller Implementation
Section titled “4. Controller Implementation”Controllers implement LockRequestInterface to declare their locking requirements.
Example: PersonController
Section titled “Example: PersonController”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, }; }}Example: CategoryController
Section titled “Example: CategoryController”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, }; }}5. LockRequestMiddleware
Section titled “5. LockRequestMiddleware”File: app/Support/DistributedLock/LockRequestMiddleware.php
Middleware that acquires locks before request processing and releases them after.
Implementation
Section titled “Implementation”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(); } } }}Key Behavior
Section titled “Key Behavior”- 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
Contextfor developer access viaContext::getHttpRequestLock() - Throws
TooManyRequestsHttpException(429) when lock fails - Uses
finallyblock to release ALL locks registered for HTTP response
6. AcquireLockFailedException
Section titled “6. AcquireLockFailedException”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); }}7. Error Response
Section titled “7. Error Response”{ "message": "Resource is currently locked by another process. Please retry later."}HTTP Status: 429 Too Many Requests
Headers:
Retry-After: {lock_seconds}8. Lock Flow Diagram
Section titled “8. Lock Flow Diagram”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└────────────────────────────┘9. Context Integration
Section titled “9. Context Integration”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();10. Serialization Support
Section titled “10. Serialization Support”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.
11. Release Strategies
Section titled “11. Release Strategies”HTTP Response Release (Default)
Section titled “HTTP Response Release (Default)”// Automatically called by middleware$lock->releaseOnHttpResponse();Job Completion Release
Section titled “Job Completion Release”// For queue jobs that need to hold lock$lock->releaseOnJobProcessedOrFailed();Cancel Auto-Release
Section titled “Cancel Auto-Release”// If you need to hold lock beyond HTTP response$lock->cancelReleaseOnHttpResponse();Manual Release
Section titled “Manual Release”$lock->release();Summary
Section titled “Summary”| Component | Purpose |
|---|---|
LockRequestInterface | Controller declares locking needs (returns DistributedLock|null) |
DistributedLock | Lock wrapper around Cache::lock() |
LockKeyManager | Centralized key generation with prefix lock: |
LockRequestMiddleware | Acquires/releases locks, throws 429 on failure |
AcquireLockFailedException | Exception with retry_after for 429 response |
Best Practices
Section titled “Best Practices”- Use LockKeyManager - Keep lock key generation centralized
- Only lock when needed - Check request method before returning lock
- Use appropriate TTL - Short for quick operations (ONE_MINUTE_IN_SECONDS)
- Handle 429 errors - Frontend should handle lock failures with Retry-After
- Use SingleFlight - For expensive operations that should not run concurrently