Two queue workers picking up the same shipment at the same moment will both mark it dispatched, and the customer gets the box twice. Cache::lock() already solves that, but you write the key format and the owner token yourself every time, and you remember to release it in a finally. Laravel Lock, by Md Mahedi Zaman Zaber, puts that behind a builder that takes an action name and a target, and stores the lock in either the cache or a database table.
Main features
- Fluent builder:
Lock::for('shipment_dispatch', $shipment)->ttl(120)->acquire()returns a boolean, andblock()wraps a callback so you never write the release. - Model-scoped locks: The
HasLockstrait adds$shipment->lock('dispatch'), and the key includes the model's morph class and primary key. - Route middleware: A
lockalias that takes the lock before your controller runs and releases it afterwards, even when the controller throws. - Two storage drivers:
cachefor any Laravel cache store,databasefor alockstable that survives a cache flush. - Waiting:
acquire()andblock()both take a number of seconds to keep retrying before they give up. - Lock inspection: A readonly
LockInfoobject with the key, owner token and expiry, plus helpers likeremainingSeconds()andisOwnedBy().
Acquiring and Releasing Locks
The Lock facade builds a pending lock from an action string and an optional target. Keep that builder in a variable, because the release has to come from the same instance:
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 makes its own UUID owner token the first time it needs one, and both drivers check that token before they delete anything. Build a second Lock::for(...) and call release() on that instead, and it carries a different token, so the release does nothing and hands back false. You can set the token yourself with owner('worker-7') when the acquire and the release happen in different processes.
The default TTL is 60 seconds. There is forSeconds() and forMinutes() next to ttl(), and refresh() extends a lock you still hold without releasing it first.
block() does the same job in one call and returns whatever the callback returns:
$manifest = Lock::for('shipment_dispatch', $shipment)->block(function () use ($shipment, $carrier) { return $carrier->dispatch($shipment);});
If the lock is already held, block() throws LockAcquisitionException rather than returning null, and the exception carries the LockInfo for the lock that is holding it up. In a queue job that means the job fails instead of quietly skipping the work.
Both methods can wait instead of failing straight away. acquire() takes the number of seconds to keep trying, and block() takes it as a third argument, with a 250 millisecond pause between attempts:
$lock->acquire(blockSeconds: 5); Lock::for('stock_allocation', $warehouse)->block($callback, 60, 5);
To look without acquiring, there is isLocked(), isOwnedByCurrent(), remaining() and info(), and enforce() throws if someone else holds the lock. forceRelease() deletes the record no matter who owns it, for cleaning up after a worker that died holding one.
Locking a Model
Add the HasLocks trait to a model and the target is filled in for you:
use Illuminate\Database\Eloquent\Model;use ZaberDev\Lock\HasLocks; class Shipment extends Model{ use HasLocks;}
$lock = $shipment->lock('dispatch')->ttl(120); $shipment->isLocked('dispatch'); $shipment->forceReleaseLock('dispatch');
The key is the action, then the morph class with backslashes swapped for underscores, then the primary key: dispatch:App_Models_Shipment:42. Register a morph map and you get the shorter alias instead. Scalar targets work too, so Lock::for('stock_allocation', $sku) gives you stock_allocation:SKU-1180.
For a target that is not an Eloquent model, there is a Lockable interface with one method, getLockTargetIdentifier(): string. Implement it on a value object and that string becomes the second half of the key.
On the database driver, HasLocks also gives you a locks() morph relationship pointing at the locks table, for listing what a model currently holds:
$shipment->locks()->where('expires_at', '>', now())->get();
Protecting a Route
The service provider registers a lock middleware alias. Pass it the action, a TTL in seconds, and a driver if you do not want the default:
Route::post('/warehouse/reconcile', [ReconcileController::class, 'store']) ->middleware('lock:warehouse_reconcile,300'); Route::post('/warehouse/reindex', [ReindexController::class, 'store']) ->middleware('lock:warehouse_reindex,600,database');
That form passes no target, so the lock covers the endpoint for everyone. One reconcile runs at a time no matter who started it.
To scope the lock to a single record, put a route parameter in the action name. The middleware swaps {shipment} for that parameter's value, or for its primary key when the parameter is a bound model. The README does not mention this form, but the package's tests use it:
Route::post('/shipments/{shipment}/dispatch', [ShipmentController::class, 'dispatch']) ->middleware('lock:shipment_dispatch:{shipment},60');
A second request for the same shipment is rejected while the first one is still running. Requests for different shipments do not see each other at all. The middleware does not wait, so there is no queueing here, only a rejection.
When the lock is already held, the middleware throws LockAcquisitionException before the controller runs. It extends RuntimeException rather than one of Laravel's HTTP exceptions, so an unhandled one reaches the client as a 500. The exception code is 423, matching the 423 Locked status, but nothing applies that to the response for you. Handle the exception to pick the status the caller gets:
use ZaberDev\Lock\Exceptions\LockAcquisitionException; $exceptions->render(function (LockAcquisitionException $e) { return response()->json([ 'message' => 'Already processing. Try again in a moment.', 'retry_after' => $e->lockInfo?->remainingSeconds(), ], 429);});
The release happens in a finally around $next($request). A validation error or an unhandled exception releases the lock the same way a 200 response does.
Cache or Database Storage
config/locks.php sets the default driver through the LOCK_DRIVER environment variable, and using() switches it for a single lock:
Lock::for('inventory_sync', $warehouse)->using('cache')->ttl(15)->acquire(); Lock::for('stock_reconciliation', $warehouse)->using('database')->ttl(600)->acquire();
The cache driver stores a small payload under a lock: prefix and uses Cache::add() for atomicity, the same primitive behind Laravel's atomic cache locks. It is the faster of the two and fine for short locks on Redis or Memcached.
The database driver writes a row to a locks table with a unique key column, and every acquire runs in a transaction that selects with lockForUpdate() before it inserts. Those rows are still there after a cache flush or a Redis restart, and you can query them with Eloquent. You pay a write and a row lock for every acquisition.
Expired rows go away two ways. Reading an expired lock deletes it, and LockModel uses Laravel's Prunable trait for the rest:
use Illuminate\Support\Facades\Schedule;use ZaberDev\Lock\Models\LockModel; Schedule::command('model:prune', ['--model' => LockModel::class])->daily();
Three events fire when locks.events.dispatch is on: LockAcquired with the key, owner, TTL, expiry and LockInfo, LockFailed with the key, owner and whatever lock got in the way, and LockReleased with a $forced flag that tells a normal release apart from a forceRelease(). Listening for LockFailed tells you which actions in your app actually contend, which is useful data when you are debugging race conditions.
LockManager extends Laravel's Manager class, so you register your own driver with Lock::extend():
Lock::extend('dynamodb', fn ($app) => new DynamoLockDriver($app['dynamodb']));
Installation
Laravel Lock needs PHP 8.2 or newer and supports Laravel 11, 12, and 13:
composer require zaber-dev/laravel-lock
The config and migration publish under separate tags, and you only need the migration for the database driver:
php artisan vendor:publish --tag=locks-configphp artisan vendor:publish --tag=locks-migrationsphp artisan migrate
The package also ships a Laravel Boost skill covering its API patterns. It publishes under the locks-skill tag, and the service provider copies it to .ai/skills/laravel-lock during an Artisan command if it finds Boost installed or an existing .ai/skills directory.
Source and documentation are on the Laravel Lock GitHub repository.