DEV Community

Cover image for Laravel Lock vs Cache::lock: When Entity-Scoped Locking Earns a Package
Hafiz
Hafiz

Posted on Originally published at hafiz.dev

Laravel Lock vs Cache::lock: When Entity-Scoped Locking Earns a Package

Originally published at hafiz.dev


Two workers pick up the same job at the same moment. Both read a stock count of 1. Both decide there is enough. Both allocate it.

You now owe someone an apology email.

Laravel has shipped Cache::lock() for years and it solves this. So when Laravel Lock turned up, a package whose entire job is distributed locking, my first reaction was that we already have this. Then I read what it actually does, and the answer got more interesting.

This is a post about when a thin wrapper earns its place in your composer.json.

What you already have

Cache::lock() is the built-in answer, and for a lot of cases it is the right one.

$lock = Cache::lock('stock_allocation_SKU-1180', 10);

if ($lock->get()) {
    try {
        $this->allocate($sku);
    } finally {
        $lock->release();
    }
}
Enter fullscreen mode Exit fullscreen mode

That works. It is atomic against Redis or Memcached, it has a TTL so a crashed worker cannot hold the lock forever, and it costs you nothing extra.

But look at the key. 'stock_allocation_SKU-1180' is a string you built by hand. Somewhere else in the codebase, someone else builds 'stock-allocation-' . $sku->id and now you have two locks guarding the same resource, which is the same as having none.

That is the actual problem. Not atomicity, naming.

What the package adds

use ZaberDev\Lock\Facades\Lock;

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

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

Lock::for('shipment_dispatch', $shipment) builds the key from the name plus the model, so the same target always produces the same key. You cannot typo your way into a second lock on the same row.

There is a block() form that handles acquire and release for you:

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

Models can carry their own locks with a trait:

use ZaberDev\Lock\HasLocks;

class Shipment extends Model
{
    use HasLocks;
}

$lock = $shipment->lock('dispatch')->ttl(120);
Enter fullscreen mode Exit fullscreen mode

And routes can be guarded with middleware, which is the piece I have hand-rolled more than once:

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

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

That last one binds the lock to the route parameter, so two requests for different shipments do not block each other while two requests for the same one do. When the lock is already held, the middleware does not queue the request. It rejects it with a 429 before your controller runs.

Install:

composer require zaber-dev/laravel-lock
php artisan vendor:publish --provider="ZaberDev\Lock\LockServiceProvider"
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

It needs PHP 8.2 or newer and works on Laravel 11, 12 and 13. Cache drivers or a database table, so Redis, Memcached or your existing SQL database.

The decision

View the interactive component on hafiz.dev

The diagram is the short version. The longer version is that these three tools solve genuinely different problems and people reach for the wrong one constantly.

A database transaction is right when the thing you are protecting is a database write and nothing else. lockForUpdate() inside a transaction is stronger than any application lock, because the database enforces it. If your race is two workers updating the same row, stop reading and use a transaction.

Cache::lock() is right when the work spans more than the database. Calling a payment API, writing a file, sending a webhook. A transaction cannot protect those because they are not transactional. One lock key, one place in the code, no ceremony.

Laravel Lock starts to earn its place when the same logical resource gets locked from several places. A dispatch that can be triggered by a controller, a queued job and an artisan command is three chances to build the key differently. Entity scoping makes that impossible by construction.

Where I would not use it

If you have exactly one lock in your application, this is a dependency you do not need. Cache::lock() with a well-named constant does the same job.

If your race is purely a database one, both of these are the wrong layer. Use lockForUpdate().

And if the goal is to let a few workers through rather than exactly one, that is not a lock at all, it is a funnel. Different tool, different failure modes. I covered that in concurrency limiting with cache funnels.

And be careful with the database driver. It writes a row per lock and every acquire runs a transaction with a SELECT ... FOR UPDATE before the insert. That is correct, and it is also slower than Redis by a wide margin. If you are locking in a hot path, use a cache driver.

The version is also worth a glance. v1.0.1 landed in July 2026, so it is young. The surface is small enough that a breaking change would be cheap to absorb, but I would not put it in the path of anything that cannot fail on day one.

Locks are not a substitute for idempotency

The failure I see most often is not a missing lock. It is a lock treated as a guarantee.

A lock with a TTL can expire while the work is still running. The worker holding it does not find out. A second worker acquires the lock and starts the same work, and now you have the exact race you were preventing, except harder to reproduce because it only happens under load.

Set the TTL longer than the worst realistic runtime, not the average. And make the work idempotent anyway, so that if it does run twice the second run is harmless. The same reasoning applies to queue jobs that must not double-process, and it is the same discipline I used when three multi-tenancy queue bugs turned out to be concurrency problems wearing a different hat.

A lock narrows the window. It does not close it.

FAQ

Is this different from ShouldBeUnique on a job?

Yes. ShouldBeUnique stops a duplicate job being dispatched at all, at dispatch time. A lock protects a section of code at execution time, whoever runs it. Use the first to keep the queue clean, the second to protect the resource.

Does it work without Redis?

Yes. It supports cache drivers and a database table. The database driver writes a row per lock and takes a transaction with lockForUpdate() on each acquire, which is correct but slower. Redis or Memcached for anything hot.

What happens if the process dies while holding a lock?

The TTL releases it. That is why the TTL matters: too short and a second worker starts before the first has finished, too long and a crashed job blocks the resource until it expires. Pick a value longer than your worst realistic runtime.

Can I lock something that is not an Eloquent model?

Yes. The package resolves models, strings and integers into keys out of the box, so Lock::for('stock_allocation', 'SKU-1180') is valid. For your own value objects, implement the package's Lockable interface and the string it returns becomes the second half of the key.

Should I use this or just Cache::lock?

If you have one or two locks, use Cache::lock(). If the same resource is locked from several entry points, the entity scoping stops a whole class of key-mismatch bug and is worth the dependency.

The short version

Cache::lock() is not broken and this package does not replace it. What it replaces is the string key you were building by hand in four different files.

That is a real problem in a codebase of any size, and a boring one to solve yourself. Whether it is worth a dependency comes down to how many places lock the same thing.

The honest test is to count. If one place locks the resource, Cache::lock() and a named constant is the whole answer. If it is four places across a controller, a job and two commands, the key is going to drift, and that is what you are actually buying.

Top comments (0)