DEV Community

Cover image for When Laravel storage cache is enough, and when it isn't
Saqueib Ansari
Saqueib Ansari

Posted on • Originally published at qcode.in

When Laravel storage cache is enough, and when it isn't

Laravel’s storage-backed cache is the kind of feature teams either ignore or misuse. Ignore it, and they reach for Redis too early. Misuse it, and they blame the filesystem for problems that were really caused by sloppy keys, bad invalidation, and deploys that reset warm state.

My default take is simple: if your app is not yet a true distributed system, storage cache is often the right first cache. It is cheap, durable enough for the right topology, and operationally boring in a good way. But it only works well if you treat it like a deliberate subsystem instead of sprinkling Cache::remember() calls around your controllers and hoping the latency graph goes down.

This is where Laravel developers tend to get it wrong. The backend choice matters less than the cache contract. If your keys are vague, your invalidation is hand-wavy, and your deployment model quietly destroys local state, Redis will not save you. You will just have a more expensive mess.

Laravel’s cache API makes backend switching easy. That is useful, but it also hides an important fact: not every cache store fails the same way. A storage-backed cache has different strengths and different traps. If you understand those clearly, you can get a lot of value out of it before you need a networked cache layer.

Start with the deployment shape, not the API

The first question is not whether filesystem caching is “fast enough.” The first question is whether your deployment shape makes the cache coherent.

If your Laravel app runs on a single VPS, a single bare-metal box, or one persistent container with a stable writable volume, storage cache is usually a perfectly rational default. Reads and writes stay local, cold starts are manageable, and the cache survives process restarts because it lives on disk rather than inside PHP worker memory.

That durability is the underrated part. Teams often compare file-backed cache to Redis as if the only dimension is speed. In practice, durability and operational cost matter too. A cache that survives app restarts can be exactly what you want for expensive derived state like rendered fragments, feed payloads, feature matrices, or precomputed dashboard slices.

Where things start to break is multi-node deployment.

If you have three app servers behind a load balancer and each one writes to its own local disk, you do not have one cache. You have three unrelated caches with the same API. That might still be acceptable for node-local acceleration, but you need to admit what it is. A request landing on node A may see a warm cache while node B is cold. If your application behavior assumes a shared view of cached state, that setup is already wrong.

A shared network volume sounds like the obvious fix, but it comes with its own tradeoff: consistency improves, latency often gets worse, and lock behavior becomes more sensitive to storage performance. That does not automatically kill the approach, but it means your benchmark needs to reflect reality, not localhost optimism.

The practical decision matrix looks like this:

  • Single server or single persistent app node: storage cache is a strong default.
  • Multiple nodes with shared durable storage: viable, but benchmark real IO and lock contention.
  • Multiple nodes with per-node local disks: only use it if inconsistent warm state is acceptable.
  • Ephemeral containers or serverless-style rollouts: skip it for shared application cache.

That is the first hard rule: topology determines whether storage cache is a system or an illusion.

What storage cache is actually good at

Storage-backed caching shines when the cached value is expensive relative to the cost of a disk read, but not so hot that memory-only speed is mandatory.

That includes a lot of real Laravel workloads:

  • paginated public content queries
  • rendered HTML fragments for marketing or blog pages
  • computed API responses that combine several database queries
  • derived settings snapshots used across requests
  • expensive “shape once, serve many times” data for dashboards

It is a bad fit for coordination-heavy workloads, high-churn ephemeral data, or systems where cache latency itself is on the hot path for every request under significant concurrency.

The common mistake is treating the storage cache as a poor man’s Redis instead of treating it as a cheap durable cache for stable derived data.

That distinction changes how you design around it.

For example, if you are caching the homepage feed for 15 minutes and invalidating on publish events, file-backed storage can work very well. If you are caching a constantly mutating per-user state blob that gets touched across many workers, it is the wrong backend and probably the wrong cache shape too.

Laravel’s official cache documentation is still the reference point for the API surface and driver capabilities: https://laravel.com/docs/12.x/cache. The abstraction is stable enough that the higher-level design advice matters more than memorizing individual method names.

Cache keys should be designed, not improvised

Most cache systems become unreliable because the keys were invented opportunistically.

A key like this is a red flag:

Cache::remember('posts', 900, fn () => Post::latest()->take(10)->get());
Enter fullscreen mode Exit fullscreen mode

That key tells you almost nothing. Which posts? Public only? Locale-specific? Tenant-specific? Are drafts excluded? Is this the homepage widget or an admin panel query? If someone later adds category filtering or per-tenant visibility, the key becomes silently wrong.

A good key should describe three things clearly:

  1. The thing being cached
  2. The scope that shapes the value
  3. The version boundary that invalidates it

That usually means namespacing your keys more aggressively than most teams do.

$key = sprintf(
    'blog:index:v%d:tenant:%s:locale:%s:page:%d',
    $version,
    $tenantId,
    app()->getLocale(),
    $page,
);

$posts = Cache::store('file')->remember($key, now()->addMinutes(15), function () use ($page) {
    return Post::query()
        ->published()
        ->latest('published_at')
        ->paginate(12, page: $page);
});
Enter fullscreen mode Exit fullscreen mode

That key is longer, and that is good. Short keys are not a badge of engineering elegance. If a longer key makes the cache contract obvious, the extra characters are cheap.

Build keys centrally

Do not scatter stringly-typed cache keys across controllers, Livewire components, jobs, console commands, and observers. Centralize them in a small dedicated layer.

<?php

namespace App\Support\CacheKeys;

final class BlogCacheKeys
{
    public static function index(int $tenantId, string $locale, int $page, int $version): string
    {
        return "blog:index:v{$version}:tenant:{$tenantId}:locale:{$locale}:page:{$page}";
    }

    public static function post(int $tenantId, string $locale, string $slug, int $version): string
    {
        return "blog:post:v{$version}:tenant:{$tenantId}:locale:{$locale}:slug:{$slug}";
    }

    public static function version(int $tenantId): string
    {
        return "blog:version:tenant:{$tenantId}";
    }
}
Enter fullscreen mode Exit fullscreen mode

This class is not “architecture astronaut” work. It is basic hygiene. It gives your team one place to reason about naming, scope, and invalidation boundaries.

Avoid serializing accidental complexity

Another failure mode is caching giant Eloquent collections or model graphs just because Laravel makes it easy.

You usually want to cache the smallest stable representation that solves the read problem. In many cases that means arrays, DTO-like payloads, or view models, not raw model objects with lazy relationships waiting to surprise you later.

Bad pattern:

return Cache::remember($key, 3600, fn () => User::with(['roles', 'permissions', 'teams'])->findOrFail($id));
Enter fullscreen mode Exit fullscreen mode

Better pattern:

return Cache::remember($key, now()->addHour(), function () use ($id) {
    $user = User::query()
        ->with(['roles:id,name', 'teams:id,name'])
        ->findOrFail($id);

    return [
        'id' => $user->id,
        'name' => $user->name,
        'roles' => $user->roles->pluck('name')->all(),
        'teams' => $user->teams->pluck('name')->all(),
    ];
});
Enter fullscreen mode Exit fullscreen mode

Smaller payloads reduce file size, serialization overhead, and downstream surprises when the shape of your Eloquent graph evolves.

Invalidation is where the real engineering lives

The backend is rarely the hardest part. Invalidation is the system.

If your cache invalidation strategy is “flush it when things get weird,” you do not have a cache strategy. You have an outage ritual.

Storage cache makes this more obvious because broad flushes are painful. They wipe durable warm state and can trigger a burst of recomputation immediately after a deploy, a content publish, or an admin action.

The clean pattern for many Laravel applications is versioned namespacing.

Instead of trying to track every concrete key and forget them one by one, keep a small version key for each logical slice of data. When the underlying state changes, bump the version. New reads automatically use fresh keys. Old values remain harmless until TTL expiry or manual cleanup.

<?php

namespace App\Support\CacheVersioning;

use Illuminate\Support\Facades\Cache;
use App\Support\CacheKeys\BlogCacheKeys;

final class BlogCacheVersion
{
    public static function current(int $tenantId): int
    {
        return Cache::store('file')->get(BlogCacheKeys::version($tenantId), 1);
    }

    public static function bump(int $tenantId): int
    {
        $next = self::current($tenantId) + 1;

        Cache::store('file')->forever(BlogCacheKeys::version($tenantId), $next);

        return $next;
    }
}
Enter fullscreen mode Exit fullscreen mode

That gives you a stable invalidation lever without global cache destruction.

Put invalidation next to state changes

Do not invalidate in controllers unless the controller is genuinely the only place the state changes. In most apps, it is not.

State changes happen through:

  • admin forms
  • queued jobs
  • Artisan commands
  • model factories in back-office flows
  • webhooks
  • import pipelines

If invalidation lives only in HTTP handlers, it will drift out of sync with reality.

Model observers or domain events are usually the better location.

<?php

namespace App\Observers;

use App\Models\Post;
use App\Support\CacheVersioning\BlogCacheVersion;

final class PostObserver
{
    public function saved(Post $post): void
    {
        if ($post->wasChanged(['title', 'slug', 'status', 'published_at'])) {
            BlogCacheVersion::bump($post->tenant_id);
        }
    }

    public function deleted(Post $post): void
    {
        BlogCacheVersion::bump($post->tenant_id);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is much more reliable than chasing exact keys from five different parts of the codebase.

TTL is not a substitute for invalidation

A lot of developers use a 10-minute TTL as a way to avoid thinking. That is lazy and usually wrong.

TTL should match the volatility of the underlying data and the acceptable staleness window for readers.

Examples:

  • dashboard metrics that can be slightly stale: 30 to 120 seconds
  • content indexes that update a few times per day: 10 to 30 minutes with explicit version bumps
  • reference configuration derived from several tables: hours or effectively forever with event-driven invalidation
  • expensive report snapshots: long TTL plus manual refresh control

If the true correctness boundary is “refresh when a post is published,” then the answer is not “maybe 15 minutes is fine.” The answer is explicit invalidation on publish.

Prevent stampedes and deployment-time self-sabotage

Once a cache starts working, the next problem is usually concurrency.

One hot key expires. Several workers miss simultaneously. Everyone recomputes the same expensive value. The database gets hit harder precisely when the cache was supposed to protect it.

Laravel’s lock support matters here, even for storage-backed caching. The framework documents atomic locks for supported stores, and for expensive read paths you should use them instead of assuming remember() alone is enough.

use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;

function getAccountSummary(int $accountId): array
{
    $key = "accounts:summary:v1:id:{$accountId}";
    $store = Cache::store('file');

    if ($store->has($key)) {
        return $store->get($key);
    }

    try {
        return $store->lock("lock:{$key}", 15)->block(3, function () use ($store, $key, $accountId) {
            return $store->remember($key, now()->addMinutes(20), function () use ($accountId) {
                return app(AccountSummaryBuilder::class)->build($accountId);
            });
        });
    } catch (LockTimeoutException) {
        $staleKey = "{$key}:stale";

        if ($store->has($staleKey)) {
            return $store->get($staleKey);
        }

        $fresh = app(AccountSummaryBuilder::class)->build($accountId);

        $store->put($key, $fresh, now()->addMinutes(20));
        $store->put($staleKey, $fresh, now()->addHours(2));

        return $fresh;
    }
}
Enter fullscreen mode Exit fullscreen mode

The important idea is not the exact code. The idea is that one worker should do the expensive regeneration, and other workers should either wait briefly or get a controlled fallback.

For especially expensive values, a stale-while-revalidate pattern is often better than hard expiry. Keep a short-lived fresh key and a longer-lived stale fallback. When regeneration is contended or slow, serve the stale result briefly instead of detonating your database under load.

Deploys break more caches than traffic does

Storage-backed caching also forces you to think honestly about deployment behavior.

If your deploy process replaces containers with new writable layers, your cache durability is fake.

If your release hook clears application caches aggressively, you are training your app to cold-start under real traffic every time you ship.

If your key shape changes between releases and you did not version the namespace, you can get subtle serialization or payload mismatch bugs.

The safer deployment pattern is:

  1. Ship code that can tolerate a short overlap between old and new cached shapes.
  2. Introduce a new key version when the payload contract changes.
  3. Avoid global flushes unless you are cleaning up corruption or a truly incompatible format.
  4. Let old keys die naturally.

That is less dramatic than php artisan cache:clear, and that is exactly why it is better.

Know when to stop being cheap and move to Redis

There is no medal for stretching storage cache beyond its useful life.

At some point, Redis or another shared in-memory backend becomes the correct answer. The trick is making that move for the right reasons.

Move when the workload demands:

  • consistent shared cache across many app nodes
  • lower and more predictable latency under concurrency
  • heavier use of locks, queues, throttling, or coordination patterns
  • higher cache churn where file IO becomes noticeable
  • better operational visibility into hit rates, failures, and memory pressure

Do not move just because Redis sounds more serious. That is how teams add infrastructure without fixing the actual problem.

If your real issue is vague keys, broken invalidation, or deploy-time cache destruction, Redis gives you a faster version of the same bad design.

The better mental model is this: storage cache is the right first serious cache for a lot of Laravel applications because it keeps the system simple while forcing you to learn the parts that matter. It makes you face topology. It makes you design keys. It makes you think about invalidation and deploy behavior instead of hiding behind infrastructure.

That is valuable.

My recommendation is straightforward: use Laravel storage cache when the app is single-node or backed by genuinely shared durable storage, the cached values are stable derived data, and you have explicit invalidation rules. Switch to Redis when concurrency, coordination, or multi-node consistency becomes the real problem.

If you remember one decision rule, make it this: pick the cheapest cache backend that matches your deployment shape, then spend your engineering energy on keys, invalidation, and stampede control. That is where the wins actually come from.


Read the full post on QCode: https://qcode.in/laravel-storage-cache-patterns-cheap-durable-app-caching/

Top comments (0)