Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Caching Strategies

Cache::remember, tags and invalidation, config/route caching and what to cache.

Caching means doing expensive work once and reusing the answer. Done well, it's the cheapest performance win you'll get. Done badly, users see stale or wrong data. This lesson covers the patterns you'll use most, all built around one real cached endpoint in this app.

Try it: a cached dashboard

GET /api/demo/stats returns dashboard numbers for the Projects & Tasks data. Producing them takes eight SQL queries, and the result is then cached for 60 seconds. Send a few requests and watch the cache, took_ms and query count change. Refresh forgets the key first, so the next request is a miss.

The first request is a miss: eight queries, a few milliseconds. Every request in the next minute is a hit: zero queries, a fraction of a millisecond. On a busy dashboard, that difference is your database load.

Cache-aside with Cache::remember()

The pattern behind nearly all application caching is cache-aside: look in the cache, and on a miss compute the value, store it and return it. Cache::remember() does all three in one call:

Request remember(key…) Cache store file / redis / db HIT → return stored value 0 queries, < 1 ms MISS → run closure query DB, store for TTL, return

This is the real service behind the demo:

app/Services/ProjectStats.php

class ProjectStats
{
    public const CACHE_KEY = 'demo.project-stats';

    public const TTL_SECONDS = 60;

    /**
     * @return array{data: array<string, mixed>, cache: string, computed_at: string}
     */
    public function get(): array
    {
        $hit = true;

        $payload = Cache::remember(self::CACHE_KEY, self::TTL_SECONDS, function () use (&$hit): array {
            $hit = false;

            return [
                'data' => $this->compute(),
                'computed_at' => now()->toIso8601String(),
            ];
        });

        return $payload + ['cache' => $hit ? 'hit' : 'miss'];
    }

    public function forget(): void
    {
        Cache::forget(self::CACHE_KEY);
    }

The closure only runs on a miss, so setting $hit = false inside it tells us which path ran. Real code doesn't usually need that flag; it's there so the demo can show you. computed_at is stored with the value, so every hit reports when the data was really calculated.

app/Http/Controllers/Api/ProjectStatsController.php

public function __invoke(Request $request, ProjectStats $stats): JsonResponse
{
    if ($request->boolean('refresh')) {
        $stats->forget();
    }

    $startedAt = microtime(true);
    $result = $stats->get();

    return response()->json($result + [
        'took_ms' => round((microtime(true) - $startedAt) * 1000, 2),
    ]);
}

The Cache API you'll actually use

Cache::get('key', 'default');                 // read, with a fallback
Cache::put('key', $value, now()->addHour());   // write with an expiry
Cache::add('key', $value, 60);                 // write only if missing (returns false otherwise)
Cache::forever('settings', $settings);         // no expiry: you must forget() it yourself
Cache::forget('key');                          // delete
Cache::has('key');
Cache::increment('page-views');                // atomic counters
Cache::pull('one-time-code');                  // read and delete

Cache::remember('key', 600, fn () => expensive());
Cache::flexible('key', [60, 300], fn () => expensive()); // stale-while-revalidate

The TTL is in seconds when you pass an int, or you can pass a DateTime/Carbon instance.

Cache::flexible() serves the cached value as fresh for 60 seconds. Between 60 and 300 seconds it still serves the stale value instantly and refreshes it in the background after the response is sent. Users never wait for a recompute.

Choosing a cache store

The store is set by CACHE_STORE in .env, so code stays the same everywhere:

Store Notes
file Simple single-server option, and what this site sets in production (CACHE_STORE=file). No extra software, but no tags
database The framework default in a fresh Laravel 13 app. Works anywhere with a cache table; fine for modest traffic
redis / memcached Fast, shared between servers, supports tags. The usual choice at scale
array Lives for one request only. Used in tests (this app's phpunit.xml forces it)

If several apps share one Redis, give each a different CACHE_PREFIX so their keys can't collide.

What not to put in the cache: objects

Cache values are serialized. Laravel 13's config/cache.php ships with 'serializable_classes' => false, which means objects can't be unserialized from the cache. That protects you if your APP_KEY ever leaks, by blocking "gadget chain" attacks. Building this lesson's endpoint hit that rule:

"by_priority": {"__PHP_Incomplete_Class_Name": "Illuminate\\Support\\Collection"}

On a hit, the cached Collection came back as an incomplete class. The tests had passed because they use the array store, which never serializes. The fix is to cache plain arrays. Note the ->all() call at the end:

app/Services/ProjectStats.php

/**
 * The expensive part: several aggregate queries. Returns plain arrays
 * only — config/cache.php sets serializable_classes to false, so cached
 * objects such as Collections would come back as incomplete classes.
 *
 * @return array<string, mixed>
 */
private function compute(): array
{
    return [
        'projects' => Project::count(),
        'tasks' => Task::count(),
        'open_tasks' => Task::open()->count(),
        'completed_tasks' => Task::completed()->count(),
        'by_priority' => collect(TaskPriority::cases())
            ->mapWithKeys(fn (TaskPriority $priority): array => [$priority->value => Task::open()->priority($priority)->count()])
            ->all(),
        'busiest_project' => Project::withCount(['tasks' => fn ($query) => $query->open()])
            ->orderByDesc('tasks_count')
            ->first()
            ?->only(['name', 'slug', 'tasks_count']),
    ];
}

A regression test now proves the cached payload contains no objects, whichever store runs the tests:

tests/Feature/Demo/DemoExtrasApiTest.php (excerpt)

public function test_cached_stats_contain_no_objects(): void
{
    // Production cache stores refuse to unserialize objects (serializable_classes = false)
    app(ProjectStats::class)->get();

    $cached = cache()->get(ProjectStats::CACHE_KEY);

    array_walk_recursive($cached, fn ($value) => $this->assertFalse(is_object($value)));
    $this->assertSame(unserialize(serialize($cached), ['allowed_classes' => false]), $cached);
}

The same applies to Eloquent models. Cache $model->toArray() or the ids, not the model itself. If you really need objects back, list the safe classes in serializable_classes.

Invalidation: keeping cached data correct

There are three common strategies, from simplest to most precise:

  1. Short TTLs. Accept up to n seconds of staleness. Our dashboard does this, and for counts on a dashboard, a minute old is fine.

  2. Forget on write. Clear the key whenever the underlying data changes, so the next read rebuilds it. Model events make this automatic. Illustrative:

    class Task extends Model
    {
        protected static function booted(): void
        {
            static::saved(fn () => Cache::forget(ProjectStats::CACHE_KEY));
            static::deleted(fn () => Cache::forget(ProjectStats::CACHE_KEY));
        }
    }
    
  3. Versioned keys. Put something that changes into the key itself, so old entries are simply never asked for again. This site's tutorial pages do it: the rendered Markdown is cached under a key that includes the file's modification time.

    app/Services/TutorialRepository.php

    // Keyed on mtime so editing a lesson invalidates its cached HTML
    return Cache::remember(
        "tutorials.{$track}.{$slug}.".filemtime($path),
        now()->addDay(),
        fn () => $this->renderFile($path),
    );
    

On Redis or Memcached you can also use tags to clear a group of keys at once: Cache::tags(['projects'])->flush(). The file and database stores don't support tags.

Stopping a stampede with locks

When a popular key expires, a hundred simultaneous requests can all miss and all run the expensive query together. An atomic lock lets one request rebuild while the others wait. Illustrative:

$stats = Cache::get('stats') ?? Cache::lock('stats:rebuild', 10)->block(5, function () {
    // Re-check inside the lock: another request may have just rebuilt it
    return Cache::remember('stats', 300, fn () => $this->compute());
});

Cache::flexible() avoids most stampedes on its own, because stale values keep being served while one background refresh runs.

Framework caches for deployment

Separate from your data cache, Laravel can cache its own bootstrapping work. This site's deploy script runs the first three on every release:

php artisan config:cache   # merge every config file into one PHP file
php artisan route:cache    # compile routes (closures cache fine since Laravel 8; controllers are still the convention)
php artisan view:cache     # precompile all Blade templates
php artisan event:cache    # discover listeners once
# or all at once:
php artisan optimize

After config:cache, env() returns null outside config files. Always read settings through config(). That's why this app's admin email lives in config/admin.php and the code calls config('admin.email'), never env('ADMIN_EMAIL').

Key takeaways

  • Cache::remember(key, ttl, closure) is cache-aside in one line. The closure runs only on a miss.
  • Cache plain arrays, not objects. Laravel 13 refuses to unserialize classes from the cache by default.
  • Pick an invalidation strategy on purpose: short TTLs, forget on write (model events), or versioned keys.
  • Use Cache::flexible() or atomic locks to avoid stampedes when a popular key expires.
  • In production, cache config, routes and views (php artisan optimize does all of them), and read settings with config() rather than env().