Laravel / Advanced / Lesson 14
Cache correctness and concurrency
Treat cached data as a versioned, scoped derivative and choose freshness, invalidation, and lock behavior from the actual business contract.
On this page
Lesson goals
- Build cache keys from tenant scope and every result-changing input.
- Distinguish read-through caching from stampede protection.
- Explain lock leases, stale data, and backend assumptions.
A cache is a copy with a consistency policy
Caching can reduce repeated work, but the authoritative data and permission rules remain elsewhere. Define which result is cached, who may see it, how long staleness is acceptable, and which change invalidates it. The synthetic fixture is a tenant-scoped published-post count: every authorized member of one tenant sees the same aggregate. It is not an authorization cache. Financial balances, revocation decisions, and personalized results may require a different policy. A cache outage must have a deliberate fallback rather than silently bypassing security or overwhelming the database with unrestricted recomputation.
Keys express the identity of the derived result
Include tenant, result version, and normalized filters when they affect the result. A page, locale, visibility scope, or user-specific permission can also require a separate key. The complete PHP fixture canonicalizes a small flat filter map before hashing it. It verifies that equivalent filter order produces one key and another tenant produces a different key. It does not validate authorization, normalize arbitrary nested data, or contact a cache service. Version prefixes help change a cached representation safely, but old versions still need expiry or deliberate cleanup according to the application retention policy.
<?php
declare(strict_types=1);
function reportKey(int $tenantId, array $filters): string
{
if ($tenantId < 1) { throw new InvalidArgumentException("invalid tenant"); }
ksort($filters);
return "report:v1:tenant:".$tenantId.":".hash("sha256", json_encode($filters, JSON_THROW_ON_ERROR));
}
$first = reportKey(7, ["status" => "published", "locale" => "en"]);
$same = reportKey(7, ["locale" => "en", "status" => "published"]);
$other = reportKey(8, ["status" => "published", "locale" => "en"]);
echo json_encode([$first === $same, $first === $other], JSON_THROW_ON_ERROR), PHP_EOL;
[true,false]
The output confirms order-independent equality for this flat fixture and separation by tenant. It does not test Laravel cache persistence or locking.
A missing value can trigger many recalculations
Cache::remember avoids recomputation when a value is present, but its callback is not a general distributed single-flight guarantee. Several misses can begin the same expensive work. The contextual fragment uses an atomic cache lock around a second cache lookup, so a worker acquiring the lock after another refresh can reuse the newly stored result. It assumes Post has tenant_id and status fields and that authorization has already established tenant 7. The cache store must support locks and coordinate all participating processes; an isolated array store cannot coordinate separate servers. The chosen durations are illustrative workload assumptions, not tuning recommendations.
<?php
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
$key = "posts:published-count:v1:tenant:7";
$count = Cache::lock($key.":refresh", 10)->block(2, function () use ($key): int {
return Cache::remember($key, 30, function (): int {
return Post::query()->where("tenant_id", 7)->where("status", "published")->count();
});
});
Expected observation in an isolated shared-store fixture: one lock holder computes a missing value, while a later holder can reuse it. Lock acquisition can time out; no cache or database is contacted by this lesson.
Choose what stale means before serving it
A lock lease can expire while work is still running, so bound execution or choose a renewal and fencing design appropriate to the backend. Catch lock-timeout failures at a boundary that can return an acceptable response or fallback. Laravel Cache::flexible supports stale-while-revalidate with fresh and stale time windows, but this is valid only when serving old data is acceptable. It is not a way to make a revoked permission safe. Database commits and cache invalidation are separate operations; account for races, failed invalidations, and readers repopulating an old result. Strongly consistent decisions should read an authoritative source with the required transaction semantics.
Test isolation, misses, and invalidation explicitly
Use synthetic tenants with identical filter values and different expected counts, then test that results never cross tenant keys. Exercise cold misses, warm hits, expiry, concurrent refresh, timeout, and mutation followed by invalidation. A facade mock can verify that remember was called, but it does not prove store-level atomicity or shared-server behavior. Record hit rate, miss latency, refresh duration, and lock contention using bounded labels. Avoid broad cache flushes as a routine repair: they can remove unrelated application state and cause a synchronized miss storm. Keep operational invalidation procedures separate from these read-only course examples.
Common errors and fixes
A cached report is visible to another tenant.
- Why it happens
- The key omits a result-changing security scope.
- How to fix it
- Include the validated scope and review existing invalidation.
- Prevent it next time
- Test two tenants with identical request filters.
Many workers recalculate the same expired result.
- Why it happens
- Read-through caching was mistaken for exclusive refresh.
- How to fix it
- Use a shared lock or an appropriate stale-data policy.
- Prevent it next time
- Measure contention and test concurrent misses.
An old refresh overlaps after its lease expires.
- Why it happens
- The lease was shorter than the actual critical work.
- How to fix it
- Bound work and apply backend-appropriate ownership protection.
- Prevent it next time
- Treat lock lifetime as a correctness assumption.
Check your understanding
1. A report gains a locale-sensitive title, but the key remains tenant plus status. Explain the defect and how the offline key fixture can protect the repair.
Show worked solution
Different locales can reuse the same cached value and show the wrong title. Add validated locale to the canonical result identity, then assert that en and ar keys differ while reordering the same flat filters does not change a key. This tests identity only; an isolated cache integration test should still verify the stored representations and invalidation policy.
2. A refresh can take fifteen seconds while its lock lease is ten. Why is a successful first acquisition insufficient, and what must the design decide?
Show worked solution
Another worker may acquire a new lease before the first computation finishes. Decide whether to bound the operation, choose a justified longer lease, renew safely, or use stronger backend-specific fencing. Retain an idempotent update policy and handle lock timeout separately. Merely changing the blocking wait duration does not extend the acquired lease or prevent an expired holder from continuing.
Key takeaways
- Cache identity must include result-changing scope.
- Freshness and exclusion are separate policies.
- Mocks and local stores do not prove distributed correctness.