Laravel Caching: Making Your App Fly
Database queries are expensive. API calls are slow. Learn to cache the expensive stuff — with Redis, Memcached, or just files. Cache tags, cache invalidation, and the patterns that made my Laravel apps actually fast.
The Slow Dashboard Problem
My admin dashboard loaded in 8 seconds. Every page load ran:
- 15 database queries for stats
- 3 API calls to external services
- Complex aggregations on millions of rows
Users complained. I profiled. The numbers didn’t lie — most time was spent waiting for data that rarely changed.
Solution? Don’t fetch it every time. Cache it.
The Cache Facade
use Illuminate\Support\Facades\Cache;
// Store a value for 60 minutes
Cache::put('key', 'value', 60);
// Store forever
Cache::forever('key', 'value');
// Get a value
$value = Cache::get('key');
// Get with default
$value = Cache::get('key', 'default');
// Check existence
if (Cache::has('key')) {
// ...
}
// Delete
Cache::forget('key');
// Delete multiple
Cache::forget(['key1', 'key2']);
// Clear all cache
Cache::flush();
The Magic Method: remember()
This is the pattern I use 90% of the time:
$users = Cache::remember('all_users', 60, function () {
return User::all();
});
This does:
- Check if
all_usersexists in cache - If yes, return the cached value
- If no, run the closure, cache the result for 60 minutes, return it
One line replaces the check-fetch-store dance.
Forever Version
$settings = Cache::rememberForever('site_settings', function () {
return Setting::all()->pluck('value', 'key');
});
Cache until manually cleared. Good for data that rarely changes.
Cache Drivers
Configure in config/cache.php:
'default' => env('CACHE_DRIVER', 'file'),
'stores' => [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
],
'redis' => [
'driver' => 'redis',
'connection' => 'cache',
],
'memcached' => [
'driver' => 'memcached',
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'array' => [
'driver' => 'array', // For testing, doesn't persist
],
],
Development: file driver is fine.
Production: redis or memcached for speed.
Switch with one env variable:
CACHE_DRIVER=redis
Using Redis
Install Redis and the PHP extension:
sudo apt install redis-server
composer require predis/predis
Configure in .env:
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
Now all Cache:: calls use Redis. Faster than file, shared across servers.
Practical Caching Patterns
Cache Database Queries
// Without caching - runs query every time
public function index()
{
$posts = Post::with('user', 'tags')
->published()
->latest()
->paginate(20);
return view('posts.index', compact('posts'));
}
// With caching - query runs once per 10 minutes
public function index()
{
$page = request('page', 1);
$posts = Cache::remember("posts_page_{$page}", 10, function () use ($page) {
return Post::with('user', 'tags')
->published()
->latest()
->paginate(20);
});
return view('posts.index', compact('posts'));
}
Cache API Calls
public function getExchangeRates()
{
return Cache::remember('exchange_rates', 60, function () {
// Only calls API once per hour
$response = Http::get('https://api.exchangerate.io/latest');
return $response->json();
});
}
Cache Expensive Computations
public function getMonthlyReport($month)
{
return Cache::remember("report_{$month}", 1440, function () use ($month) {
// Complex aggregation that takes 30 seconds
return Order::whereMonth('created_at', $month)
->selectRaw('DATE(created_at) as date, SUM(total) as revenue, COUNT(*) as orders')
->groupBy('date')
->get();
});
}
Cache User-Specific Data
public function getDashboard()
{
$userId = auth()->id();
$stats = Cache::remember("user_{$userId}_stats", 5, function () use ($userId) {
return [
'orders_count' => Order::where('user_id', $userId)->count(),
'total_spent' => Order::where('user_id', $userId)->sum('total'),
'pending_orders' => Order::where('user_id', $userId)->pending()->count(),
];
});
return view('dashboard', compact('stats'));
}
Cache Invalidation
“There are only two hard things in Computer Science: cache invalidation and naming things.”
Manual Invalidation
// When a post is updated
public function update(Request $request, Post $post)
{
$post->update($request->validated());
// Clear related caches
Cache::forget('posts_page_1');
Cache::forget("post_{$post->id}");
Cache::forget('recent_posts');
return redirect()->back();
}
Using Model Observers
// app/Observers/PostObserver.php
class PostObserver
{
public function saved(Post $post)
{
Cache::forget("post_{$post->id}");
$this->clearPostListCaches();
}
public function deleted(Post $post)
{
Cache::forget("post_{$post->id}");
$this->clearPostListCaches();
}
private function clearPostListCaches()
{
// Clear first 10 pages
for ($i = 1; $i <= 10; $i++) {
Cache::forget("posts_page_{$i}");
}
Cache::forget('recent_posts');
Cache::forget('popular_posts');
}
}
Cache Tags (Redis/Memcached only)
Tags let you group related cache items:
// Store with tags
Cache::tags(['posts', 'users'])->put("post_{$id}", $post, 60);
Cache::tags(['posts'])->put('recent_posts', $recent, 60);
Cache::tags(['users'])->put("user_{$id}", $user, 60);
// Flush all caches with a tag
Cache::tags(['posts'])->flush(); // Clears all post-related caches
Much cleaner than tracking individual keys.
class PostObserver
{
public function saved(Post $post)
{
Cache::tags(['posts'])->flush();
}
}
One line clears everything related to posts.
Cache Lock (Preventing Race Conditions)
What if two requests try to build the same cache simultaneously?
// Without lock - might run expensive query twice
$posts = Cache::remember('posts', 60, function () {
return $this->expensiveQuery();
});
// With lock - only one request runs the query
$posts = Cache::lock('posts_lock', 10)->block(5, function () {
return Cache::remember('posts', 60, function () {
return $this->expensiveQuery();
});
});
The lock ensures only one process builds the cache. Others wait.
Cache in Blade
Cache entire view fragments:
@cache('sidebar_' . auth()->id(), 60)
<div class="sidebar">
{{-- Expensive sidebar content --}}
@foreach($recommendations as $item)
<div class="recommendation">{{ $item->title }}</div>
@endforeach
</div>
@endcache
This requires the laravel-responsecache or blade-cache package, but the concept is powerful.
Artisan Cache Commands
# Clear application cache
php artisan cache:clear
# Clear config cache
php artisan config:clear
# Clear route cache
php artisan route:clear
# Clear view cache
php artisan view:clear
# Clear all caches
php artisan optimize:clear
# Cache config for production (faster boot)
php artisan config:cache
# Cache routes for production
php artisan route:cache
Cache Best Practices
1. Key Naming Convention
// Bad
Cache::put('data', $value);
Cache::put('stuff', $value);
// Good - descriptive, namespaced
Cache::put('posts:page:1', $value);
Cache::put('users:123:profile', $value);
Cache::put('api:weather:nyc:2019-07-15', $value);
2. Set Appropriate TTLs
// Very short - data changes frequently
Cache::put('active_users_count', $count, 1); // 1 minute
// Medium - data changes occasionally
Cache::put('popular_posts', $posts, 30); // 30 minutes
// Long - data rarely changes
Cache::put('site_settings', $settings, 1440); // 24 hours
// Forever - static reference data
Cache::forever('countries_list', $countries);
3. Always Have a Fallback
// Handle cache failures gracefully
try {
$data = Cache::remember('key', 60, function () {
return $this->getData();
});
} catch (\Exception $e) {
// Cache server down? Fall back to direct query
$data = $this->getData();
Log::warning('Cache unavailable', ['error' => $e->getMessage()]);
}
4. Don’t Cache Everything
Good candidates for caching:
- Database queries that run often with same parameters
- External API responses
- Expensive computations
- Aggregated data
Bad candidates:
- User-specific data that changes constantly
- Small, fast queries
- Data that must be real-time
My Dashboard Fix
Before: 8 seconds. After: 200ms.
public function dashboard()
{
return view('admin.dashboard', [
'stats' => Cache::remember('admin:dashboard:stats', 5, function () {
return [
'total_users' => User::count(),
'total_orders' => Order::count(),
'revenue_today' => Order::whereDate('created_at', today())->sum('total'),
'pending_orders' => Order::pending()->count(),
];
}),
'chart_data' => Cache::remember('admin:dashboard:chart', 60, function () {
return Order::selectRaw('DATE(created_at) as date, SUM(total) as total')
->whereBetween('created_at', [now()->subDays(30), now()])
->groupBy('date')
->get();
}),
'recent_orders' => Cache::remember('admin:dashboard:recent', 1, function () {
return Order::with('user')->latest()->take(10)->get();
}),
]);
}
Stats cache for 5 minutes. Chart caches for 1 hour. Recent orders cache for 1 minute.
What I Wish I’d Known Earlier
-
Cache::remember()is your best friend. Use it everywhere. -
Cache keys should be predictable. If you can’t easily construct the key, you can’t easily invalidate it.
-
Tags are worth the Redis dependency. Invalidation becomes trivial.
-
Profile before caching. Don’t cache things that aren’t slow.
-
Clear cache on deployment. Add
php artisan cache:clearto your deploy script.
The Journey Continues
Caching made my app fast. But there was still repetitive work — sending reminders, cleaning up old files, generating reports. Things that needed to run on a schedule.
Laravel had a beautiful solution for that too.
P.S. — The dashboard that took 8 seconds now loads in under 200ms. Users thought we’d upgraded the server. Nope — just caching. Sometimes the biggest performance wins come from NOT doing work rather than doing it faster.
Saurav Sitaula
Software Architect • Nepal