Caching Strategy — Fast by Default
Series: Every Laravel Project Should Have These Building Blocks
Part: 17 of 35 Level: Intermediate Prerequisites: App Service Provider
What You’ll Learn
- The
CacheKeysenum pattern — typed, conflict-free cache keys Cache::remember()for simple cachingCache::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:
- First call: runs the callback, caches result for 60s
- Between 0–30s: returns cached value immediately
- Between 30–60s: returns cached (slightly stale) value AND re-runs the callback in the background to refresh the cache
- 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
CacheKeysenum 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
- Laravel Docs: Cache
- Laravel Docs:
Cache::flexible()— stale-while-revalidate (Laravel 11+) - Laravel Docs: Cache Tags
- PHP Enums — the
CacheKeyspattern uses backed enums