Open navigation
Back to Articles

August 2026 · 7 min read

How I Decide What to Cache and What to Leave Alone

Every cache is a second source of truth. How I decide whether a slow Laravel query needs Redis, a schema change, or nothing at all.

Decision sequence for Laravel caching: profile the query, ask if the model is wrong, ask if the read is frequent, then cache or skip.

Caching rescued the symptom, not the schema

On a past social platform backend we over-indexed on complete database normalisation for social relations early in development. The relational structure looked clean on paper. In practice, fetching a simple user feed produced joins heavy enough to choke MySQL under ordinary load, and we ended up caching follower lists in Redis to take the pressure off.

Caching rescued the symptom and created long-term friction. What I took from it was not a desire to cache more, but the recognition that I had patched a flawed modelling decision instead of fixing it. The honest version of the lesson is that I should have questioned the normalisation much sooner.

Every cached dataset is a second source of truth, and it has to be kept consistent with the primary store across every write path. That consistency work is real maintenance cost, and it never appears in the decision at the moment someone suggests adding Redis to fix a slow query.

An external cache also adds structural complexity. With cache-aside the application owns every cache miss and every invalidation path, and that code has to be correct on every write. If a query will not be measurably faster in memory, the read path is better off without the extra moving part.[2][5]

And it adds operational surface: connection pools to maintain, memory limits to tune, monitoring to set up, and cache behaviour to think about on every deploy. A network round trip to Redis is not free, and on a query that is already fast it can cost more than the query does.[5]

Three questions to ask before writing a Cache::remember call

The order I use now, before any caching code gets merged: is the query expensive because the model is wrong, is the data read often enough to be worth holding in memory, and only then, where should the cache live.

A cache on a read path nobody hits is pure liability. You pay the memory footprint and the invalidation maintenance for data that almost nobody requests. I look at the read-to-write ratio of an entity before I write a key generator at all.

In Laravel applications, wrapping database calls inside Cache::remember closures provides a simple API for retrieving or storing key values. Framework guidelines emphasize that caching should be restricted to slow queries returning data that stays stable across requests. Caching fast queries or frequently changing records wastes memory while increasing invalidation bugs in production.[1][4]

php
$users = Cache::remember('users:active', 3600, function () {
    return DB::table('users')->where('active', 1)->get();
});

It is also worth verifying rather than assuming. Cache::has tells you whether a key is actually populated, which is the only way to know if the cache is earning its place. When the query is fast enough on its own, skipping the memory store keeps the read path predictable.[1][4]

Denormalization versus caching: fixing the database first

When queries slow down, the reflex is to reach for Redis without looking at the schema first. I learned to question the normalisation level instead. Caching recovered throughput temporarily; changing the schema fixed the actual problem without adding a second state store.

Database denormalization solves performance bottlenecks directly inside the primary datastore. You can add counter columns to parent records. You can store pre-calculated aggregate fields during record creation. You can also build targeted summary tables that update synchronously inside standard database transactions.[3][5]

Storing pre-calculated follower counts on a user table eliminates multi-table join operations across millions of relation rows. The database updates the counter in the exact same transaction that creates the relationship row. Data integrity remains tied to ACID guarantees inside MySQL or PostgreSQL. You avoid running background cache purges across distributed job workers.[3][5]

Adding an external cache layer increases infrastructure overhead for the entire team. Application servers must establish and maintain connection pools to Redis. Deployment scripts must account for cache flushing policies and state clearing during releases. When schema adjustments achieve acceptable response times, keeping the query inside SQL avoids unnecessary operational footprint.[5]

Identifying query bottlenecks worth caching in Laravel

Choosing what to optimise needs real profiling rather than intuition in code review. The projects I am drawing on here are anonymised private client work, so I describe structural choices rather than traffic numbers or measured latencies. Profiling is what keeps the effort pointed at a bottleneck that actually exists.

Profiling tools like Laravel Telescope or Debugbar isolate slow database operations effectively. They surface queries that run repeatedly on a single request. They highlight missing table indexes. They measure precise execution timing for individual database calls.[3]

High-volume public endpoints represent the strongest candidates for application caching. A public trending news feed or product catalog index serves the exact same data payload to thousands of visitors every minute. Reading that payload from an in-memory cache saves thousands of identical database queries and keeps database CPU utilization low.[4]

Conversely, low-frequency requests inside internal admin panels rarely warrant caching. An administrator generating a custom report once a day creates minimal database burden over time. Caching that report adds key management logic and invalidation overhead for a path that barely impacts overall database performance.[4]

Cache invalidation patterns with Laravel events and observers

On the social platform backend we started with a complex custom event-handling system for data updates, and eventually simplified it down to Laravel's native event queue so the code stayed understandable for junior team members. The clever version cost far more in developer time than it ever returned.

Automatic cache invalidation relies on predictable model events. Eloquent observers hook into events like created or updated. When a model changes, the corresponding observer fires a Cache::forget call against the affected key to prevent serving stale data to incoming user requests.[3][6]

php
public function updated(News $news): void
{
    Cache::forget("news:{$news->id}");
}

Grouped invalidation becomes easier when using cache tags. Tagging related items under a common label allows applications to clear entire collections with a single flush call. When a category updates, flushing its assigned tag clears all child page keys instantly without requiring manual iteration over individual cache keys.[4][6]

Cache tags do require a driver that supports them, such as Redis or Memcached, and using them on an unsupported driver throws at runtime. Check what production actually runs before building tagged invalidation into your observers.[4][6]

Guarding against cache stampedes and managing Redis limits

Heavy production traffic introduces specific operational risks when relying on Redis stores. A cache stampede occurs when a hot key expires while hundreds of concurrent requests arrive simultaneously. Every incoming request sees a cache miss and attempts to re-calculate the expensive query against the primary database at the exact same microsecond.[2][4]

Preventing stampedes requires atomic locks during cache generation. By acquiring an atomic lock before executing the fallback query, only one worker calculates the fresh dataset. Remaining requests wait or accept a temporary fallback value. This pattern protects relational database servers from sudden query surges during high traffic periods.[2][4]

php
$lock = Cache::lock('processing-news', 10);

if ($lock->get()) {
    // Refresh cache payload
    $lock->release();
}

Paginated queries require unique key parameters to prevent data corruption. A cache key for paginated news must incorporate the page number and category identifier explicitly. Omitting parameter details from the key string leads to incorrect data delivery across different paginated user requests.[6]

Memory management in Redis is equally critical for system stability. Redis stores data entirely in server RAM. Without configuring a maxmemory limit and an explicit eviction policy such as allkeys-lru, Redis can consume all available host RAM and crash under high key write volumes.[4]

Managing configuration and environment caching safely

Laravel provides distinct mechanisms for framework caching compared to application data caching. Commands like php artisan config:cache compile environment variables and configuration files into a single flat file to speed up application boot times. Running config:cache does not clear or modify stored application data keys in Redis or file storage.[4][5]

Deployment pipelines must execute config:clear whenever environment files change during release cycles. If a deployment script updates environment parameters without clearing the configuration cache, Laravel continues reading stale values from the precompiled configuration file. This behavior creates confusing bugs where updated database credentials or API keys fail to take effect.[5]

Similar framework commands exist for precompiling route definitions and event listeners. Developers must maintain clear separation between deployment framework commands and runtime data invalidation calls. Using php artisan cache:clear purges the application data cache entirely, which can cause sudden database load spikes if executed during peak traffic hours.[4][5]

Practical checklist for Laravel caching decisions

Before adding a cache layer to any Laravel application, run the targeted endpoint through this decision sequence. Profile the slow endpoint using Telescope or Debugbar to locate the exact database query. Evaluate whether schema changes or denormalization can eliminate expensive joins directly within MySQL or PostgreSQL. Verify that the entity read volume significantly exceeds its update frequency before allocating memory.[3]

Write targeted invalidation logic inside Eloquent observers or native framework event listeners. Define an explicit time-to-live setting matched to how rapidly the underlying record changes. Incorporate pagination parameters and filter options into the constructed cache key.[3][6]

Implement atomic locks around expensive recalculations on high-traffic keys to block stampedes. Ensure the production Redis instance enforces a maxmemory cap paired with an LRU eviction strategy. Test cache hit behavior during staging builds to confirm hit rates before deploying code to production users.[2][4]

LaravelRedisDatabasePerformanceBackend

Sources

  1. Cache | Laravel 13.x - The clean stack for Artisans and agents
  2. Laravel Redis Caching Strategies for Performance Boost | ANIL KUMAR posted on the topic | LinkedIn
  3. Caching Strategies and Optimization in Laravel - DEV Community
  4. Laravel Caching Strategies: Boost Your App Speed with Redis, Query Cache & Response Cache - DEV Community
  5. The ultimate guide to Laravel caching - Honeybadger Developer Blog
  6. Best Redis Caching Strategy in Laravel: A Guide to Fast and Efficient Caching

Related articles

Press ? for keyboard shortcuts