Cache Invalidation Basics
Cache invalidation is the work of making sure a cached copy stops being used when it is no longer correct. Real applications change products, prices, permissions, settings, pages, and files, so every cache needs a plan for becoming fresh again.
The main skill is tying each cached value to the data that can make it stale. A junior PHP developer should be able to choose a TTL, delete affected keys after writes, use versioned keys where helpful, and avoid clearing far more cache than a change requires.
Start With The Risk Of Stale Data
Before caching a value, ask what happens if it is wrong for 10 seconds, 5 minutes, or an hour.
<?php
declare(strict_types=1);
function staleRisk(string $dataType): string
{
return match ($dataType) {
'product_price' => 'high: stale prices can affect orders',
'user_permissions' => 'high: stale permissions can leak access',
'blog_sidebar' => 'low: brief staleness is usually acceptable',
default => 'unknown: decide how stale this data may be',
};
}
echo staleRisk('product_price') . PHP_EOL;
// Prints:
// high: stale prices can affect orders
A blog sidebar can often tolerate a longer TTL. Permissions and prices usually need targeted invalidation and a short fallback TTL.
TTL-Based Invalidation
A TTL is the simplest invalidation strategy. The cache entry expires after a fixed number of seconds.
<?php
declare(strict_types=1);
function ttlForCachedValue(string $valueType): int
{
return match ($valueType) {
'homepage_featured_products' => 300,
'shipping_rate_quote' => 60,
'product_price' => 30,
'compiled_templates' => 0,
default => 120,
};
}
echo ttlForCachedValue('shipping_rate_quote') . PHP_EOL;
// Prints:
// 60
TTL-only caching is resilient and easy to reason about. Its tradeoff is that users can see stale data until expiry. A TTL of 0 should be reserved for data with a deliberate manual or deployment-based invalidation path.
Delete Affected Keys After Writes
When source data changes, remove the cache keys that depend on it. This is explicit invalidation.
<?php
declare(strict_types=1);
function productCacheKeysToDelete(int $productId, string $categorySlug): array
{
return [
'product:detail:' . $productId,
'product:summary:' . $productId,
'homepage:featured_products',
'category_products:' . $categorySlug . ':page:1',
];
}
print_r(productCacheKeysToDelete(42, 'lighting'));
// Prints:
// [0] => product:detail:42
// [1] => product:summary:42
// [2] => homepage:featured_products
The difficult part is knowing every cache entry that depends on changed data. Central key builders and clear naming conventions reduce missed keys.
Centralise Key Builders
If reads and invalidation construct keys differently, stale values survive.
<?php
declare(strict_types=1);
function productDetailKey(int $productId): string
{
return 'product:detail:' . $productId;
}
function categoryProductsKey(string $categorySlug, int $page): string
{
return 'category_products:' . $categorySlug . ':page:' . $page;
}
echo productDetailKey(42) . PHP_EOL;
echo categoryProductsKey('lighting', 1) . PHP_EOL;
// Prints:
// product:detail:42
// category_products:lighting:page:1
Small key-builder functions make cache reads, writes, deletes, logs, and tests use the same key shape.
Invalidate After The Source Changes
The database or durable storage is the source of truth. Update it first, then invalidate the cache.
<?php
declare(strict_types=1);
function productUpdatePlan(bool $databaseUpdateSucceeded): array
{
if (!$databaseUpdateSucceeded) {
return [
'invalidate_cache' => false,
'reason' => 'source update failed, so the cached value still matches',
];
}
return [
'invalidate_cache' => true,
'reason' => 'delete dependent keys after the source update succeeds',
];
}
print_r(productUpdatePlan(true));
// Prints:
// [invalidate_cache] => 1
// [reason] => delete dependent keys after the source update succeeds
If cache deletion fails after a successful database update, stale data may still be served. Log the failure and retry when the value is important. A short TTL limits the damage if explicit invalidation fails.
Versioned Keys
A versioned key changes when the underlying record changes. The application naturally reads a fresh key without needing to find and delete every old entry immediately.
<?php
declare(strict_types=1);
function versionedProductKey(int $productId, string $updatedAt): string
{
return 'product:detail:' . $productId . ':v' . sha1($updatedAt);
}
echo versionedProductKey(42, '2026-05-20T10:30:00Z') . PHP_EOL;
// Prints:
// product:detail:42:v17aaedc67cc3d7e239a7404b99035c115aa99190
Old entries may remain until their TTL expires or cleanup removes them. Versioning prevents stale reads, but it does not remove the need to control memory usage.
Dependency Tags
Some cache libraries support tags. A rendered product page might be tagged with product:42 and category:lighting, allowing related entries to be removed together.
<?php
declare(strict_types=1);
function cacheTagsForProduct(int $productId, string $categorySlug): array
{
return [
'product:' . $productId,
'category:' . $categorySlug,
];
}
print_r(cacheTagsForProduct(42, 'lighting'));
// Prints:
// [0] => product:42
// [1] => category:lighting
Even when a backend does not support tags directly, thinking in dependencies helps identify which keys need deleting.
Avoid Broad Cache Clears
Clearing an entire cache can hide a weak invalidation design. It removes unrelated entries, triggers expensive rebuilds, and can create a sudden spike in database or API traffic.
<?php
declare(strict_types=1);
function invalidationScope(string $changeType): string
{
return match ($changeType) {
'single_product_saved' => 'delete product detail, summary, and affected listing keys',
'template_format_changed' => 'clear rendered fragments during deployment',
'global_tax_rate_changed' => 'invalidate affected price and quote keys',
default => 'identify dependent keys before clearing cache',
};
}
echo invalidationScope('single_product_saved') . PHP_EOL;
// Prints:
// delete product detail, summary, and affected listing keys
Prefer targeted invalidation. Use broad clears deliberately for cases such as deployments, emergency repairs, or global format changes.
Cache Stampedes
When a popular key expires, many requests may try to rebuild it at once. This is called a cache stampede.
Common mitigations include:
- adding a short lock around rebuilding;
- serving stale data briefly while one request refreshes it;
- adding small random TTL differences so keys do not all expire together;
- warming important entries after deployment.
<?php
declare(strict_types=1);
function ttlWithJitter(int $baseTtl, int $jitterSeconds): int
{
return $baseTtl + intdiv($jitterSeconds, 2);
}
echo ttlWithJitter(300, 40) . PHP_EOL;
// Prints:
// 320
The example is deterministic so it is easy to run. Real code may use a small random offset.
Choose Read And Write Ordering Explicitly
Cache-aside reads usually check the cache, load the source after a miss, then store the result. Writes require a policy. A common sequence is:
- Commit the source-of-truth database change.
- Delete or replace affected cache entries.
- Return success only according to the application's consistency contract.
Deleting before the database commit creates a race. Another request can miss the cache, read the old database value, and repopulate stale data before the write commits.
Updating the cache before the database also risks publishing a value whose source write later fails. Source first, invalidation second is usually the understandable default.
There remains a failure gap: the database can commit and cache deletion can fail. Use a bounded TTL, retries or an outbox-backed invalidation worker when staleness is important. Record the failed key and source version rather than logging sensitive cached content.
Decide Whether To Delete Or Replace
Deleting is simple. The next reader reloads authoritative data. Replacing can avoid a miss but requires the writer to construct exactly the same representation as the read path.
Delete when many code paths can change the record, the cached representation is complex, or rebuilding is cheap. Replace when the new complete value is already available and the update can preserve key version and serialization rules.
Partial mutation of cached documents is risky. If a cached product contains price, categories, stock summary, and display text, changing only the price may leave other fields stale. Prefer rebuilding the full value or deleting it.
Handle Multi-Layer Caches
A response may pass through APCu, Redis, an application framework, a reverse proxy, a CDN, and a browser. Invalidating Redis does not purge a CDN response, and changing a CDN key does not reset an in-process APCu entry.
Document each layer:
Browser: Cache-Control max-age=60
CDN: surrogate key product-42
Redis: product:detail:42:v3
APCu: process-local parsed configuration
For a product update, identify which layers contain product-dependent data and which can expire naturally. Use response cache headers and surrogate-key purges deliberately. Avoid caching personalized responses in a shared layer unless the cache key and privacy controls are proven correct.
Protect Security-Sensitive Decisions
Permissions, account suspension, subscription entitlement, and feature access can become security issues when stale. A long TTL may let revoked access continue.
Prefer authoritative checks or very short, explicitly invalidated caches for high-risk decisions. Include tenant, user, resource, permission, and policy version in the key where relevant. Never let a missing tenant dimension share one customer's authorization result with another.
A cache outage should not automatically grant access. Define fail-closed or authoritative fallback behavior. Availability requirements do not justify silently bypassing authorization.
Prevent Old Writers From Restoring Stale Data
Concurrent cache rebuilds can finish out of order:
Request A reads source version 10 slowly
Source changes to version 11
Request B caches version 11
Request A finishes and overwrites the key with version 10
Versioned keys avoid the overwrite because each source version uses a different key. Another option stores a version in the value and uses a compare-and-set or script so an older version cannot replace a newer one.
Locks used for stampede control need expiry and ownership tokens. A process can pause beyond the lock TTL; it must not delete a lock now owned by another worker.
Observe Cache Correctness
Track more than hit rate. A high hit rate can mean the application serves stale data efficiently.
Useful metrics include:
- hit, miss, and error rates;
- rebuild duration;
- invalidation attempts and failures;
- age or source version of served entries;
- stampede-lock contention;
- fallback database load;
- memory usage and evictions;
- CDN purge failures;
- authorization-cache bypass or rejection counts.
Add a safe diagnostic path that compares a sampled cached value's source version with authoritative data. Do not compare or log sensitive payloads unnecessarily.
Test The Failure Cases
Tests should cover:
- source update failure causes no invalidation;
- successful source update deletes the expected keys;
- deletion failure is recorded and bounded by TTL;
- old serialized versions are rejected;
- two tenants never share a key;
- a stampede lock allows one rebuild;
- stale-while-revalidate never exceeds the permitted stale window;
- cache unavailability follows the documented fallback;
- broad clears are not used for one-record changes.
Time-dependent tests are more reliable with an injected clock than real sleep calls. A fake cache can record operations, but integration tests should also verify the actual backend's TTL and atomic-operation semantics.
What To Check
Before moving on, make sure you can:
- judge how dangerous stale data would be for a cached value;
- choose a TTL based on acceptable staleness;
- delete specific keys after a successful source-of-truth update;
- centralise key builders so reads and deletes agree;
- explain when versioned keys and dependency tags help;
- avoid broad cache clears unless there is a deliberate reason;
- recognize cache stampede risk for popular keys;
- order database writes and invalidation safely;
- map dependencies across process, Redis, proxy, CDN, and browser caches;
- protect authorization and tenant-specific cache keys;
- monitor freshness and invalidation failures, not only hit rate.
The ordering and stampede cases are developed further in Cache Races, Stampedes, And Fencing.
Deep Dive And Application
Start With The Requirement
Cache invalidation is the work of making sure a cached copy stops being used when it is no longer correct. Real applications change products, prices, permissions, settings, pages, and files, so every cache needs a plan for becoming fresh again. That statement is the starting point, but a production decision needs a more precise requirement. A PHP developer working with a shared database or cache under real request concurrency should identify who depends on the behavior, what state is allowed to change, what must remain true after success, and what the caller should observe after failure. Without those details, two implementations can both look reasonable while providing different guarantees.
For Cache Invalidation Basics, write the requirement in observable terms before choosing a command, library, pattern, or provider. Name the input, the expected output, and the authority that owns the result. Then identify whether the operation is local to one process or crosses PHP application code, the database transaction, cache state, and any asynchronous follow-up work. Every additional boundary introduces another place where data can be stale, work can be repeated, configuration can drift, or an apparently successful step can fail before the complete outcome is durable.
A useful review question is: "What fact will still be true if the process stops immediately after any individual step?" This question exposes hidden ordering assumptions. It also separates the essential guarantee from a preferred implementation. The implementation may change as the project grows, but the invariant and the evidence for it should remain understandable.
Build A Precise Mental Model
The main concepts in this lesson include Start With The Risk Of Stale Data, TTL-Based Invalidation, Delete Affected Keys After Writes, and Centralise Key Builders, Invalidate After The Source Changes, Versioned Keys. Do not study them as isolated vocabulary. Connect each concept to a state transition: what exists before the operation, what decision is made, what changes, and what the next observer can see.
Model durable rows as authoritative and cached values as derived state. Put transaction boundaries, uniqueness constraints, locks, invalidation, and retry points directly on the state-transition diagram. Use a small diagram or state table to expose ownership, transitions, and the observations available to each participant. This does not need specialist notation. Its purpose is to make the lesson-specific invariant inspectable before implementation begins.
Next, walk through one success path and at least two failure paths. One failure should happen before the authoritative change, and one should happen after that change but before the caller receives confirmation. The second case is especially important because it creates ambiguity: the caller may not know whether retrying is harmless. A robust design gives that uncertainty an explicit answer through identity, versioning, transactions, conditional operations, or documented recovery steps.
A Repeatable Implementation Workflow
Use the following workflow when applying Cache Invalidation Basics:
- Describe the user or system outcome without naming a tool.
- Identify the authoritative state and the component allowed to change it.
- List every read, decision, write, message, and externally visible side effect.
- State the invariant that must survive retries, concurrency, partial failure, and deployment.
- Choose the smallest mechanism that can preserve that invariant.
- Define errors in terms the caller can act on.
- Add observability at the boundary where uncertainty remains.
- Verify the behavior with a controlled success, rejection, and recovery scenario.
This sequence prevents tool-first design. A team can replace a framework, hosting product, Git platform, data structure, or proxy while retaining the same reasoning. It also improves reviews because the reviewer can challenge one explicit assumption instead of reverse-engineering intent from configuration.
Four practical rules from this lesson deserve special attention:
- adding a short lock around rebuilding. Treat this as a design constraint, not a final cleanup item. Show where the rule is enforced and what happens when input or environment state violates it.
- serving stale data briefly while one request refreshes it. Make the responsible layer visible in code or configuration. Duplicating the rule in unrelated layers creates drift and contradictory behavior.
- adding small random TTL differences so keys do not all expire together. Include the exceptional path in the initial implementation. An error message without a recovery or retry policy often transfers operational uncertainty to users.
- warming important entries after deployment. Verification must observe the real boundary. A helper returning the expected array or command string is not proof that the browser, database, remote repository, proxy, or provider behaves as intended.
Worked Scenario
Consider a checkout workflow that reads product state, updates durable records, and must remain correct during retries and overlapping requests. The team wants to apply Cache Invalidation Basics, but the first design discussion should not start with a product name or one copied configuration block. Start by listing the actors, the state each actor can observe, and the point at which the result becomes authoritative.
The first pass should be deliberately simple. Create one controlled example with known input and an expected result. Record the current behavior before changing it. Apply one mechanism, then repeat the same observation. If several variables change at once, the team cannot tell which change produced the improvement or which one introduced a regression.
Now introduce pressure. Run the operation through two independent database connections, force one transaction to pause, retry one request, and inspect both durable rows and derived cache state. The purpose is to test the assumption that normally remains invisible and to connect the observed failure or success to the lesson-specific invariant.
Finally, inspect query logs, affected-row counts, constraints, concurrent integration tests, cache metrics, and final durable state. The evidence should let another developer explain not only that the test passed, but why the result demonstrates the intended guarantee. Save the relevant command, fixture, request, metric, or trace with the review when the decision is operationally significant.
Failure Analysis
The most valuable failures are not syntax mistakes. They are plausible designs that work in a demonstration but break when ownership, scale, or timing changes.
monitor freshness and invalidation failures, not only hit rate. This usually happens when a developer treats one observed run as the complete specification. Reproduce the case with an explicit fixture or timeline, then move the guarantee to the layer that owns the shared state.
protect authorization and tenant-specific cache keys. Convenience can hide expensive or stateful work. Make that work visible through naming, logging, query inspection, graph inspection, or a dedicated boundary. The caller should know whether an operation can block, retry, mutate shared state, or contact another system.
map dependencies across process, Redis, proxy, CDN, and browser caches. A partial fix often replaces one failure with another. Review the complete lifecycle, including setup, normal operation, cancellation, retry, cleanup, rollback, and later maintenance. The correct solution is the one whose failure behavior remains understandable.
order database writes and invalidation safely. Configuration and documentation describe intent, not runtime truth. Validate permissions, emitted headers, final data, process state, ordering, or output under the environment that will actually execute the work.
When a failure is discovered, resist adding an unexplained delay, broad catch block, global cache clear, forced Git update, or provider-specific switch merely because it makes the immediate symptom disappear. Record the violated invariant first. A narrow repair should restore that invariant and add a regression check that would have failed before the repair.
Verification Strategy
A strong verification plan combines fast local checks with at least one boundary-level test. Use these lesson-specific checks as starting points:
- test representative success and failure paths. Record the fixture and expected observation so the check is repeatable.
- inspect the real boundary rather than only an in-memory value. Inspect the value at the authoritative boundary rather than only the caller's optimistic interpretation.
- record enough evidence for another developer to reproduce the result. Include enough diagnostic context to distinguish invalid input, temporary dependency failure, policy rejection, and an internal defect.
- repeat the check under the environment where the behavior matters. Repeat the check after restart, retry, deployment, or changed ordering when those conditions are relevant.
Verification should also include negative evidence. Confirm that an unsafe path is rejected, that a body is absent when the protocol forbids it, that a duplicate action creates no second business effect, that an old branch cannot overwrite newer shared work, or that an algorithm does not silently accept malformed structure. Negative tests make the boundary concrete.
For performance-sensitive behavior, report a distribution and the tested input size rather than one timing. For reliability-sensitive behavior, report the final durable state and number of side effects. For security-sensitive behavior, test from an untrusted client position. For operational behavior, verify logs and metrics are useful before an incident.
Tradeoffs And Evolution
The simplest correct mechanism is usually preferable. Simplicity means fewer hidden states and clearer ownership, not fewer lines at any cost. A small application may reasonably choose a direct implementation while a larger system needs explicit coordination, queues, versioning, or managed infrastructure. The important point is to know which assumption allows the simpler design.
Record the trigger for reconsidering the choice. Useful triggers include measured latency, data volume, contention, team size, compliance needs, repeated incidents, deployment frequency, provider limitations, or review cost. This avoids premature abstraction while preventing a temporary shortcut from becoming an undocumented permanent architecture.
Compatibility also matters. Existing clients, old application instances, queued messages, cached assets, shared branches, and stored data may outlive one deployment. When changing the mechanism behind Cache Invalidation Basics, plan how old and new behavior overlap. Prefer additive transitions, observable cutovers, and a rollback or roll-forward path.
Review Questions
Before considering the lesson applied, answer these questions in project-specific terms:
- What is the authoritative state, and who owns it?
- Which operation or boundary makes the result durable or shared?
- What can be repeated, reordered, cached, interrupted, or observed late?
- Which input sizes, users, environments, or providers change the tradeoff?
- What does the caller see for success, rejection, temporary failure, and ambiguous outcome?
- Which logs, metrics, traces, diffs, queries, or tests prove the guarantee?
- What is the safe recovery path?
- What future condition would justify a more complex design?
If the answers are vague, the implementation is not finished. Return to the working model, make the invariant explicit, and create a test that observes the boundary directly. The goal of Cache Invalidation Basics is not merely to reproduce an example. It is to make a defensible decision, implement it with visible ownership, and leave evidence that the next developer can use.
Practice
Practice: Plan Product Cache Invalidation
Build a small PHP helper that decides which product-related cache entries should be invalidated after a product update.
Requirements
- Create key builders for product detail, product summary, and category listing caches.
- Return no invalidation work when the database update failed.
- Return targeted keys to delete when the database update succeeded.
- Include tags or dependency labels for the product and category.
- Include a TTL recommendation based on stale-data risk.
- Show one successful update and one failed update.
Focus on the invalidation plan. You do not need to connect to Redis, Memcached, APCu, or a database.
Show solution
This solution centralises keys and invalidates only after the source-of-truth update succeeds.
<?php
declare(strict_types=1);
function productDetailKey(int $productId): string
{
return 'product:detail:' . $productId;
}
function productSummaryKey(int $productId): string
{
return 'product:summary:' . $productId;
}
function categoryListingKey(string $categorySlug, int $page): string
{
return 'category_products:' . $categorySlug . ':page:' . $page;
}
function ttlForStaleRisk(string $risk): int
{
return match ($risk) {
'high' => 30,
'medium' => 300,
'low' => 1800,
default => 120,
};
}
function productInvalidationPlan(
bool $databaseUpdateSucceeded,
int $productId,
string $categorySlug,
string $staleRisk
): array {
if (!$databaseUpdateSucceeded) {
return [
'delete_keys' => [],
'tags' => [],
'ttl_seconds' => ttlForStaleRisk($staleRisk),
];
}
return [
'delete_keys' => [
productDetailKey($productId),
productSummaryKey($productId),
categoryListingKey($categorySlug, 1),
],
'tags' => [
'product:' . $productId,
'category:' . $categorySlug,
],
'ttl_seconds' => ttlForStaleRisk($staleRisk),
];
}
$success = productInvalidationPlan(true, 42, 'lighting', 'high');
$failed = productInvalidationPlan(false, 42, 'lighting', 'high');
echo implode(', ', $success['delete_keys']) . PHP_EOL;
echo implode(', ', $success['tags']) . PHP_EOL;
echo $success['ttl_seconds'] . PHP_EOL;
echo count($failed['delete_keys']) . PHP_EOL;
// Prints:
// product:detail:42, product:summary:42, category_products:lighting:page:1
// product:42, category:lighting
// 30
// 0
The ordering is the important part. The database update comes first; invalidation follows only when the source-of-truth change succeeds.
Design Cache Write Ordering
A product update currently deletes the cache, updates the database, and then writes a new cached value. Explain the races and failure cases.
Design a safer sequence that covers database commit, targeted invalidation, failed cache deletion, TTL fallback, and concurrent old rebuilds.
Show solution
Commit the product update first, then delete the affected keys. If deletion fails, record retryable invalidation work and rely on a bounded TTL to limit stale exposure. Avoid pre-populating from a representation that differs from the normal read path.
Use source-versioned keys or version-aware writes so a slow rebuild of version 10 cannot overwrite version 11. The old key expires naturally or is cleaned asynchronously. Return behavior should match the documented freshness requirement rather than hiding an important invalidation failure.
Protect A Permission Cache
Design a cache key and invalidation policy for whether a user may edit a project. The application is multi-tenant and access can be revoked immediately.
Include all key dimensions, source versioning, TTL, revoke behavior, cache-outage behavior, and tests that prevent cross-tenant leakage.
Show solution
On cache failure, query the authoritative permission store or deny; never grant by default. Tests use identical user and project IDs in different tenants, verify distinct keys, revoke one membership, and prove the other tenant's result is unchanged.