skip to content

Atomic Mutexes

Cache::lock() gives a named, expiring mutex with get(), block() and release(), shareable across processes by owner token. Interviewers ask about expiry shorter than the work and lock stores.

on this pageshow

explore

questions

5

In Laravel, how do Cache::lock()'s get() and block() differ when two queue workers try to generate the same monthly statement at once?

level: juniorimportance: must knowfreq 48%

answer

  1. one tries once, one waits
  2. get(): true or false, no waiting
  3. block(5): retries every 250 ms
  4. LockTimeoutException when the wait runs out
  5. closure form releases in finally

basics

~10 s

Cache::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
<?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

for a junior

Recall that Cache::lock() only builds the lock, get() tries once and returns true or false, and block() waits and can throw LockTimeoutException.

for a middle

Explain the closure forms, releasing in finally, the 250 ms polling inside block(), and why every worker must share one lock-capable store.

for a senior

Choose skip versus wait deliberately for duplicate jobs, avoid parking workers in long blocks, and turn LockTimeoutException into a meaningful response.

for a principal

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
open as a page

In Laravel 13, which cache stores can back Cache::lock() across servers, and where do the database and Redis stores keep their locks?

level: middleimportance: should knowfreq 30%

basics

~20 s

Cross-server locks need a shared store: database, redis, memcached or dynamodb; file covers one machine and array one process. The Laravel 13 database default uses the cache_locks table; Redis uses its lock_connection, the default connection.

open as a page

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()?

level: middleimportance: should knowfreq 30%

basics

~10 s

Acquire 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.

open as a page

In Laravel, what goes wrong when a Cache::lock() expires before a statement job finishes, and how do owner tokens and refresh() limit the damage?

level: seniorimportance: should knowfreq 40%

basics

~20 s

If the lock's lifetime ends mid-work, a second worker can acquire it and the statement is generated twice. Owner tokens stop the first worker's release() deleting the second's lock; refresh() extends a lock this process still owns.

open as a page

In Laravel, what do Cache::withoutOverlapping() and Cache::funnel() add over a raw Cache::lock(), and what are their defaults?

level: middleimportance: nice to knowfreq 20%

basics

~20 s

Cache::withoutOverlapping($key, $callback) wraps lock()->block(): it waits up to 10 seconds, runs the callback and releases, holding the lock with no lifetime by default. Cache::funnel($name)->limit(N) allows up to N concurrent runs, each slot a lock that expires after 60 seconds by default.

open as a page