skip to content

Cache & Locks

Laravel's Cache facade stores values in pluggable stores with remember helpers and takes atomic locks through the same stores. Interviewers ask which store to run and how to avoid races.

on this pageshow

explore

questions

11

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, how does Cache::remember() cache an expensive exchange-rate lookup, and what happens on a cache hit versus a miss?

level: juniorimportance: must knowfreq 72%

basics

~20 s

Cache::remember($key, $ttl, $closure) returns the cached value when the key exists; on a miss it runs the closure, stores the result for the TTL (integer seconds or a DateTime) and returns it, so the expensive lookup runs once per TTL window.

open as a page

In Laravel 13, which cache store does a fresh app use, how does CACHE_STORE select it, and how does the failover store behave?

level: juniorimportance: should knowfreq 55%

basics

~20 s

A Laravel 13 skeleton uses the database store: CACHE_STORE in .env feeds the default key of config/cache.php, which names one entry in its stores array. The failover store tries its listed stores in order and moves on only when one throws.

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 does php artisan cache:clear actually delete, and why can it wipe another app's keys despite the cache prefix?

level: middleimportance: should knowfreq 40%

basics

~20 s

php artisan cache:clear calls flush() on the default or named store, which ignores the key prefix: the database store deletes every cache row and the Redis store runs FLUSHDB, so apps sharing that table or database lose keys too.

open as a page

In Laravel, how do Cache::tags() let you flush a group of cached entries, and which cache stores support tags?

level: middleimportance: should knowfreq 45%

basics

~20 s

Cache::tags(['fx', 'fx:EUR'])->put(...) stores an entry under those tags, and Cache::tags('fx:EUR')->flush() drops every entry carrying that tag. Only tag-capable stores such as redis, memcached and array allow it; the database, file, dynamodb and storage stores throw BadMethodCallException.

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, how does Cache::flexible() serve stale exchange rates while refreshing them, and how does it differ from Cache::remember() at expiry?

level: seniorimportance: should knowfreq 35%

basics

~20 s

Cache::flexible($key, [$fresh, $stale], $closure) returns the cached value untouched while it is younger than $fresh; between $fresh and $stale it returns the old value and refreshes it after the response; past $stale it recomputes inline, like a remember() miss.

open as a page

In a new Laravel 13 app, why does an Eloquent collection cached with Cache::remember() come back as __PHP_Incomplete_Class objects on later requests?

level: seniorimportance: should knowfreq 30%

basics

~10 s

The Laravel 13 skeleton sets serializable_classes to false in config/cache.php, so stores unserialize with allowed_classes false and every object, including an Eloquent Collection and its models, becomes __PHP_Incomplete_Class on a cache hit.

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