In Laravel, how do Cache::lock()'s get() and block() differ when two queue workers try to generate the same monthly statement at once?
answer
- one tries once, one waits
- get(): true or false, no waiting
- block(5): retries every 250 ms
- LockTimeoutException when the wait runs out
- closure form releases in finally
basics
~10 sCache::lock($name, $seconds)->get() tries once and returns true or false immediately; block($wait) keeps retrying and throws LockTimeoutException after $wait seconds. Both accept a closure and release the lock automatically when it finishes.
solid answer
~40 s`Cache::lock('statement:42:2026-08', 300)` creates a named lock that expires after 300 seconds; nothing is acquired yet. `get()` makes **one** attempt: `true` if this process now holds the lock, `false` if someone else does. `block(5)` retries, sleeping 250 ms between attempts, and throws `Illuminate\Contracts\Cache\LockTimeoutException` if it still has not acquired the lock after 5 seconds. Pass a closure to either and Laravel runs it while holding the lock, then releases it in a `finally` block, returning the closure's result (or `false` from `get()` when the lock was busy). For duplicate statement jobs `get()` fits — the second worker just skips — while `block()` suits a caller that genuinely needs to run after the holder finishes.
code
php · 23 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 function handle(StatementGenerator $generator): void
{
Cache::lock("statement:{$this->account->id}:{$this->month}", 300)
->get(fn () => $generator->generate($this->account, $this->month));
// get() returned false: another worker holds the lock, so skip.
}
}go deeper
Recall that Cache::lock() only builds the lock, get() tries once and returns true or false, and block() waits and can throw LockTimeoutException.
Explain the closure forms, releasing in finally, the 250 ms polling inside block(), and why every worker must share one lock-capable store.
Choose skip versus wait deliberately for duplicate jobs, avoid parking workers in long blocks, and turn LockTimeoutException into a meaningful response.
Decide where mutual exclusion belongs for the statement pipeline: a cache lock, a unique job, or a database constraint, and what each guarantees on failure.
## What `Cache::lock()` returns In a bank-statement service, a `GenerateStatement` job builds one account's PDF for one month. If the job is dispatched twice — a user double-clicks, or a retry overlaps a slow first attempt — two workers can render and email the same statement. An **atomic lock** is a named mutex stored in the cache so that only one process at a time can hold it. ```php $lock = Cache::lock("statement:{$account->id}:{$month}", 300); ``` This call only builds a lock object: a **name**, a **lifetime in seconds** after which the store forgets it, and a random **owner token** (`Str::random()`, 16 characters) that identifies this holder. Nothing is acquired until you call `get()` or `block()`. ## `get()`: one attempt - `get()` with no argument returns `true` if the lock was acquired, `false` otherwise. It never waits. - `get($closure)` acquires, runs the closure, and releases the lock in a `finally` block, so an exception inside the closure still frees it. It returns the closure's return value, or `false` if the lock was busy and the closure never ran. ## `block()`: wait for a while - `block($seconds)` loops: attempt to acquire, and if that fails sleep 250 ms (adjustable with `betweenBlockedAttemptsSleepFor($ms)`) and try again. - When the wait budget runs out it throws `Illuminate\Contracts\Cache\LockTimeoutException`. - `block($seconds, $closure)` behaves like `get($closure)` after acquiring: it runs the closure and releases the lock in `finally`, returning the closure's result. | | `get()` | `block($seconds)` | |---|---|---| | Attempts | one | repeated, every 250 ms by default | | Lock busy | returns `false` | waits, then throws `LockTimeoutException` | | With a closure | runs it and releases; `false` if busy | runs it and releases after acquiring | | Blocks the worker | no | up to `$seconds` | ## Releasing correctly When you do not use the closure form you own the release: ```php $lock = Cache::lock($name, 300); if ($lock->get()) { try { $generator->generate($account, $month); } finally { $lock->release(); } } ``` - `release()` deletes the lock **only if this object's owner token still matches** the stored one, and returns `false` otherwise. - Forgetting the `finally` means an exception leaves the lock held until its lifetime ends, and every duplicate attempt in that window is skipped or times out. - The lifetime is a safety net for crashes (a killed worker never reaches `finally`), not the normal way to unlock. ## Choosing between them for statements 1. **Duplicate job, same work:** use `get()`. If the lock is taken, another worker is already generating this statement, so the second job can simply return. 2. **A caller that needs the result:** a "download statement" request that must wait for an in-progress render can `block(10)` and then read the finished file, catching `LockTimeoutException` to show a "still generating" message. 3. **Long waits are a smell.** A queue worker parked inside `block(60)` is not processing anything else; prefer skipping or re-dispatching the job over long blocking. ## What locks need from the store Locks live in the cache store, so every worker must talk to the **same** store: `database`, `redis`, `memcached` or `dynamodb`. The `file` store only coordinates processes on one server, and the `array` store only within one process. In a Laravel 13 skeleton the default `database` store keeps locks in the `cache_locks` table, so the pattern works out of the box across workers that share the database. ## Naming the lock The name decides what is mutually exclusive, so build it from every dimension of the work: - `statement:{account}:{month}` lets different accounts, and different months of one account, render in parallel while blocking only true duplicates. - A bare `statement` name would serialize the whole service behind one lock. - The store applies the cache prefix to lock names automatically, so you do not add the app name yourself. - Keep the name in one place, such as a method on the job, so the code that acquires and any code that later releases or inspects the lock cannot drift apart. A well-chosen name plus `get()` with a closure covers most duplicate-work problems in a few lines.
- In Laravel, what does Cache::lock($name, 300)->get($closure) return when another process already holds the lock?It returns `false` and the closure never runs. When the lock is acquired, the same call returns whatever the closure returns and releases the lock in a `finally` block. Code that needs to know whether work happened should therefore not rely on the closure returning `false` itself, since that would be indistinguishable.
- In Laravel, how would you handle LockTimeoutException from block() in a controller that waits for a statement render?Catch `Illuminate\Contracts\Cache\LockTimeoutException` around `block(10, ...)` and return a response that says the statement is still being generated, for example a 202 with a retry hint, instead of letting it become a 500. Keep the wait short, because the request's PHP worker is tied up for the whole block.
saying these in an interview costs you the question
- Cache::lock() acquires the lock as soon as it is called
- get() waits until the lock becomes free
- block() returns false when the wait runs out
- The closure form leaves the lock held until its TTL expires
- A lock on the file store coordinates workers on several servers