Step-by-step
-
1
Identify What Deserves Caching
Not every query should be cached. The sweet spot is data that is read frequently and changes rarely. Think site settings, navigation categories, homepage featured content, or aggregated statistics. Avoid caching anything that must be real-time — order statuses, stock levels, or per-user session data.
Also consider the query cost. A simple
userslookup by primary key is already fast. A multi-join report with GROUP BY across millions of rows is a prime candidate. -
2
Cache a Query with Cache::remember
Cache::remember()is the workhorse. It checks the cache first; if the key exists it returns the stored value, otherwise it runs the closure, stores the result, and returns it. The TTL is in seconds.phpuse Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; $categories = Cache::remember('categories.all', 3600, function () { return DB::table('categories') ->where('active', true) ->orderBy('name') ->get(); }); // Eloquent version $settings = Cache::remember('settings.all', 86400, fn () => Setting::all()->keyBy('key') ); -
3
Choose Descriptive Cache Keys
The cache key is the only thing separating two cached values. Collisions are silent and hard to debug. Follow a consistent naming pattern:
noun.qualifierornoun:id:qualifier. Always include factors that affect the result — locale, user role, applied filters.php// Bad — collides across locales Cache::remember('posts', 3600, fn () => Post::published()->get()); // Good — locale-scoped $locale = app()->getLocale(); Cache::remember("posts.featured.{$locale}", 3600, fn () => Post::published()->featured()->with('author')->get() ); // Good — user-scoped Cache::remember("user.{$user->id}.dashboard", 600, fn () => Dashboard::buildFor($user) ); -
4
Use the cache() Helper for Brevity
The
cache()global helper is a shorthand for common operations. It is identical to the facade under the hood — use whichever you prefer, but be consistent within a project.php// Remember cache()->remember('key', 3600, fn () => expensiveQuery()); // Store forever cache()->rememberForever('key', fn () => expensiveQuery()); // Get with default $value = cache('key', 'default'); // Put cache(['key' => $value], now()->addHour()); // Forget cache()->forget('key'); -
5
Invalidate via Model Observers
Stale cache is worse than no cache. When a model is saved or deleted, the related cache entries must be purged. Model observers are the cleanest place to do this — the logic lives alongside the model and fires automatically on every write path, including mass updates if you hook
updating.php<?php namespace App\Observers; use App\Models\Category; use Illuminate\Support\Facades\Cache; class CategoryObserver { public function saved(Category $category): void { Cache::forget('categories.all'); Cache::forget("categories.all.en"); Cache::forget("categories.all.ar"); } public function deleted(Category $category): void { $this->saved($category); } } // Register in AppServiceProvider or via #[ObservedBy] attribute (Laravel 10+) // Category::observe(CategoryObserver::class); -
6
Pick the Right Cache Store
Laravel supports multiple drivers. File is fine for small apps and local dev — no extra services needed. Database is useful when you cannot install Redis but need cache to survive restarts. Redis is the production standard: sub-millisecond reads, atomic operations, and built-in TTL management. Set the driver in
.env.bash# .env CACHE_STORE=redis # redis | database | file | array REDIS_HOST=127.0.0.1 REDIS_PORT=6379 # For database driver, create the table first: php artisan cache:table php artisan migrate -
7
Clear the Cache on Deploy
Deploying new code can change what a cached query should return — new columns, different relationships, changed business logic. Always clear the application cache as part of your deploy script so the first request after a deploy re-builds fresh values.
bash# In your deploy script (after git pull) php artisan cache:clear php artisan optimize php artisan view:cache -
8
Measure Before and After
Never cache blindly. Use
microtime()to confirm you are actually saving time, or enable Laravel Telescope's query panel to spot N+1 problems and slow queries before reaching for the cache. Cache hides problems — profiling finds them.php// Quick benchmark in a controller or tinker $start = microtime(true); $result = Cache::remember('expensive.query', 600, fn () => HeavyReport::generate() ); $ms = round((microtime(true) - $start) * 1000, 2); logger("Cache query took {$ms}ms"); // Or use DB::listen() to log all queries during a request DB::listen(fn($q) => logger($q->sql, ['time' => $q->time . 'ms']));
Tips & gotchas
- Use <code>Cache::tags()</code> (Redis/Memcached only) to group related keys and flush them all at once — far cleaner than manually listing every key in an observer.
- Set a realistic TTL. "Forever" is almost never right for database-backed data. Even an hour of staleness is acceptable for most UI data.
- If the closure throws an exception, <code>Cache::remember()</code> will not cache the result — the error is propagated. This is the correct behavior; never swallow exceptions inside cache closures.
- For very hot keys (thousands of hits per second), consider a two-tier approach: an in-request static variable checked before hitting the cache store.
- <code>php artisan tinker</code> + <code>Cache::get('key')</code> is the fastest way to inspect what is actually stored and confirm invalidation worked.
Wrapping up
Effective caching is about choosing the right data, keying it precisely, and keeping it fresh. Get those three things right and you can cut database load dramatically without introducing subtle bugs.