Route caching, config caching, query caching, full-page caching with response cache, tag-based cache invalidation, and the one caching mistake that causes more bugs than any other — the guide your app needs before your next traffic spike hits.
Caching is the single most impactful performance optimisation available to a Laravel application. It’s also the most misused. Most developers know about Cache::get and Cache::put. Far fewer understand the complete picture: the layers of caching that Laravel provides, how they interact, and the invalidation strategies that prevent your cache from becoming a source of bugs rather than a source of speed.
This post covers all of it — from the Laravel framework-level caches that require no application code to the response caching and tag-based invalidation patterns that make high-traffic applications survivable.
Understanding Cache Layers
Laravel provides caching at multiple levels of the application stack. Understanding where each layer sits determines when it helps and when it doesn’t.
Request → Nginx (static files)
→ Route Cache (framework bootstrap, ~1ms saved)
→ Config Cache (config loading, ~2ms saved)
→ Full-Page Response Cache (entire response, ~50-500ms saved)
→ Application Code
→ Query Cache (database queries, ~5-100ms saved)
→ Fragment Cache (partial results, ~1-50ms saved)
→ Database
Each layer serves requests faster than the layer below it. The goal of caching strategy is to serve as many requests as possible from the highest (fastest) layer while maintaining data correctness.
Layer 1: Framework-Level Caches
These require no application code changes and should be part of every deployment script.
Route Caching
# Compile all routes into a single cached file
php artisan route:cache
# Verify the cache was created
php artisan route:list | head -5
# Clear route cache (required when routes change)
php artisan route:clear
Route caching compiles your entire route definition file into a single serialised PHP file. On subsequent requests, Laravel loads this file directly instead of parsing all your route files. For applications with many routes, this saves 5-15ms on every request.
Critical constraint: Route caching doesn’t work with routes that use closures. Every route must point to a controller method.
// ✗ Cannot be cached — closure route
Route::get('/health', function () {
return response()->json(['status' => 'ok']);
});
// ✓ Can be cached — controller route
Route::get('/health', [HealthController::class, 'check']);
Config Caching
# Merge all config files into a single cached file
php artisan config:cache
# Also cache views
php artisan view:cache
# Cache events (Laravel 11+)
php artisan event:cache
Config caching merges all files in the config/ directory into one serialised file. The framework loads this single file instead of requiring all config files on every request.
Critical constraint: When config cache is active, env() calls outside of config files return null. All environment-based configuration must go through config files, never called directly from application code.
// ✗ Breaks with config cache — don't call env() in application code
if (env('FEATURE_FLAGS_ENABLED')) { ... }
// ✓ Correct — put it in config/features.php and call config()
if (config('features.flags_enabled')) { ... }
The Complete Deployment Script
#!/bin/bash
# deploy.sh — cache everything on every deploy
composer install --no-dev --optimize-autoloader
php artisan migrate --force
# Clear caches before rebuilding (prevents stale data)
php artisan optimize:clear
# Build fresh caches
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
# Or use the shorthand (config + route + view)
# php artisan optimize
Layer 2: Application-Level Query Caching
For data that changes infrequently, caching database query results eliminates repeat database hits.
The remember() Pattern
use Illuminate\Support\Facades\Cache;
// Cache the result for 60 minutes
$categories = Cache::remember('categories:all', 3600, function () {
return Category::with('children')
->where('active', true)
->orderBy('position')
->get();
});
// rememberForever — no expiry (use with explicit invalidation)
$settings = Cache::rememberForever('settings:global', function () {
return Setting::pluck('value', 'key')->all();
});
Structured Cache Keys
Cache key design matters more than most developers realise. Keys that are too broad cause unnecessary invalidation. Keys that are too specific cause cache fragmentation. A consistent naming convention makes maintenance tractable.
// Naming convention: {type}:{identifier}:{qualifier}
// Examples:
'products:all'
'products:1' // single product
'products:category:electronics' // products by category
'users:1:orders' // user's orders
'users:1:orders:pending' // user's pending orders
'dashboard:stats:2026-05-23' // daily dashboard stats
// A helper class to centralise key generation
class CacheKeys
{
public static function product(int $id): string
{
return "products:{$id}";
}
public static function productsByCategory(string $slug): string
{
return "products:category:{$slug}";
}
public static function userOrders(int $userId, string $status = 'all'): string
{
return "users:{$userId}:orders:{$status}";
}
public static function dashboardStats(\Carbon\Carbon $date): string
{
return "dashboard:stats:{$date->toDateString()}";
}
}
// Usage
$product = Cache::remember(CacheKeys::product($id), 3600, fn () =>
Product::with(['images', 'category'])->findOrFail($id)
);
Model-Level Caching Pattern
// app/Models/Product.php
class Product extends Model
{
public static function cached(int $id): self
{
return Cache::remember(
CacheKeys::product($id),
now()->addHour(),
fn () => static::with(['images', 'category', 'variants'])->findOrFail($id)
);
}
protected static function booted(): void
{
// Invalidate cache when the model is updated or deleted
static::updated(fn (Product $product) =>
Cache::forget(CacheKeys::product($product->id))
);
static::deleted(fn (Product $product) =>
Cache::forget(CacheKeys::product($product->id))
);
}
}
// Controller — uses cached model
$product = Product::cached($id);
Layer 3: Tag-Based Cache Invalidation
The single biggest caching mistake in Laravel applications is over-invalidating the cache. When a product changes, some teams invalidate everything with ‘product’ in the key. This is correct in intent but wasteful in practice — it removes cached data that wasn’t affected by the change.
Tag-based caching solves this. You tag related cache entries together and invalidate them as a group — without invalidating unrelated entries.
Driver requirement: Tags require a cache driver that supports them. Redis and Memcached both support tags. The file and database drivers do not.
// app/Services/ProductCacheService.php
use Illuminate\Support\Facades\Cache;
class ProductCacheService
{
// Store products with tags
public function getAll(): \Illuminate\Database\Eloquent\Collection
{
return Cache::tags(['products'])->remember(
'products:all',
3600,
fn () => Product::with('category')->active()->get()
);
}
public function getByCategory(string $categorySlug): \Illuminate\Database\Eloquent\Collection
{
return Cache::tags(['products', "category:{$categorySlug}"])->remember(
"products:category:{$categorySlug}",
3600,
fn () => Product::whereHas('category', fn($q) => $q->where('slug', $categorySlug))
->with('category')
->active()
->get()
);
}
public function get(int $id): Product
{
return Cache::tags(['products', "product:{$id}"])->remember(
"products:{$id}",
3600,
fn () => Product::with(['images', 'category', 'variants'])->findOrFail($id)
);
}
// ── Invalidation ──────────────────────────────────────────────────
// When a specific product changes — only invalidate that product's cache
public function invalidateProduct(int $id): void
{
Cache::tags(["product:{$id}"])->flush();
}
// When any product in a category changes — invalidate that category's listings
public function invalidateCategory(string $slug): void
{
Cache::tags(["category:{$slug}"])->flush();
}
// When any product changes — invalidate all product listings
public function invalidateAll(): void
{
Cache::tags(['products'])->flush();
}
}
Tag Invalidation via Model Observers
The cleanest pattern: use an observer to automatically invalidate cache when the model changes.
// app/Observers/ProductObserver.php
class ProductObserver
{
public function __construct(
private ProductCacheService $cache
) {}
public function updated(Product $product): void
{
// Invalidate this specific product
$this->cache->invalidateProduct($product->id);
// Invalidate the category listing it belongs to
if ($product->wasChanged('category_id') || $product->wasChanged('active')) {
$this->cache->invalidateCategory($product->category->slug);
$this->cache->invalidateAll();
} else {
// Just the category that contains this product
$this->cache->invalidateCategory($product->category->slug);
}
}
public function created(Product $product): void
{
// New product affects all listings and its category
$this->cache->invalidateAll();
$this->cache->invalidateCategory($product->category->slug);
}
public function deleted(Product $product): void
{
$this->cache->invalidateProduct($product->id);
$this->cache->invalidateAll();
$this->cache->invalidateCategory($product->category->slug);
}
}
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Product::observe(ProductObserver::class);
}
Layer 4: Full-Page Response Caching
For pages where the entire response can be cached — landing pages, product pages, blog posts, category listings that don’t change per-user — full-page response caching is the most impactful optimisation.
The spatie/laravel-responsecache package caches complete HTTP responses. For a cached response, the middleware serves the cached HTML or JSON in under 1ms without touching application code, the database, or the view layer.
composer require spatie/laravel-responsecache
php artisan vendor:publish --provider="Spatie\ResponseCache\ResponseCacheServiceProvider"
// config/responsecache.php
return [
'enabled' => env('RESPONSE_CACHE_ENABLED', true),
'cache_lifetime_in_seconds' => env('RESPONSE_CACHE_LIFETIME', 3600), // 1 hour default
'cache_driver' => env('RESPONSE_CACHE_DRIVER', 'redis'),
// Cache different URLs for different user sessions?
// 'UseCacheNameBasedOnSessionId' caches per-session (large cache, accurate)
// 'DoNotHashCacheProfile' uses the same cache for all users (small cache, public pages only)
'cache_profile' => \Spatie\ResponseCache\CacheProfiles\CacheAllSuccessfulGetRequests::class,
// Add this header to cached responses
'add_cache_time_header' => env('RESPONSE_CACHE_DEBUG_HEADER', true),
'cache_time_header_name' => 'X-Response-Cache',
];
// routes/web.php — apply to publicly cacheable routes
Route::middleware(['cacheResponse:3600'])->group(function () {
Route::get('/', [HomeController::class, 'index']);
Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{product}', [ProductController::class, 'show']);
Route::get('/blog', [BlogController::class, 'index']);
Route::get('/blog/{post}', [BlogController::class, 'show']);
});
// Routes that must NOT be cached (authenticated, POST, cart, etc.)
Route::middleware(['doNotCacheResponse'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/cart', [CartController::class, 'show']);
});
Invalidating Response Cache
use Spatie\ResponseCache\Facades\ResponseCache;
// Forget a specific URL
ResponseCache::forget('/products/1');
// Forget all cached responses for a URL pattern
ResponseCache::forget('/products/*');
// Forget everything
ResponseCache::clear();
// In a controller after updating
public function update(UpdateProductRequest $request, Product $product): JsonResponse
{
$product->update($request->validated());
// Invalidate the cached page for this product
ResponseCache::forget(route('products.show', $product));
ResponseCache::forget(route('products.index'));
return response()->json(new ProductResource($product));
}
Per-User vs Public Caching
// Custom cache profile for pages that have a public version and a user-specific version
class PublicAndAuthCacheProfile extends BaseCacheProfile
{
public function shouldCacheRequest(Request $request): bool
{
// Only cache GET requests that are successful
if (!$request->isMethod('GET')) return false;
// Don't cache requests with session cart data
if (session('cart')) return false;
return true;
}
public function cacheNameSuffix(Request $request): string
{
// Cache authenticated and anonymous separately
return $request->user() ? 'auth-' . $request->user()->id : 'public';
}
}
Layer 5: Fragment Caching
For pages that are mostly static but have a few dynamic sections, fragment caching caches the expensive parts individually.
// In a Blade template — cache expensive sections
@cache('homepage:trending-products', 1800)
<section class="trending">
@foreach ($trendingProducts as $product)
<x-product-card :product="$product" />
@endforeach
</section>
@endcache
Or in a controller using Cache::remember for specific expensive data:
public function index(): View
{
// These all cached independently
$hero = Cache::remember('homepage:hero', 3600, fn () => HeroContent::latest()->first());
$trending = Cache::remember('homepage:trending', 1800, fn () => Product::trending()->limit(8)->get());
$categories = Cache::remember('homepage:categories', 7200, fn () => Category::withCount('products')->get());
$testimonials = Cache::remember('homepage:testimonials', 86400, fn () => Testimonial::featured()->get());
return view('home', compact('hero', 'trending', 'categories', 'testimonials'));
}
Each fragment has its own TTL based on how frequently the data changes. Testimonials (rarely updated) get 24 hours. Trending products (updated every 30 minutes by a job) get 30 minutes.
The One Caching Mistake That Causes More Bugs Than Any Other
This deserves its own section because it causes production bugs that are genuinely confusing and hard to trace.
The mistake: caching data at a key, updating the data, and not invalidating the key.
The result: users see stale data. A product shows the old price. A category shows items that were removed. A user’s profile shows outdated information. The data in the database is correct. The application is serving wrong data.
What makes this particularly insidious is the time delay. You update a product’s price. For the next 60 minutes (or however long your TTL is), some users see the old price. By the time you notice, the cache entry has expired naturally, the bug is gone, and you can’t reproduce it.
The Three Patterns That Cause Cache Bugs
Pattern 1: Forgetting to invalidate on update
// ✗ Update without cache invalidation
public function update(UpdateProductRequest $request, Product $product): JsonResponse
{
$product->update($request->validated());
// Cache still holds the old product data — users see stale prices
return response()->json(new ProductResource($product));
}
// ✓ Update with cache invalidation
public function update(UpdateProductRequest $request, Product $product): JsonResponse
{
$product->update($request->validated());
Cache::forget(CacheKeys::product($product->id)); // explicit invalidation
return response()->json(new ProductResource($product->fresh()));
}
Pattern 2: Caching too broadly then forgetting scope
// ✗ Cached all users, but invalidation only clears one user's key
$allUsers = Cache::remember('users:all', 3600, fn () => User::all());
// Later: user is deleted, but 'users:all' is not invalidated
// The deleted user still appears in the 'all users' list for up to 60 minutes
Pattern 3: Race condition between cache miss and slow database query
// ✗ Two requests hit a cache miss simultaneously
// Both execute the database query
// Both try to set the cache
// This is usually harmless but wastes two database queries per miss
// ✓ Use atomic cache operations where the miss cost is high
// Laravel's Cache::remember handles this via Redis SETNX for atomic writes
// For truly high-contention keys, use a lock
$expensiveData = Cache::lock("generating:report:{$id}", 30)->get(function () use ($id) {
return Cache::remember("report:{$id}", 3600, fn () => generateExpensiveReport($id));
});
The Cache-Aside Pattern with Automatic Invalidation
The most reliable way to prevent stale cache bugs: tie cache invalidation to model events, not to the code that updates models.
// Every update to a Product automatically invalidates relevant cache
// regardless of which controller, action, job, or import caused the update
class ProductObserver
{
public function saved(Product $product): void
{
Cache::forget(CacheKeys::product($product->id));
Cache::tags(['products'])->flush();
// Also invalidate response cache for this product's public page
if (class_exists(\Spatie\ResponseCache\Facades\ResponseCache::class)) {
\Spatie\ResponseCache\Facades\ResponseCache::forget(
route('products.show', $product)
);
}
}
}
With observer-based invalidation, you can update a product from a command, an import, a webhook, or a Telescope-triggered update — and the cache is always correctly invalidated without any code in the calling context needing to know about the cache.
Cache Configuration: Redis Setup
Redis is the right cache driver for production Laravel applications. The database and file drivers are acceptable for development but not for production under load.
Redis Configuration
# .env
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_CACHE_DB=1 # separate DB for cache vs sessions
// config/cache.php
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
'lock_connection' => 'default',
],
// config/database.php — separate Redis connection for cache
'redis' => [
'cache' => [
'host' => env('REDIS_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 1), // different DB from sessions
],
]
Redis Memory Eviction Policy
For a cache server, configure Redis to evict the least-recently-used keys when memory is full, rather than returning errors:
# In redis.conf or via redis-cli
maxmemory 256mb
maxmemory-policy allkeys-lru
# Or via redis-cli:
redis-cli config set maxmemory-policy allkeys-lru
With allkeys-lru, Redis automatically evicts the least-recently-used cache entries when memory is full. Your application continues to work — cache misses just result in database queries rather than errors.
Cache Monitoring
Checking Cache Hit Rate
// A simple hit/miss counter (useful for diagnosing cache effectiveness)
class CacheMonitor
{
public static function remember(string $key, int $ttl, callable $callback): mixed
{
if (Cache::has($key)) {
// Track hit
Cache::increment('cache:hits:' . date('Y-m-d-H'), 1);
return Cache::get($key);
}
// Track miss
Cache::increment('cache:misses:' . date('Y-m-d-H'), 1);
return Cache::remember($key, $ttl, $callback);
}
public static function hitRate(): float
{
$hits = (int) Cache::get('cache:hits:' . date('Y-m-d-H'), 0);
$misses = (int) Cache::get('cache:misses:' . date('Y-m-d-H'), 0);
$total = $hits + $misses;
return $total > 0 ? round(($hits / $total) * 100, 1) : 0;
}
}
Artisan Cache Commands Reference
# ── Application caches ─────────────────────────────────────────────
php artisan cache:clear # clear all application cache
php artisan cache:forget key_name # forget a specific key
# ── Framework caches ──────────────────────────────────────────────
php artisan route:cache # compile route cache
php artisan route:clear # clear route cache
php artisan config:cache # compile config cache
php artisan config:clear # clear config cache
php artisan view:cache # compile view cache
php artisan view:clear # clear view cache
php artisan event:cache # cache event listeners
php artisan event:clear # clear event cache
# ── All at once ────────────────────────────────────────────────────
php artisan optimize # route + config + view cache
php artisan optimize:clear # clears all above caches
# ── Viewing cache contents ─────────────────────────────────────────
php artisan tinker
>>> Cache::get('products:1')
>>> Cache::has('products:1')
>>> Cache::forget('products:1')
The Cache Warming Strategy
Cold start after deployment: your cache is empty, every request is a cache miss, and all load hits the database simultaneously. On a traffic spike, this can be catastrophic.
Cache warming pre-populates the cache after deployment, before traffic arrives:
// app/Console/Commands/WarmCache.php
class WarmCache extends Command
{
protected $signature = 'cache:warm';
protected $description = 'Pre-populate the cache after deployment';
public function handle(
ProductCacheService $products,
CategoryCacheService $categories,
): void {
$this->info('Warming cache...');
// Warm high-traffic pages
$this->withProgressBar(Category::active()->get(), function (Category $category) use ($products) {
$products->getByCategory($category->slug);
});
// Warm global settings
Cache::rememberForever('settings:global', fn () =>
Setting::pluck('value', 'key')->all()
);
// Warm navigation data
Cache::remember('navigation:main', 86400, fn () =>
Category::whereNull('parent_id')->with('children')->get()
);
$this->info('Cache warmed successfully.');
}
}
# In your deploy script, after php artisan optimize:
php artisan cache:warm
The Complete Caching Checklist
Framework caches (run on every deploy):
✓ php artisan config:cache — all env() calls go through config files
✓ php artisan route:cache — no closure routes in production
✓ php artisan view:cache — pre-compiled Blade templates
✓ php artisan event:cache — cached event-listener mappings
Redis configuration:
✓ CACHE_DRIVER=redis in production
✓ Separate Redis DB for cache (REDIS_CACHE_DB=1)
✓ maxmemory-policy=allkeys-lru configured in Redis
✓ Redis persistence appropriate for your needs (RDB vs AOF)
Application caching:
✓ Frequently read, infrequently changed data uses Cache::remember()
✓ Consistent cache key naming convention defined and documented
✓ CacheKeys class or similar centralises key generation
✓ No env() called directly in application code (only in config files)
Cache invalidation:
✓ Model observers handle cache invalidation automatically
✓ Cache::forget() called on every model update path
✓ Tag-based cache used for related data groups (requires Redis)
✓ No "invalidate everything" patterns — targeted invalidation only
Full-page caching:
✓ Response cache installed for public, non-personalised pages
✓ Authenticated routes have doNotCacheResponse middleware
✓ Response cache invalidated in model observers or after updates
✓ X-Response-Cache header checked to verify cache is being hit
Testing and monitoring:
✓ Cache::fake() used in tests (never real cache in unit tests)
✓ Feature tests verify invalidation — stale data bugs caught in CI
✓ Cache hit rate monitored — low hit rate indicates poor key design
✓ Redis memory monitored — approach to maxmemory triggers alerts
Deployment:
✓ php artisan optimize:clear run before rebuilding caches
✓ php artisan cache:warm runs after deployment
✓ Cache is rebuilt on every deployment, not left from previous deploy
Final Thoughts
Caching is not one feature — it’s a stack of features that operate at different levels of the request lifecycle. Framework-level caches save milliseconds on every request with zero application code changes. Query caches save tens to hundreds of milliseconds on database-heavy pages. Full-page response caches serve entire pages from memory in under a millisecond.
The difference between an application that struggles at 10,000 requests per hour and one that handles 100,000 is rarely the database schema or the server size. It’s usually the caching strategy.
But caching is also the feature most likely to introduce subtle bugs. The antidote to stale cache bugs is not avoiding cache — it’s coupling invalidation to the data lifecycle via observers and events, so that every code path that changes data automatically invalidates the relevant cache entries.
Build the cache warming strategy before your first traffic spike, not during it. Add the observer-based invalidation before you have stale data reports from users, not after. The infrastructure investment is small. The operational benefit is large.
Cache everything that’s safe to cache. Invalidate everything that changes. Monitor everything that should be hitting the cache. That’s the complete picture.
