Skip to content
Laravel & PHP•15 min read•Published September 22, 2026•Updated 9/27/2026

Redis Caching Architecture for Laravel SaaS at Scale

A cache with no invalidation story isn't a performance win. It's stale data waiting to embarrass you in front of a customer.

Aqib Javaid
Aqib Javaid
Senior Full-Stack Engineer
Dark tech blog cover diagramming a Laravel API backed by a Redis cache layer and PostgreSQL, with a cache-tagging snippet

Introduction: Caching Is Easy Until It's Wrong#

Cache::remember() is one line. That's the trap. Every Laravel developer has wrapped a slow query in a remember() call, watched the response time drop from 800ms to 4ms, and moved on to the next ticket feeling like caching is a solved problem. It stays solved right up until the first time a customer updates a record and then refreshes the page to find their own change missing — because the cache key that served that page has no idea the underlying row changed, and it's cheerfully going to keep serving the old value for however long the TTL says it should. Now it's not a performance feature, it's a bug report, and the person debugging it is trying to figure out why the database says one thing and the UI says another for a value that should be identical.

That gap — between "caching makes things fast" and "caching stays correct while things change underneath it" — is where the real engineering is. It's not about learning the Cache facade's API surface, which takes about ten minutes. It's about designing keys that can't collide across tenants, invalidation that fires exactly when the underlying data changes and not a moment later, and a strategy for the exact instant a popular cache key expires under load, so ten thousand requests don't all turn into ten thousand identical database queries at once.

I've built caching layers for products where each of these failure modes had a real cost: ReplyVibe, the brand reputation SaaS I built where dashboards aggregate review data, sentiment analysis, and monthly trend analytics pulled from Google Business Profile — expensive to compute, read constantly, and wrong the moment a cached "average rating" doesn't reflect a review that just came in; SignageFlow, the digital signage platform where dozens of screens in a fleet poll for content updates continuously, and a stale cache means a screen displaying yesterday's promotion instead of today's; and SafetySpace, the AI-powered safety platform I run as CTO, where cached AI responses and form auto-completion have to be fast without ever serving one tenant's cached data to another tenant's session.

This is a breakdown of how to build a Redis caching layer in Laravel that actually holds up: correct multi-tenant key design, cache-aside as the default pattern with a real invalidation story, stampede protection for the moment a hot key expires, and the monitoring that tells you whether the cache is actually helping or just adding a layer of things that can be wrong.

Architecture: What a Production Cache Layer Actually Needs#

A cache that only has to be fast is a solved problem. A cache that has to be fast, correct, tenant-isolated, and resilient under concurrent load is four separate design decisions, and most of the production incidents I've debugged trace back to only one of the four being addressed.

1. Cache-aside is the default — and the key is the contract#

Laravel's Cache::remember() implements the cache-aside pattern: check the cache, and on a miss, run the callback, store the result, and return it. It's the right default for almost everything — read-heavy data that tolerates a short staleness window, like ReplyVibe's aggregated review analytics. The part that actually determines whether it works is the key, because the key is the only thing standing between "this cached value belongs to this exact request" and "this cached value quietly leaked into a request it doesn't belong to."

php
class ReviewAnalyticsCache
{
    public function keyFor(int $tenantId, string $metric, array $params = []): string
    {
        $paramHash = md5(json_encode($params));

        return sprintf('tenant:%d:analytics:%s:%s', $tenantId, $metric, $paramHash);
    }

    public function monthlyTrend(int $tenantId, int $year, int $month): array
    {
        $key = $this->keyFor($tenantId, 'monthly-trend', compact('year', 'month'));

        return Cache::tags(["tenant:{$tenantId}", 'analytics'])
            ->remember($key, now()->addHours(6), function () use ($tenantId, $year, $month) {
                return $this->computeMonthlyTrend($tenantId, $year, $month);
            });
    }
}

Two things are doing the actual protective work here, and neither of them is the remember() call. First, tenant:{$tenantId} is baked into the key itself, not left to whatever scoping the query inside the closure happens to apply — the same discipline I've written about for tenant-isolated storage paths: the boundary has to be structural, because a cache key with no tenant in it is one bad code path away from serving tenant 7's dashboard numbers to tenant 42. Second, the params are hashed into the key rather than trusted to stay consistent — monthly-trend for March 2026 and monthly-trend for April 2026 need to be provably different keys, not the same key with different in-memory state hoping nobody calls it with stale closures.

2. Invalidation has to be event-driven, not TTL-and-hope#

A TTL is a promise that stale data is acceptable for at most that long — it's not a substitute for invalidating the cache the moment the underlying data actually changes. Setting a six-hour TTL on analytics that tolerate staleness is fine. Setting a six-hour TTL on "does this user have access to this file" and hoping nobody's permissions change in that window is how you get a support ticket. The fix is Redis's tag support: group related cache entries under a tag, and flush the tag the instant a write happens that invalidates them.

php
class Review extends Model
{
    protected static function booted(): void
    {
        static::saved(fn (Review $review) => Cache::tags(["tenant:{$review->tenant_id}", 'analytics'])->flush());
        static::deleted(fn (Review $review) => Cache::tags(["tenant:{$review->tenant_id}", 'analytics'])->flush());
    }
}

On ReplyVibe, a new review coming in through the Google Business Profile webhook — or a sentiment reclassification running as a background job — flushes the analytics tag for that one tenant, and only that tenant. Every other tenant's cached dashboard is untouched, because tags in Redis are implemented as a set of keys per tag, and flushing one tag deletes exactly the keys registered under it, not a SCAN-and-guess across the whole keyspace. This is the difference between "the cache clears itself when it should" and "the cache clears itself on a schedule and is wrong in between."

php
class InvalidatesTenantCache
{
    public static function tags(int $tenantId, array $extra = []): array
    {
        return array_merge(["tenant:{$tenantId}"], $extra);
    }
}

// Flushing everything for one tenant — an offboarding flow, a plan downgrade
Cache::tags(InvalidatesTenantCache::tags($tenantId))->flush();

Tagging every cache write with the owning tenant, not just the specific feature tag, means a tenant offboarding or a bulk data correction can flush everything belonging to that tenant in one call — without ever touching another tenant's cached data, and without a Redis::keys('tenant:42:*') scan, which blocks the Redis event loop and is explicitly warned against in Redis's own documentation for anything beyond local debugging.

3. Cache stampede protection: the thundering herd on expiry#

Here's the failure mode that doesn't show up in development and absolutely shows up in production: a cache key backing an expensive query — ReplyVibe's cross-location sentiment rollup, say — expires at the same moment a burst of dashboard traffic hits. Every one of those requests gets a cache miss simultaneously, and every one of them independently starts running the same expensive query against the database, at the same time, because nothing coordinated them. One slow query becomes fifty concurrent slow queries, and the database — not the cache — is what falls over.

php
class StampedeSafeCache
{
    public function remember(string $key, int $seconds, \Closure $callback): mixed
    {
        $value = Cache::get($key);
        if ($value !== null) {
            return $value;
        }

        $lock = Cache::lock("lock:{$key}", 10);

        try {
            if ($lock->get()) {
                $value = Cache::get($key);
                if ($value !== null) {
                    return $value;
                }

                $value = $callback();
                Cache::put($key, $value, $seconds);
                return $value;
            }

            // another request is already computing this key — wait briefly, then re-read
            $lock->block(5);
            return Cache::get($key) ?? $callback();
        } finally {
            optional($lock)->release();
        }
    }
}

Cache::lock() uses Redis's SET NX under the hood, so only one request wins the lock and actually runs the expensive callback; every other concurrent request either blocks briefly and re-reads the now-populated cache, or, in the worst case, falls through and computes the value itself rather than hanging indefinitely. On SignageFlow, where a large fleet of screens can poll for content updates in a tight window around the same moment, this single lock is the difference between one database query per cache expiry and a query count that scales with fleet size.

A softer version that avoids the lock overhead entirely for lower-stakes data: probabilistic early expiration, where a cache read has a small, TTL-proportional chance of recomputing the value before it actually expires, spreading the recomputation load out instead of letting every consumer hit empty at the exact same second.

php
function rememberWithEarlyExpiry(string $key, int $seconds, \Closure $callback): mixed
{
    $entry = Cache::get($key . ':meta');

    if ($entry && $entry['expires_at'] > now()->timestamp) {
        $beta = 1.0;
        $delta = $entry['compute_time'] * $beta * log(mt_rand() / mt_getrandmax());
        if (now()->timestamp - $delta < $entry['expires_at']) {
            return Cache::get($key);
        }
    }

    $start = microtime(true);
    $value = $callback();
    $computeTime = microtime(true) - $start;

    Cache::put($key, $value, $seconds);
    Cache::put($key . ':meta', ['expires_at' => now()->addSeconds($seconds)->timestamp, 'compute_time' => $computeTime], $seconds);

    return $value;
}

4. Separate concerns: cache store, session store, and queue backend are not the same Redis database#

It's common to point every Redis-backed Laravel feature — cache, sessions, queues, broadcasting — at the same Redis instance and database index, because it's the path of least resistance in config/database.php. It works fine at low traffic and becomes a real operational problem the moment one of them misbehaves: a cache-flush command run against the wrong database index can wipe active user sessions, and a queue backlog competing for the same Redis memory as your cache means an eviction policy tuned for one workload actively hurts the other.

php
// config/database.php — separate logical databases, minimum
'redis' => [
    'cache' => ['host' => env('REDIS_HOST'), 'database' => 1],
    'session' => ['host' => env('REDIS_HOST'), 'database' => 2],
    'queue' => ['host' => env('REDIS_HOST'), 'database' => 3],
    'horizon' => ['host' => env('REDIS_HOST'), 'database' => 4],
],

For anything beyond a small deployment, separate Redis instances — not just database indexes on one instance — is worth the operational overhead: cache traffic on SafetySpace shouldn't be able to pressure the queue Redis that background jobs depend on to stay responsive, and an allkeys-lru eviction policy that's exactly right for a cache instance is exactly wrong for a queue instance, where evicting a job under memory pressure means silently losing work.

Step-by-Step: Building the Caching Layer#

  1. Choose the eviction policy deliberately, per Redis instance. A dedicated cache instance should run maxmemory-policy allkeys-lru (or allkeys-lfu for more accurate "actually hot" tracking) so Redis evicts the least-useful cached data under memory pressure instead of refusing writes or, worse, evicting queue jobs it should never touch:
text
# redis.conf — cache instance
maxmemory 2gb
maxmemory-policy allkeys-lfu
  1. Build tenant scoping into a single cache-key builder, not ad hoc string interpolation scattered across the codebase. Every place that writes a cache key should go through the same class:
php
class CacheKey
{
    public static function tenant(int $tenantId, string $feature, string $identifier, array $params = []): string
    {
        $suffix = $params ? ':' . md5(json_encode($params)) : '';
        return "tenant:{$tenantId}:{$feature}:{$identifier}{$suffix}";
    }
}

// usage
$key = CacheKey::tenant($tenant->id, 'dashboard', 'review-summary', ['range' => '30d']);
  1. Wrap expensive, high-traffic reads in the stampede-safe helper, not the raw remember() call — reserve plain remember() for cache entries that are cheap to recompute even if ten requests do it simultaneously, and use the lock-protected version for anything that hits the database with a non-trivial query or an external API call, the way ReplyVibe's Google Business Profile sync does.
  1. Register invalidation on the model, not the controller. Invalidation logic scattered across every controller action that might touch a cached resource is invalidation that's one missed code path away from silently going stale. A model observer or a booted() hook is the one place that's guaranteed to run regardless of which code path triggered the write:
php
class Tenant extends Model
{
    protected static function booted(): void
    {
        static::updated(function (Tenant $tenant) {
            if ($tenant->wasChanged('plan_id')) {
                Cache::tags(["tenant:{$tenant->id}"])->flush();
            }
        });
    }
}
  1. Cache HTTP responses for read-heavy, rarely-changing endpoints at the response layer, not just the query layer — for SignageFlow's screen-facing content API, where the same playlist gets requested by every screen in a location on a polling interval, caching the fully-rendered JSON response (keyed by tenant and playlist version) avoids re-running the same serialization and permission checks on every poll:
php
Route::get('/screens/{screen}/content', function (Screen $screen) {
    $key = CacheKey::tenant($screen->tenant_id, 'playlist', $screen->playlist_id, ['v' => $screen->playlist->version]);

    return Cache::remember($key, now()->addMinutes(2), fn () => new PlaylistResource($screen->playlist));
})->middleware(['auth:sanctum', 'tenant.scope']);

Note the version bump in the key rather than an invalidation event for this specific case — for a resource that's polled on a fixed interval anyway, incrementing a version number on write and letting the old version fall out of cache naturally on TTL is simpler than wiring an explicit flush, and it composes cleanly with CDN caching in front of the API if that's ever added later.

  1. Instrument hit ratio before optimizing anything further. A caching layer that isn't measured is a caching layer you're guessing about:
php
class MeasuredCache
{
    public function remember(string $key, int $seconds, \Closure $callback): mixed
    {
        $hit = Cache::has($key);
        $value = Cache::remember($key, $seconds, $callback);

        Redis::connection('cache')->incr($hit ? 'metrics:cache:hits' : 'metrics:cache:misses');

        return $value;
    }
}

Ship that ratio to whatever dashboard already tracks other production metrics. A 40% hit ratio on a key that's supposed to be hot is a signal that the TTL is too short, the key is fragmenting on a parameter that shouldn't be in it, or the data genuinely doesn't repeat enough to be worth caching at all.

Real-World Pitfalls to Avoid#

Caching a query result without caching what determines when it's wrong. A cached "unread notification count" that only gets invalidated by a cron job running every ten minutes is wrong for up to ten minutes after every notification. If the UI displays it as real-time, the cache strategy has to match — invalidate on write, not on a schedule that has nothing to do with when the data actually changed.

No stampede protection on the cache keys that matter most. The keys most worth protecting with a lock are exactly the ones teams forget to protect, because they're the ones that "have always just worked" — until traffic grows past the point where simultaneous cache misses stop being rare.

Trusting Cache::flush() in a shared Redis database. Cache::flush() clears everything in the configured cache store — every tenant, every feature, not just the one thing that needed clearing. It's fine in a single-tenant local dev environment and a production incident waiting to happen in a multi-tenant one, where the fix for "tenant 42's data looks stale" should never be a command that also clears tenant 7 through tenant 41's warm caches at the same time.

Caching permission and authorization checks with a TTL that outlives how fast permissions can change. If a role can be revoked instantly through an admin action, a cached can() check with a five-minute TTL means a revoked user keeps working for up to five minutes after being revoked. Authorization caches need invalidation tied to the actual permission-change event, not a TTL chosen for convenience.

Cache key collisions from insufficiently-specific parameters. A key like tenant:42:report that's reused across a monthly report and a weekly report — because the range parameter got left out of the key — means one report's cached output silently gets served for the other. If two calls can return different data, their keys have to be able to differ.

One Redis instance for cache, sessions, and queues, with no eviction policy chosen deliberately. The default noeviction policy means Redis starts rejecting writes once memory fills — which, depending on which Redis database that instance backs, can mean new sessions failing to save or new jobs failing to queue, which is a much worse outage than a cache slightly under-provisioned.

Key Takeaways#

A Redis caching layer earns its place in a Laravel SaaS product when it's designed as a system with a correctness story, not bolted on as a remember() call wrapped around whatever query happened to be slow in staging.

  • Cache keys carry the tenant boundary and every parameter that can change the result — never trust scoping to happen somewhere else in the call stack
  • Invalidation is event-driven, wired into model lifecycle hooks with Redis tags, not left to a TTL to eventually catch up with reality
  • Stampede protection — a distributed lock or probabilistic early expiration — is mandatory on any cache key backing an expensive query that real concurrent traffic can hit simultaneously
  • Cache, session, and queue Redis workloads are isolated by database index at minimum, by instance ideally, each with an eviction policy chosen for what it actually holds
  • Hit ratio gets measured, not assumed — a cache nobody's watching is a cache nobody actually knows is working

I've built this discipline into a brand-reputation platform whose dashboards live or die on cached analytics staying both fast and current, a signage platform where a fleet of screens depends on cache correctness to show the right content at the right moment, and an AI safety platform where cached responses have to be fast without ever crossing a tenant boundary. The queries change; the requirement that "fast" and "correct" both have to be true at the same time doesn't.

If your Laravel SaaS product is starting to feel the weight of uncached queries — or worse, living with a cache nobody fully trusts anymore — get in touch about your caching architecture or see the full case studies from platforms running these patterns in production today.

Share this technical insight with your network

Share to LinkedIn or Facebook with key takeaways, featured media, and direct links.

📁 Production Case Study

Case Study: Violerts - Enterprise NYC PropTech Compliance & Violation Monitoring SaaS

Violerts is a PropTech SaaS platform that consolidates fragmented NYC municipal property data into a single compliance intelligence platform. I led the modernization of the React frontend and Laravel backend, building multi-agency data ingestion, GIS mapping, asynchronous scraping, real-time alerts, team collaboration, and Stripe-powered SaaS billing.

Related Technical Articles

View all articles →
✦ Let's Build Together

Have a complex technical project in mind?

Available for full-stack engineering, performance audits, cloud deployments, and high-concurrency systems architecture.

Need a web or software development partner?

Tell me what you’re building, what’s getting in the way, and where you need help. Whether you need a custom web application, SaaS platform, API integration, or full-stack development, I’ll give you a clear answer on scope, cost, and timeline usually within one business day.

AqibJavaid

Senior Full-Stack Engineer building backend systems, cloud infrastructure and product platforms for teams that need them to stay up.

Available for new projects

Get in touch

© 2026 Aqib Javaid. All rights reserved.

Built and maintained by Aqib Javaid