databases storage and caching

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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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 example
<?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.

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;
  • recognise cache stampede risk for popular keys.

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 example
<?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.