×
Premium WordPress plugins, PHP Scripts, Android ios games, and Apps. Download Nulled PHP Scripts, Codecanyon Scripts, App Source Code, WordPress Themes here And Many More.
PHP Caching Strategies: From OPcache to Redis

Why "Just Add Caching" Is Bad Advice Without a Strategy

Caching is one of the most effective ways to make a slow PHP application fast, and one of the easiest ways to introduce a bug that serves stale or wrong data to users. The difference between the two outcomes is almost entirely about understanding what layer you are caching at, what invalidates that cache, and what happens when it is wrong. This guide walks through the caching layers available to a PHP application, from the ones that are essentially free to enable, to the ones that require real architectural decisions.

Layer One: OPcache — Caching Compiled Code, Not Data

Before talking about caching data, it is worth understanding a layer of caching that has nothing to do with your application logic: OPcache. Every time a PHP script runs, the PHP engine has to parse the source file and compile it into opcodes before executing it. OPcache stores those compiled opcodes in shared memory, so subsequent requests skip the parse-and-compile step entirely.

; php.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; production: disable to skip filesystem checks

Setting validate_timestamps=0 in production gives a meaningful speed boost, but it means deployed code changes will not be picked up until the cache is cleared or PHP-FPM restarts — a common cause of "I deployed but the old code is still running" confusion. Every deploy script for a production PHP app should explicitly reset OPcache.

Layer Two: Caching Expensive Queries

The next layer is caching the results of expensive database queries — a homepage that aggregates statistics across millions of rows, a "trending products" calculation, anything that takes meaningfully longer than a simple lookup. Redis and Memcached are the two standard choices for this; Redis additionally supports richer data structures (lists, sets, sorted sets) which make it useful for more than plain key-value caching.

$cacheKey = "trending_products";
$products = Cache::remember($cacheKey, now()->addMinutes(15), function () {
    return Product::orderByDesc('view_count')->limit(10)->get();
});

The remember() pattern — check the cache, compute and store on a miss, return on a hit — is the workhorse pattern for this layer. The hard part is never the code; it is choosing the right time-to-live and invalidation approach for each cached value.

Cache Invalidation: The Genuinely Hard Problem

There is an old joke in software engineering that there are only two hard problems: cache invalidation, naming things, and off-by-one errors. The joke holds up because invalidation really is where caching strategies go wrong. Two broad approaches exist: time-based expiry (a TTL, simple but means data can be stale for up to that window) and event-based invalidation (explicitly clearing a cache key the moment underlying data changes, more precise but requires remembering to do it everywhere that data can change).

// Event-based: clear cache the moment the underlying row changes
public function update(Product $product, array $data)
{
    $product->update($data);
    Cache::forget("product:{$product->id}");
    Cache::forget('trending_products');
}

Mixing both approaches — a reasonably short TTL as a safety net, plus explicit invalidation on writes for correctness-sensitive data — covers most real applications well.

Layer Three: Full Page and Fragment Caching

For pages that are expensive to render but rarely change (a marketing page, a rarely-updated blog post), caching the entire rendered HTML output skips not just the database query but the templating and rendering work too. Fragment caching applies the same idea to just part of a page — a sidebar widget, a footer — when the rest of the page is genuinely dynamic.

Layer Four: HTTP and CDN Caching

The outermost layer is caching at the HTTP level entirely, so requests never reach PHP at all. Setting proper Cache-Control headers lets browsers and CDNs cache static or semi-static responses, and a CDN in front of your application can absorb traffic spikes on cacheable content without your servers seeing the request.

Choosing What Not to Cache

Anything involving a logged-in user's personal data, a price that must reflect real-time inventory, or a security-sensitive check should generally not be cached, or cached very carefully with the user/context as part of the cache key. The fastest way to create a serious bug is caching a response that varies per user under a cache key that does not include the user's identity.

Closing Thought

Caching is not one decision but a stack of decisions at different layers, each with its own tradeoffs between freshness and speed. Start with OPcache (free, no tradeoffs), add query caching for genuinely expensive operations with deliberate invalidation, and only reach for page-level or CDN caching once you understand exactly which parts of a page are safe to share across requests.

If your site feels slow under load, we can profile it and tell you exactly where caching will help.

Cache Stampede: What Happens When a Hot Key Expires

Imagine a cache key backing your homepage's "trending products" widget, hit by hundreds of requests per second, with a 15-minute TTL. The moment that key expires, every one of those concurrent requests sees a cache miss at the same instant and stampedes the database simultaneously, trying to recompute the same expensive query hundreds of times in parallel — precisely the load spike caching was supposed to prevent. This is called a cache stampede, and it is one of the most common ways caching silently makes a production incident worse rather than better.

The standard fix is a lock around recomputation: the first request to see a miss acquires a lock and recomputes; every other concurrent request either waits briefly for that result or serves a slightly stale value while the recompute finishes.

$value = Cache::get($key);
if ($value === null) {
    $lock = Cache::lock("lock:$key", 10);
    if ($lock->get()) {
        try {
            $value = $expensiveComputation();
            Cache::put($key, $value, now()->addMinutes(15));
        } finally {
            $lock->release();
        }
    } else {
        // another process is already recomputing; wait briefly or serve stale
        $value = Cache::get("$key:stale") ?? $expensiveComputation();
    }
}

Cache Warming: Avoiding the Cold-Start Penalty

A related problem: right after a deploy that clears the cache, or right after a cache server restarts empty, the very first requests hit nothing but misses, and if those requests are expensive, the application can feel sluggish exactly when it just came back online. Cache warming addresses this by proactively populating known-hot keys immediately after a deploy, rather than waiting for real traffic to populate them one slow request at a time.

Negative Caching: Caching the Absence of a Result

It is easy to forget to cache "this does not exist" the same way you cache "here is the result" — but a lookup for a nonexistent record, hit repeatedly (a bot scanning sequential IDs, a broken client retrying a typo'd slug), hits the database every single time if only positive results are cached. Caching a short-lived negative result for not-found lookups closes this gap and is a cheap, often-overlooked optimization.

Choosing a TTL: A Few Concrete Heuristics

There is no universal correct TTL, but a few heuristics hold up well in practice: for data that changes on a known schedule (a daily report, a nightly batch job's output), set the TTL to match that schedule exactly rather than guessing a round number. For data that changes unpredictably but infrequently, pair event-based invalidation with a generous TTL as a safety net rather than relying on the TTL alone. For data where staleness is genuinely costly (account balances, inventory counts at checkout), avoid caching at all, or cache only with strict, short TTLs and explicit invalidation on every write.

Caching at the Application Versus the Database Layer

It is worth distinguishing application-level caching (Redis, Memcached, in-memory arrays for the duration of a single request) from database-level caching, which MySQL and other engines perform internally and mostly automatically — the query cache (deprecated in modern MySQL versions for good reason, since it caused more invalidation problems than it solved), buffer pools that keep frequently accessed pages in memory, and index caching. As an application developer, you generally do not manage database-internal caching directly; what you control is the application-level cache sitting in front of it, and the two should be thought of as separate layers with separate tuning knobs, not interchangeable solutions to the same problem.

Cache Key Design: A Detail That Causes Real Bugs

A cache key needs to fully encode everything that affects the cached value — miss any dimension, and two different results end up sharing one cache slot, silently serving wrong data to one of them. A product listing cached under the key products_list alone, with no encoding of the current page, sort order, or applied filters, will serve page one's results to a request for page three. The fix is building keys that include every input that changes the output:

$key = sprintf('products:%s:page:%d:sort:%s', md5(json_encode($filters)), $page, $sortColumn);

This looks verbose compared to a short, memorable key name, but the verbosity is exactly what prevents the silent-wrong-data bug described above. A useful habit: whenever a caching bug report comes in as "the wrong data showed up for some users," the cache key is the first place to look.

Multi-Tenant Applications: An Easy Way to Leak Data Across Tenants

For a SaaS application serving multiple separate customers (tenants) from one codebase, a cache key that omits the tenant identifier is one of the most serious caching mistakes possible — not just a stale-data bug but a genuine data leak, where Tenant A's cached query result gets served to Tenant B. Every cache key in a multi-tenant system must include the tenant ID as a matter of strict discipline, ideally enforced through a shared helper or wrapper rather than left to individual developers to remember on every cache call.

Monitoring Cache Effectiveness

A cache that is rarely hit is providing little value while still adding complexity and a class of bugs that did not exist before it was introduced. Tracking hit rate (the percentage of lookups that find a cached value versus miss) for your major cache keys tells you whether a given cache is earning its complexity, or whether the TTL is too short, the key design too granular, or the underlying data simply changes too often for caching to help in that specific case.

Case Study: A Homepage That Fell Over Every Morning at 9am

A real pattern worth describing: an e-commerce homepage displayed "today's deals," recomputed from a moderately expensive query, cached with a 5-minute TTL. Traffic was low overnight and ramped up sharply every morning around 9am as customers checked the site before work. Every 5 minutes during that ramp, the cache expired and the next wave of concurrent requests recomputed the deals query simultaneously — the cache stampede pattern described earlier — and the database connection pool would briefly saturate, causing unrelated pages to slow down too. The fix combined three of the techniques already covered: a longer TTL (15 minutes, since "today's deals" genuinely did not need to update more than a few times an hour), a recompute lock so only one request repopulated the cache on a miss, and cache warming triggered by the deploy pipeline so the cache was never actually empty during the morning ramp. No single change fully resolved it; all three together did, which is a useful reminder that caching problems are rarely solved by one silver-bullet setting.

A Glossary for This Topic

TTL (time-to-live): how long a cached value is considered valid before it expires automatically. Cache stampede: many concurrent requests recomputing the same expired cache key simultaneously. Cache invalidation: explicitly removing or updating a cached value because the underlying data changed. Cache warming: proactively populating cache keys before real traffic needs them, typically right after a deploy or restart. Negative caching: caching the fact that a lookup found nothing, to avoid repeatedly querying for data that does not exist. Hit rate: the percentage of cache lookups that find a valid cached value rather than missing.

Frequently Asked Questions

Should I cache database query results or the rendered HTML? Both are valid depending on the situation — cache query results when the same data feeds multiple different views, cache rendered HTML when an entire page or fragment is expensive to both query and render and is reused identically across requests.

How do I know if my TTL is too long or too short? Too long shows up as stale-data complaints from users; too short shows up as a high miss rate with little performance benefit. Monitoring hit rate alongside user-reported staleness issues is the practical way to tune it over time rather than guessing once and never revisiting it.

Is Redis always better than Memcached? Not strictly — Memcached is simpler and slightly faster for pure key-value caching with no persistence needs; Redis adds persistence options and richer data structures that are useful once your caching needs grow beyond simple key-value lookups, such as rate limiting counters or leaderboards.

Step-by-Step: Adding Caching to an Existing Slow Endpoint

A concrete walkthrough helps tie the theory together. Suppose a "best-selling products" endpoint takes 800ms because of a heavy aggregation query, and it is hit constantly. Step one: confirm the query itself cannot reasonably be sped up first — missing indexes are a far more common root cause of slow queries than "needs caching," and caching a query that should simply have an index added just hides the real problem rather than fixing it. Step two: once the query genuinely cannot be made fast enough directly, wrap it in Cache::remember() with a TTL chosen based on how often "best-selling" realistically changes — probably not every minute. Step three: add explicit invalidation wherever an order is placed, since a new sale could change the ranking, using a short debounce so a flurry of orders does not trigger redundant recomputation on every single one. Step four: add monitoring for this specific cache key's hit rate, so a future regression (someone changes the invalidation logic and breaks it) is visible in dashboards rather than discovered through a slow page complaint weeks later. Step five: load test the endpoint with the cache intentionally cold to confirm the uncached path still performs acceptably for the unlucky first request after each invalidation, rather than assuming the cache will always be warm.

A Comparison Table: Cache Backends at a Glance

In-memory array (request-local only): zero setup, but the cache does not survive past a single request — useful only for avoiding duplicate work within one request's lifecycle. OPcache: caches compiled code, not application data, configured once at the server level, no application code changes needed. APCu: shared memory cache local to a single server, fast, no network hop, but does not work correctly across multiple servers since each has its own separate cache. Redis: a separate networked service, shared correctly across multiple application servers, supports rich data structures and persistence, the standard choice for most production multi-server PHP applications. Memcached: similar networked model to Redis but simpler, slightly faster for pure key-value workloads, lacking Redis's richer data structures and persistence options.

Security Considerations Specific to Caching

Caching introduces its own narrow but real security surface. A cache poisoning vulnerability occurs when an attacker can influence what gets stored under a cache key that other users will later read — for instance, a page cached by URL where an attacker-controlled query parameter or header is reflected into the cached response, served to every subsequent visitor until the cache expires. Always confirm that anything used to build a cache key, or anything reflected into a cached response, is either fully trusted or properly sanitized; treating cache keys as equivalent to user input in terms of validation rigor avoids an entire class of vulnerability that caching-focused articles rarely mention.

Caching and Compliance

For applications subject to data protection regulations, cached copies of personal data are still personal data, subject to the same deletion and access obligations as the primary database record. A "right to be forgotten" deletion request that removes a user's row from the primary database but leaves a cached copy lingering for its TTL is a compliance gap, however small the window. Building cache invalidation into account-deletion workflows, not just into ordinary data-update workflows, closes this gap.

Final Checklist Before Shipping a New Cache Layer

Does the cache key fully encode every input that affects the cached value, including tenant or user identity where relevant? Is there an explicit invalidation path for every way the underlying data can change, not just the obvious one? Is the TTL chosen deliberately based on how often the data actually changes, rather than copied from an unrelated example? Is there monitoring in place to notice if hit rate drops unexpectedly, which often signals a key-design or invalidation bug introduced by a later code change? Has the uncached path been load-tested, since a cache that fails open under load needs the underlying system to still survive the resulting traffic?

Testing Caching Logic Itself

Caching code is easy to under-test because the happy path (cache miss, compute, store, return) looks trivial, while the bugs live in the edge cases: does a cache hit actually skip the expensive computation, does invalidation actually clear the right key, does a stampede-guard lock actually prevent concurrent recomputation under simulated concurrent access? Tests that explicitly assert the expensive function was called exactly zero times on a cache hit, and exactly once across many concurrent simulated misses with locking enabled, catch regressions that a purely functional "does it return the right value" test would miss entirely.

public function testCacheHitDoesNotCallExpensiveFunction()
{
    Cache::put('trending_products', $expected, 900);
    $spy = $this->spy(ProductRepository::class);
    $result = (new TrendingProductsService())->get();
    $spy->shouldNotHaveReceived('computeTrending');
    $this->assertEquals($expected, $result);
}

Closing Thought

Caching done well is one of the highest-leverage performance changes available to a PHP application — often turning a multi-hundred-millisecond page into a near-instant one with comparatively little code. Caching done carelessly trades a slow-but-correct application for a fast-but-occasionally-wrong one, which is almost always the worse trade. The layers, invalidation strategies, and failure modes covered in this guide are not exotic edge cases reserved for huge platforms; they show up in ordinary mid-sized applications the moment real concurrent traffic meets a cache that was designed without them in mind.

What to Do When You Inherit a Codebase With No Caching at All

It is common to take over a PHP application that has scaled past its original design with zero caching layers in place, every page hitting the database fresh on every request. The instinct to add caching everywhere at once is usually a mistake — it is far safer to profile first, find the handful of genuinely expensive, frequently-hit queries actually causing pain, and cache those specifically with careful invalidation, rather than wrapping every database call in a generic cache decorator. A codebase with caching applied broadly but carelessly is harder to reason about and debug than one with no caching at all, since at least the latter is consistently correct, just slow.

A Final Word on Premature Optimization

Caching is a classic example of an optimization that is genuinely valuable when applied to a real, measured bottleneck, and genuinely harmful when applied speculatively to code that was never actually slow. Measure first — with real profiling data, not guesswork about what feels like it should be slow — and let that measurement guide exactly where the complexity of caching, invalidation, and the bug classes that come with it is actually worth taking on.