Caching Strategy — Fast by Default

Caching Strategy — Fast by Default

Reading Time: 4 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 17 of 35 Level: Intermediate Prerequisites: App Service Provider


What You’ll Learn

  • The CacheKeys enum pattern — typed, conflict-free cache keys
  • Cache::remember() for simple caching
  • Cache::flexible() for stale-while-revalidate (SWR) caching
  • When and how to invalidate cache
  • Named cache stores and TTL strategies

The Problem with String Cache Keys

Most Laravel tutorials show this:

Cache::remember('products', 3600, fn () => Product::all());
Cache::remember('products', 3600, fn () => Product::active()->get());
Cache::remember('products-featured', 3600, fn () => Product::featured()->get());

As the codebase grows, you end up with cache keys scattered across 40 files, some colliding with each other, some with inconsistent TTLs, some never invalidated correctly. When a product is updated, which cache keys do you clear?


The CacheKeys Enum

Centralizing cache keys in an Enum solves all of this:

// app/Enums/CacheKeys.php
<?php

declare(strict_types=1);

namespace App\Enums;

enum CacheKeys: string
{
    // Status page
    case StatusChecks     = 'status.checks';
    case StatusPageProbe  = 'status.probe';

    // Dashboard
    case DashboardStats   = 'dashboard.stats';
    case DashboardCharts  = 'dashboard.charts';

    // Products
    case ProductCatalogue = 'products.catalogue';
    case FeaturedProducts = 'products.featured';

    // Users
    case UserPermissions  = 'user.permissions.{id}';
    case UserProfile      = 'user.profile.{id}';

    // Settings
    case AppSettings      = 'settings.app';
    case FeatureFlags     = 'settings.features';

    /**
     * Generate a per-entity cache key.
     */
    public function forId(int|string $id): string
    {
        return str_replace('{id}', (string) $id, $this->value);
    }

    /**
     * Clear this cache key.
     */
    public function forget(): bool
    {
        return Cache::forget($this->value);
    }

    /**
     * Clear a per-entity cache key.
     */
    public function forgetFor(int|string $id): bool
    {
        return Cache::forget($this->forId($id));
    }
}

Usage:

// Store
Cache::remember(CacheKeys::DashboardStats->value, now()->addMinutes(5), fn () => /* ... */);

// Per-entity
Cache::remember(CacheKeys::UserPermissions->forId($user->id), 3600, fn () => /* ... */);

// Forget
CacheKeys::DashboardStats->forget();
CacheKeys::UserPermissions->forgetFor($user->id);

Now when a product changes, grep for CacheKeys::FeaturedProducts across the codebase and you find every place it’s used, set, and cleared.


Cache::remember() — Simple Caching

The most common pattern: cache a query result for a fixed duration.

$stats = Cache::remember(
    CacheKeys::DashboardStats->value,
    now()->addMinutes(5),
    fn (): array => $this->computeDashboardStats()
);

Expire time guidance:

  • Dashboard totals: 5 minutes
  • Product catalogue: 1 hour (or invalidate on product changes)
  • Status checks: 60 seconds
  • User permissions: 15 minutes (or invalidate on permission changes)
  • App settings: until changed (use Cache::rememberForever() + explicit invalidation)

Cache::flexible() — Stale-While-Revalidate

Cache::flexible() is a Laravel 11 addition. It lets you serve stale data immediately while refreshing in the background — so users never wait for a slow query, even as the cache expires.

$results = Cache::flexible(
    CacheKeys::StatusChecks->value,
    [
        30,   // serve stale data for up to 30 seconds after TTL
        60,   // data expires completely after 60 seconds
    ],
    fn (): array => $this->runAllChecks()
);

How it works:

  1. First call: runs the callback, caches result for 60s
  2. Between 0–30s: returns cached value immediately
  3. Between 30–60s: returns cached (slightly stale) value AND re-runs the callback in the background to refresh the cache
  4. After 60s: cache is cold, blocks on the callback

This is perfect for status pages, dashboards, and anything where near-real-time is good enough and zero-latency is more important than perfect freshness.


Cache Invalidation on Model Changes

The correct place to invalidate caches is in Observers — after a model is changed:

// app/Observers/ProductObserver.php
class ProductObserver
{
    public function created(Product $product): void
    {
        CacheKeys::ProductCatalogue->forget();
        CacheKeys::FeaturedProducts->forget();
    }

    public function updated(Product $product): void
    {
        CacheKeys::ProductCatalogue->forget();

        if ($product->wasChanged('is_featured')) {
            CacheKeys::FeaturedProducts->forget();
        }
    }

    public function deleted(Product $product): void
    {
        CacheKeys::ProductCatalogue->forget();
        CacheKeys::FeaturedProducts->forget();
    }
}

The Observer reacts to every model lifecycle event. You don’t have to remember to clear caches in each controller — it happens automatically.


Per-Request In-Memory Caching

For expensive operations that might be called multiple times in a single request (e.g., checking if a user has a permission from multiple places in the view layer), use PHP’s in-process memory:

// app/Models/User.php
class User extends Authenticatable
{
    use HasCachedAttributes;

    public function getPermissions(): array
    {
        return $this->rememberCachedAttribute('permissions', function (): array {
            return $this->roles
                ->flatMap->permissions
                ->pluck('slug')
                ->unique()
                ->values()
                ->all();
        });
    }
}

This computes the permission list only once per User instance per request, without touching Redis.


Cache Tags for Group Invalidation

When you need to invalidate multiple related keys at once, use tags (requires a tag-capable driver like Redis or Memcached):

// Store with tags
Cache::tags(['products', 'catalogue'])->remember('products.list', 3600, fn () => /* ... */);
Cache::tags(['products', 'category:electronics'])->remember('electronics', 3600, fn () => /* ... */);

// Invalidate all product caches at once
Cache::tags(['products'])->flush();

This is more powerful than manually tracking individual keys, but requires a Redis or Memcached driver.


Named Cache Stores

For specific use cases (rate limiting, session data, model caching), use named stores:

// config/cache.php
'stores' => [
    'file'     => ['driver' => 'file', 'path' => storage_path('framework/cache/data')],
    'redis'    => ['driver' => 'redis', 'connection' => 'cache'],
    'sessions' => ['driver' => 'redis', 'connection' => 'sessions'],
    'locks'    => ['driver' => 'redis', 'connection' => 'locks'],
],

Usage:

// Use the sessions store specifically
Cache::store('sessions')->put('user:1:cart', $cart, 3600);

// Rate limiting on a separate connection
Cache::store('locks')->add("rate-limit:email:{$email}", 1, 300);

Separate connections mean a Redis memory issue in one store doesn’t affect another.


Key Takeaways

  • The CacheKeys enum centralizes all cache keys, adds type safety, and makes invalidation findable via grep.
  • Cache::remember() for standard caching. Cache::flexible() for stale-while-revalidate — users never wait.
  • Invalidate caches in Observers — don’t scatter Cache::forget() calls across controllers.
  • Use per-request in-memory caching (via model traits) for expensive computations called multiple times per request.
  • Cache tags (Redis-only) let you flush a group of related keys with one call.
  • TTL guidelines: dashboard stats = 5min, catalogues = 1hr, status checks = 1min, settings = forever+explicit-invalidate.

Tips and Gotchas

⚠️ Warning: Cache::remember() is not atomic. Under high concurrency, multiple processes can simultaneously find the cache empty and all hit the database at once — the “cache stampede” problem. Cache::flexible()(Laravel 11+) solves this by serving stale data while refreshing in the background.

💡 Tip: Keep cache TTLs short for user-specific data (minutes) and longer for shared reference data (hours/days). A misconfigured long TTL on user data is how users see each other’s information after an account change.

🔥 Expert Note: Never cache Eloquent model instances directly — cache scalar values, arrays, or plain PHP objects. Cached models don’t update when the database changes, their relationships aren’t loaded, and they can break when the model class changes between deployments.

Further Reading


← Artisan Commands | Next: HeartBeat Job →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.