DEV Community

A0mineTV
A0mineTV

Posted on

Laravel Caching with Redis: 3 Levels to Kill Duplicate Queries

TL;DR — Most of the time, Laravel isn't "slow"; we are simply recomputing the same expensive stuff too often. By using a tiered caching strategy—from simple in-memory variables to robust Redis tags—you can drastically reduce database load and latency.

The Mental Model: 3 Levels of Caching

When adding cache to a Laravel app, I use a 3-level approach to identify where the performance wins are hidden.


Level 1 — Request-level cache (memory, for a single request)

Goal: Avoid doing the same work multiple times inside a single request.
This is "free" performance. It requires no Redis, no TTL, and no complex invalidation

This happens a lot when:

  • a service is called from multiple controllers/resources
  • a computed aggregate is reused in multiple parts of the response
  • a policy / transformer triggers the same query repeatedly

Quick options

  • Keep the result in a private property inside the service
  • Use a request-scoped container binding
  • Use a simple in-memory static cache

Example (simple, request-scoped):

class PricingService
{
    private ?array $cached = null;

    public function summary(): array
    {
        return $this->cached ??= $this->computeSummary();
    }

    private function computeSummary(): array
    {
        // heavy SQL / aggregation
        return [
            // ...
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

This is “free” performance: no Redis, no TTL, no invalidation — because it dies at the end of the request.


Level 2 — App-level cache (Redis)

This is where the real performance wins usually happen. If you are caching hot data, Redis is the gold standard because it provides very fast access, reduces database load, and keeps latency stable during traffic spikes.

What to cache first:

  • Dashboard aggregates: Counts, sums, and top lists.
  • Popular lists: Homepages, categories, or "top-rated" items.
  • Heavy joins: Expensive filters or complex query results.
  • External API responses: Data fetched from third-party services.

The 3 Rules of a Good Cache:

  1. What do I cache ?
  2. For how long (TTL) ?
  3. How do I invalidate ?

A Practical Redis Pattern in Laravel

To reach production-grade reliability, use this pattern:

  • Explicit Redis store: Be intentional about where your data goes.
  • Versioned keys (v1): Protects your app during deploys if the data schema changes.
  • Tags: Essential for clean invalidation (e.g., clearing all "stats" at once).
  • Atomic Locks: Prevents a cache stampede (when multiple simultaneous requests try to rebuild the same expired cache at the same time).
use Illuminate\Support\Facades\Cache;
use App\Models\Restaurant;

class RestaurantService
{
    public function topParis(): array
    {
        $cache = Cache::store('redis');

        // Prevent cache stampede (Redis-only)
        return $cache->lock('lock:top:paris', 5)->block(2, function () use ($cache) {
            return $cache->tags(['restaurants'])->remember(
                'top:paris:v1',          // versioned key
                now()->addMinutes(10),   // TTL
                fn () => Restaurant::query()
                    ->where('city', 'Paris')
                    ->orderByDesc('rating')
                    ->limit(30)
                    ->get(['id', 'name', 'rating'])
                    ->toArray()
            );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Invalidation (the part that actually matters)

Invalidate on the domain change, not randomly:

  • model saved/deleted
  • admin action changes featured content
  • background job imports new data
use Illuminate\Support\Facades\Cache;
use App\Models\Restaurant;

// Simple invalidation (Observer / Event / Admin action)
Restaurant::saved(fn () => Cache::store('redis')->tags(['restaurants'])->flush());
Restaurant::deleted(fn () => Cache::store('redis')->tags(['restaurants'])->flush());
Enter fullscreen mode Exit fullscreen mode

If you don’t use tags: call Cache::store('redis')->forget('top:paris:v1') instead.


Redis setup note (Laravel)

Make sure your cache driver/store points to Redis.

In your .env:

CACHE_STORE=redis
# or (older apps)
CACHE_DRIVER=redis
Enter fullscreen mode Exit fullscreen mode

And ensure your config/cache.php includes a redis store (it usually does by default in Laravel).


Level 3 — Infra-level cache (HTTP / reverse proxy / CDN)

When a page or an API response is not highly personalized, you can achieve massive performance gains by moving the cache further away from your application server.

This level targets:

  • Browser cache: Storing data directly on the user's device.
  • Reverse proxy cache: Tools like Nginx or Varnish that serve the request before it even reaches PHP [1].
  • CDN cache: Distributing your content globally at the edge (Cloudflare, CloudFront, etc.).

Why it matters:
By caching at the infrastructure level, you significantly reduce the pressure on both your Redis instance and your MySQL database [2]. If the edge server already has the response, your application doesn't even have to boot Laravel, which is the highest ROI performance optimization you can implement.

Minimal Example: Cache headers for a public endpoint

You can trigger this caching by simply adding the correct Cache-Control headers to your Laravel response:

return response()->json($data)
    ->setPublic()
    ->setMaxAge(60)
    ->setSharedMaxAge(300);
Enter fullscreen mode Exit fullscreen mode

If your response can be cached at the edge, it’s sometimes the highest ROI performance optimization.


Mini checklist: “What should I cache first?”

1) Identify duplicates

  • same query repeated
  • same aggregate computed multiple times 2) Request-level cache
  • remove recomputation in the same request 3) Redis app-level cache
  • cache expensive results with TTL
  • use versioned keys
  • plan invalidation (events/observers)
  • add a lock for hot keys (stampede protection) 4) Infra-level
  • add HTTP caching where personalization is low
  • consider CDN/reverse proxy for public content

Common pitfalls (learned the hard way)

  • Caching without invalidation → “stale data” bugs
  • No versioned keys → painful deploys when cached schema changes
  • Caching too early → optimize the obvious hotspots first (measure!)
  • Huge payloads in cache → cache only what you need (select columns, small arrays)

Closing

Caching is not “add Redis and done”.

It’s a design decision around what, how long, and how to invalidate.

If you want, I can share a short “Laravel caching playbook” with real-world examples:

  • list pages
  • dashboards
  • external APIs
  • multi-tenant cases

Thanks for reading 👋

Top comments (0)