Laravel Lock: Distributed Locks for Models and Routes
Laravel Composer Pacakge #Laravel #Distributed Locks #Composer Package #Race Conditions #Queue Workers

Laravel Lock: Distributed Locks for Models and Routes

4 min read Mohamed Said Mohamed Said

What Is Laravel Lock?

Race conditions in queue workers are easy to overlook until a customer receives two identical shipments. Laravel's built-in Cache::lock() handles the primitive, but you still have to format the key, manage the owner token, and remember the finally block every time. Laravel Lock, by Md Mahedi Zaman Zaber, wraps all of that behind a fluent builder, a model trait, and route middleware.

Acquiring and Releasing Locks

The Lock facade accepts an action name and an optional target, then returns a pending lock you can configure before acquiring:

use ZaberDev\Lock\Facades\Lock;

$lock = Lock::for('shipment_dispatch', $shipment)->ttl(120);

if ($lock->acquire()) {
    try {
        $carrier->dispatch($shipment);
    } finally {
        $lock->release();
    }
}

Each builder generates its own UUID owner token. A second Lock::for(...) instance carries a different token, so calling release() on it does nothing — preventing accidental cross-process releases. When the acquire and release happen in separate processes, set the token explicitly with ->owner('worker-7').

For a cleaner one-liner, block() acquires, runs the callback, and releases automatically:

$manifest = Lock::for('shipment_dispatch', $shipment)
    ->block(function () use ($shipment, $carrier) {
        return $carrier->dispatch($shipment);
    });

If the lock is already held, block() throws LockAcquisitionException — which carries the LockInfo of the blocking lock — rather than silently skipping the work. Both acquire() and block() accept a wait duration so they retry before giving up:

$lock->acquire(blockSeconds: 5);
Lock::for('stock_allocation', $warehouse)->block($callback, 60, 5);

Model-Scoped Locks with HasLocks

Add the HasLocks trait to any Eloquent model and the lock target is derived automatically from the morph class and primary key:

use ZaberDev\Lock\HasLocks;

class Shipment extends Model
{
    use HasLocks;
}

$shipment->lock('dispatch')->ttl(120)->acquire();
$shipment->isLocked('dispatch');
$shipment->forceReleaseLock('dispatch');

The generated key looks like dispatch:App_Models_Shipment:42. Register a morph map and you get the shorter alias. Non-model targets can implement the Lockable interface and return a custom identifier string.

Route Middleware

The package registers a lock middleware alias. Pass it an action name and a TTL in seconds:

Route::post('/warehouse/reconcile', [ReconcileController::class, 'store'])
    ->middleware('lock:warehouse_reconcile,300');

To scope the lock to a specific route model binding, embed the parameter in the action name:

Route::post('/shipments/{shipment}/dispatch', [ShipmentController::class, 'dispatch'])
    ->middleware('lock:shipment_dispatch:{shipment},60');

When the lock is already held, the middleware throws LockAcquisitionException before the controller runs. The exception code is 423, but you need to handle it explicitly to return the right HTTP status:

$exceptions->render(function (LockAcquisitionException $e) {
    return response()->json([
        'message' => 'Already processing. Try again in a moment.',
        'retry_after' => $e->lockInfo?->remainingSeconds(),
    ], 429);
});

Cache vs. Database Storage

The default driver is set via LOCK_DRIVER. Switch per lock with ->using('database'):

  • Cache driver — uses Cache::add() for atomicity; fast and suitable for Redis or Memcached.
  • Database driver — writes to a locks table with lockForUpdate() inside a transaction; survives cache flushes and supports Eloquent queries.

Expired rows are pruned via Laravel's Prunable trait. Schedule it daily:

Schedule::command('model:prune', ['--model' => LockModel::class])->daily();

Three events fire when enabled: LockAcquired, LockFailed, and LockReleased. Listening for LockFailed surfaces which actions actually contend in production.

Installation

Requires PHP 8.2+ and Laravel 11, 12, or 13:

composer require zaber-dev/laravel-lock
php artisan vendor:publish --tag=locks-config
php artisan vendor:publish --tag=locks-migrations
php artisan migrate

Key Takeaways

  • Fluent builder with acquire(), block(), refresh(), and inspection helpers like remainingSeconds().
  • HasLocks trait auto-generates model-scoped lock keys using morph class and primary key.
  • Route middleware protects endpoints or individual model routes without touching controller code.
  • Two drivers: fast cache-backed locks or durable database locks that survive restarts.
  • LockAcquisitionException carries LockInfo so callers know how long to wait before retrying.

Source: Laravel Lock: Distributed Locks for Models and Routes — Laravel News

Found this useful?

Frequently Asked Questions

3 questions
Q01 What is the difference between the cache and database drivers in Laravel Lock?
The cache driver uses `Cache::add()` for atomicity and is faster, making it suitable for short-lived locks on Redis or Memcached. The database driver writes rows to a `locks` table using `lockForUpdate()` inside a transaction, so locks survive a cache flush or Redis restart and can be queried with Eloquent.
Q02 How does the route middleware handle a request when a lock is already held?
The `lock` middleware throws a `LockAcquisitionException` before the controller runs. The exception code is 423, but you must handle it explicitly in your exception handler to return the appropriate HTTP status to the client — for example, a 429 response with a `retry_after` value from `$e->lockInfo->remainingSeconds()`.
Q03 Can I use Laravel Lock with non-Eloquent targets?
Yes. For targets that are not Eloquent models, implement the `Lockable` interface on your value object and return a custom identifier string from `getLockTargetIdentifier()`. That string becomes the second segment of the lock key.

Continue reading

More Articles

View all