In Laravel, how do you acquire a Cache::lock() in a controller and release it from the queued job it dispatches, using owner() and restoreLock()?
answer
- pass the owner token, not the lock
- $lock->owner() returns the random token
- Cache::restoreLock($name, $owner)
- release in finally or failed()
- restored lock has 0 seconds
basics
~10 sAcquire the lock in the controller, pass $lock->owner() into the job, and inside the job call Cache::restoreLock($name, $owner)->release(). The restored object carries the same owner token, so its owner-checked release() succeeds.
solid answer
~40 sA lock object cannot usefully travel into a queued job, but its identity can. In the controller, `Cache::lock("statement:{$id}:{$month}", 600)->get()` either fails (return a response saying generation is already running) or succeeds; then dispatch `GenerateStatement::dispatch($account, $month, $lock->owner())`. In the job, rebuild the same lock with `Cache::restoreLock($name, $this->owner)` and call `release()` in a `finally` block, or from `failed()`, so an exception does not strand it. Because `release()` checks the owner, only the job holding the original token can free the lock; `forceRelease()` ignores the owner for manual clean-up. Restored locks are created with a lifetime of 0, so pass explicit seconds if you call `refresh()` on one.
code
php · 30 lines<?php
namespace App\Jobs;
use App\Models\Account;
use App\Services\StatementGenerator;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
class GenerateStatement implements ShouldQueue
{
use Queueable;
public function __construct(
public Account $account,
public string $month,
public string $lockOwner,
) {}
public function handle(StatementGenerator $generator): void
{
try {
$generator->generate($this->account, $this->month);
} finally {
Cache::restoreLock("statement:{$this->account->id}:{$this->month}", $this->lockOwner)
->release();
}
}
}go deeper
Recall that a lock has an owner token, owner() reads it, and restoreLock() rebuilds the lock in another process so it can be released.
Walk through controller acquire, dispatch with the owner, and release in finally, and explain why a fresh Cache::lock() cannot release it.
Size the lifetime for queue delay plus work, release from finally or failed(), use the same store, and avoid refresh() without seconds on restored locks.
Decide whether a lock spanning request and job is the right boundary, or whether the job should own its own exclusivity end to end.
## The scenario A customer clicks **Generate August statement**. The controller should refuse a second click while the first render is running, but the render itself happens later in a queue worker. So the lock is **acquired in one process** (the web request) and must be **released in another** (the worker), possibly on a different server. Laravel supports this with the lock's **owner token**. ## How owner tokens make this work - Every lock object has an owner string. `Cache::lock($name, $seconds)` generates a random one unless you pass a third argument; `$lock->owner()` returns it. - The store records that owner next to the lock name (a `cache_locks` row on the database store, the key's value on Redis). - `release()` deletes the stored lock only when the stored owner equals the object's owner. - `Cache::restoreLock($name, $owner)` builds a new lock object with the **same name and owner**, without trying to acquire anything. Its `release()` therefore passes the owner check. ## The code, end to end 1. The controller builds the lock name from the account and month and calls `get()`. 2. If `get()` returns `false`, respond with a message (for example a 409 or a redirect with a flash) instead of dispatching. 3. If it returns `true`, dispatch the job with `$lock->owner()` as a constructor argument. The owner is a plain string, so it serializes with the job payload. 4. The job renders the statement and, in `finally`, calls `Cache::restoreLock($name, $this->owner)->release()`. ```php // Controller $lock = Cache::lock("statement:{$account->id}:{$month}", 600); if (! $lock->get()) { return back()->with('status', 'This statement is already being generated.'); } GenerateStatement::dispatch($account, $month, $lock->owner()); ``` ## Failure paths to design for - **The job throws.** Without `finally` (or a `failed()` method that restores and releases), the lock stays until its 600-second lifetime ends, and the customer cannot retry. - **The job never runs** (queue backlog, worker down). The lock's lifetime is the only thing that frees it, so size it for realistic queue delay plus render time, not for render time alone. - **The lifetime ends before the job finishes.** Another click can then acquire the lock and dispatch a second job. When the first job finishes, its owner-checked `release()` returns `false` and leaves the newer lock alone. - **Wrong store.** `restoreLock()` must be called on the same store that issued the lock. If the controller used `Cache::store('redis')->lock(...)`, the job needs `Cache::store('redis')->restoreLock(...)`. - **Manual clean-up.** After a crash, an operator can run `Cache::lock($name)->forceRelease()` to delete the lock regardless of owner. ## The `refresh()` trap on restored locks The built-in stores implement `restoreLock()` as `lock($name, 0, $owner)`, so the restored object's own lifetime is **0**. Calling `refresh()` with no argument re-applies that 0: | Store | `refresh()` on a restored lock with no seconds | |---|---| | Redis | removes the expiry entirely (the lock no longer expires) | | Database | sets expiry to the store's `lock_timeout`, 86400 s by default | Pass the intended lifetime explicitly: `Cache::restoreLock($name, $this->owner)->refresh(600)`. ## When to prefer something else This cross-process pattern is for locks whose scope is "from the click until the job ends". Laravel also ships job-level uniqueness and job middleware for overlapping jobs; those are separate features with their own rules, and the raw lock remains the tool when the lock must start in the web request. ## Choosing your own owner `Cache::lock($name, $seconds, $owner)` accepts an owner as its third argument. Supplying a meaningful value — for example an identifier of the statement request stored in the database — can make logs clearer and lets any process that knows that identifier restore the lock without passing a random token around. The trade-off is that a predictable owner is easier to reuse by mistake: two requests that compute the same owner would both pass the owner check. Random owners, the default, avoid that, so only choose your own when the value is genuinely unique per acquisition.
- In Laravel, why pass $lock->owner() to a queued job instead of the lock object itself?The job is serialized and runs later in another process, perhaps on another server. The owner token is a plain string that serializes cleanly, and together with the lock name it is all `Cache::restoreLock()` needs to rebuild an object that passes the owner check on `release()`. The original object's connection or store handle is not what matters; the stored name and owner are.
- In Laravel, what happens if the job calls Cache::lock($name)->release() instead of restoreLock($name, $owner)->release()?`Cache::lock($name)` generates a fresh random owner, so `release()` compares that new token with the stored one, finds a mismatch and returns `false`. The lock stays held until its lifetime ends, blocking the customer's retries. Only a lock rebuilt with the original owner, or `forceRelease()`, removes it.
saying these in an interview costs you the question
- Any process can release a lock by creating Cache::lock() with the same name
- restoreLock() acquires the lock again for the job
- A lock acquired in a web request is released when the request ends
- The lock object should be serialized into the job payload
- forceRelease() only works for the process that owns the lock