Programming Intermediate 10 min

How to Cache Expensive Database Queries in Laravel

Every database round-trip has a cost. For data that rarely changes — settings, categories, popular posts, navigation menus — running the same query on every request is pure waste. The cache layer lets you pay that cost once and serve the result from memory for every subsequent request.

Laravel's cache API is driver-agnostic: the same code works whether you use file, database, or Redis. This guide covers the practical patterns: when to cache, how to key it, and how to keep the cache consistent when data changes.

Step-by-step

  1. 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 users lookup by primary key is already fast. A multi-join report with GROUP BY across millions of rows is a prime candidate.

  2. 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.

    php
    use 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. 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.qualifier or noun: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. 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. 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. 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. 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. 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.

#Laravel #Cache #Performance
Back to all guides

Need Help With Your Project?

Book a free 30-minute consultation to discuss your technical challenges and explore solutions together.