Caching Techniques

Reviewed & published by Brayan K

By the end of this lesson you'll be able to cache expensive work with the cache-aside pattern, pick the right store (file, APCu, or Redis), set TTLs and invalidate safely, and avoid the cache stampede that brings sites down — turning a 200ms page into a sub-20ms one.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Why Cache, and the Cache-Aside Pattern

A cache is a fast, temporary store for the result of slow work — a database query, an API call, a heavy calculation — so you only pay for that work once. Reading from a database might take milliseconds; reading from a cache takes microseconds, often 100x faster. Two words you'll use constantly: a hit means the value was in the cache (fast), and a miss means it wasn't, so you had to do the slow work.

The standard recipe is cache-aside (also called lazy loading): check the cache first; on a miss, run the query, store the result, then return it. It's popular because it only caches what's actually asked for, and if the cache is empty or down a miss simply falls through to the database. Watch the loop — four requests, but only two database calls.

<?php
// CACHE-ASIDE: the app checks the cache first; only a MISS hits the database.
//   1. look in the cache  ->  HIT  = return the cached value (fast)
//                              MISS = run the slow query, then STORE it
// Here a plain array stands in for a real cache so you can run it anywhere.

$cache = [];                       // our pretend cache (key => value)
$dbCalls = 0;                      // count how often we hit the "database"

function getUser(array &$cache, int &$dbCalls, int $id): string {
    $key = "user:$id";             // a unique key per user

    if (isset($cache[$key])) {     // 1) check the cache
        echo "HIT  $key\n";        //    found it — no database needed
        return $cache[$key];
    }

    echo "MISS $key\n";            // 2) not cached: do the expensive work
    $dbCalls++;
    $name = "User #$id";           //    pretend this came from the database
    $cache[$key] = $name;          // 3) store it so next time is a HIT
    return $name;
}

getUser($cache, $dbCalls, 1);      // MISS — first time we see user 1
getUser($cache, $dbCalls, 1);      // HIT  — served from the cache
getUser($cache, $dbCalls, 2);      // MISS — new user
getUser($cache, $dbCalls, 1);      // HIT  — still cached

echo "Database calls: $dbCalls\n"; // only 2 queries for 4 requests

That's the entire idea. Every cache below — file, APCu, Redis — is the same three steps (check → miss → store); only the place the data is kept changes.

2️⃣ A File-Based Cache (and TTL)

The simplest real cache writes each value to a file on disk — no extra software needed. The new idea here is the TTL (time-to-live): how many seconds a value is allowed to live before it's treated as expired and recomputed. Without a TTL a value can become stale — users keep seeing yesterday's data forever. Here the file's last-modified time tells us how old the entry is.

<?php
// FILE CACHE with a TTL (time-to-live). Each value is saved to a file that
// expires after N seconds, so stale data can't live forever.

function cacheGet(string $key, int $ttl, callable $compute): mixed {
    $file = sys_get_temp_dir() . "/cache_" . md5($key) . ".json";

    // Serve from file if it exists AND is younger than the TTL.
    if (file_exists($file) && (time() - filemtime($file)) < $ttl) {
        echo "HIT  $key\n";
        return json_decode(file_get_contents($file), true);
    }

    echo "MISS $key (computing)\n";
    $value = $compute();                       // run the expensive work
    file_put_contents($file, json_encode($value));  // write it to disk
    return $value;
}

// Start from a cold cache, so the first call below really is a MISS whether
// or not you have run this file before.
@unlink(sys_get_temp_dir() . "/cache_" . md5("report:daily") . ".json");

// A "slow" task: pretend this query takes 200ms.
$slowQuery = fn() => ['total' => 42, 'at' => date('H:i:s')];

$a = cacheGet("report:daily", 60, $slowQuery);  // MISS — runs the query
$b = cacheGet("report:daily", 60, $slowQuery);  // HIT  — same file, no recompute

echo "Total: {$a['total']}\n";
echo "Same data reused: " . ($a === $b ? "yes" : "no") . "\n";

File caches are great for rendered HTML fragments or config that rarely changes. They're a little slower than memory (disk access) and don't share well across many servers — which is exactly what the next two stores fix.

3️⃣ APCu — In-Memory Caching

APCu is a cache built into PHP that keeps data in the server's RAM, so reads are measured in microseconds. You enable the apcu extension, then use apcu_store($key, $value, $ttl) and apcu_fetch($key, $found). The catch: it's per-server (each web server has its own copy) and is wiped on restart. Perfect for a single machine; not for a cluster.

<?php
// APCu: a fast in-memory cache built into PHP (enable the apcu extension).
// Data lives in the server's RAM, so reads are microseconds — but it is
// per-server (each web server has its own) and is wiped on restart.

function fib(int $n): int {                 // a deliberately slow function
    return $n < 2 ? $n : fib($n - 1) + fib($n - 2);
}

$key = 'fib:30';

// apcu_fetch sets $found by reference: true on a HIT, false on a MISS.
$value = apcu_fetch($key, $found);

if ($found) {
    echo "HIT  $key = $value\n";
} else {
    echo "MISS $key (computing)\n";
    $value = fib(30);                       // the expensive calculation
    apcu_store($key, $value, 300);          // cache it for 300 seconds (TTL)
}

echo "Result: $value\n";

4️⃣ Redis & Memcached — A Shared Cache

Once you have more than one web server, a per-server cache isn't enough — they'd each cache different things. Redis and Memcached run as their own separate service that every server connects to over the network, so the cache is shared. Memcached is a lean key/value store; Redis adds richer types (lists, sets, hashes), persistence to disk, and more — it's the usual default today. Note invalidation at the end: when the underlying data changes, you del() the key so the next read recomputes.

<?php
// REDIS / MEMCACHED: an in-memory store that runs as its OWN service, so it
// is SHARED by every web server (unlike APCu). Perfect once you scale past
// one machine. Install a client first:  composer require predis/predis

$redis = new Predis\Client('tcp://127.0.0.1:6379');

function getTopProducts(Predis\Client $redis): array {
    $key = 'products:top';

    $cached = $redis->get($key);            // returns null on a MISS
    if ($cached !== null) {
        echo "Redis HIT\n";
        return json_decode($cached, true);  // Redis stores strings -> decode
    }

    echo "Redis MISS — running slow query\n";
    $products = [['id' => 1, 'name' => 'Widget'], ['id' => 2, 'name' => 'Gadget']];

    // setex = SET with EXpiry: store as JSON with a 300-second TTL.
    $redis->setex($key, 300, json_encode($products));
    return $products;
}

getTopProducts($redis);                     // MISS — fills the cache
$top = getTopProducts($redis);              // HIT  — served from Redis

// INVALIDATION: when products change, delete the key so the next read recomputes.
$redis->del('products:top');
echo "First product: {$top[0]['name']}\n";

5️⃣ OPcache and the Cache Stampede

OPcache is a different kind of cache: instead of your data, it caches your compiled code. PHP normally reparses and recompiles every .php file on every request; OPcache keeps the compiled bytecode in memory so that work happens once — typically a 2–3x speed-up for free. You write no code; it's switched on in php.ini.

Finally, the failure mode to know about: a cache stampede (or "dog-piling"). A hot key expires, and in the same instant dozens of requests all miss — so they all run the same expensive query at once and crush the database. The fixes: a short lock so only the first request recomputes while the rest wait or serve the old value; early expiration, where one request refreshes just before the TTL; and a little random jitter on TTLs so many keys don't expire on the very same second.

6️⃣ Your Turn

Time to wire up the pattern yourself. The script below is almost complete — fill each ___ using the 👉 hint, then run it and check it against the Output panel.

<?php
// 🎯 YOUR TURN — finish the cache-aside lookup, then run it.
// Fill each ___ using the 👉 hint. The pattern is always: check, miss, store.

$cache = [];

function getPrice(array &$cache, string $sku): int {
    $key = "price:$sku";

    if (isset($cache[$key])) {        // 1) check the cache
        echo "HIT  $key\n";
        return ___;                   // 👉 return the cached value: $cache[$key]
    }

    echo "MISS $key\n";              // 2) not cached — do the work
    $price = strlen($sku) * 10;       //    pretend this came from the database
    $cache[___] = $price;             // 👉 store it under the key: $key
    return $price;
}

getPrice($cache, "ABC");              // MISS
getPrice($cache, "ABC");              // HIT  — second call is cached

// ✅ Expected output:
//    MISS price:ABC
//    HIT  price:ABC
?>

One more — this time about freshness. Compare an entry's age against the TTL so stale data is detected.

<?php
// 🎯 YOUR TURN — give the cache a TTL so old data expires.
// An entry is fresh only while  (now - storedAt) < ttl.

$cache = ['weather' => ['value' => 'sunny', 'storedAt' => time() - 90]];
$ttl = 60;                            // entries live for 60 seconds

$entry = $cache['weather'];
$age   = time() - $entry['storedAt']; // how old the entry is, in seconds

if ($age < ___) {                     // 👉 compare against the TTL: $ttl
    echo "FRESH: {$entry['value']}\n";
} else {
    echo "STALE: re-fetch needed (age {$age}s)\n";
}

// ✅ Expected output (the entry is 90s old, TTL is 60s):
//    STALE: re-fetch needed (age 90s)
?>

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Caching in PHP

StoreScopeUse it for
OPcachePer serverCompiled PHP bytecode — always enable
File cachePer serverRendered HTML, config — no extra software
APCuPer server (RAM)Hot data on a single machine
MemcachedSharedSimple shared key/value across servers
RedisSharedShared cache + rich types + persistence
TTL / del()—Expire and invalidate to avoid stale data

Mini-Challenge: A Counting Cache

No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is the same write-run-check loop you'll use on every real cache.

<?php
// 🎯 MINI-CHALLENGE: a counting cache wrapper.
// No code is filled in — work from the steps, then run it.
//
// 1. Make an array $cache = [] and an int $misses = 0.
// 2. Write a function remember(&$cache, &$misses, $key, $compute) that:
//      - returns $cache[$key] if the key is already set  (a HIT)
//      - otherwise: add 1 to $misses, call $compute(), store and return it
// 3. Call it twice with key "square:8" and a function fn() => 8 * 8.
// 4. echo the result and echo "Misses: $misses".
//
// Tip: pass $compute as a callable, e.g.  fn() => 8 * 8
//
// ✅ Expected output:
//    64
//    Misses: 1

// your code here
?>

🎉 Lesson Complete!

Practice quiz

In the cache-aside pattern, what happens on a cache MISS?

  • Return null and stop
  • Delete the cache key
  • Run the slow query, store the result in the cache, then return it
  • Throw an exception

Answer: Run the slow query, store the result in the cache, then return it. Cache-aside: check the cache first; on a miss, run the slow work, store the result, then return it. Later requests for that key are fast hits.

What is a cache 'hit'?

  • The value was already in the cache, so no slow work was needed
  • The cache was cleared
  • The cache server crashed
  • A new value was written to the cache

Answer: The value was already in the cache, so no slow work was needed. A hit means the value was in the cache (fast); a miss means it wasn't, so you had to do the slow work.

What does TTL stand for, and what does it control?

  • Total transfer load — bytes per request
  • Time to login — session length
  • Tries till lockout — failed attempts
  • Time-to-live — how many seconds before an entry is treated as expired

Answer: Time-to-live — how many seconds before an entry is treated as expired. TTL is the number of seconds an entry may stay before it is recomputed — the simplest form of cache invalidation, stopping stale data living forever.

What is the key limitation of APCu compared to Redis?

  • It cannot store integers
  • It is per-server (each web server has its own copy) and wiped on restart
  • It requires a separate network service
  • It never expires entries

Answer: It is per-server (each web server has its own copy) and wiped on restart. APCu lives in one server's RAM, so it's per-server and cleared on restart — great for a single machine, not for a cluster.

Why use Redis or Memcached instead of APCu once you have multiple web servers?

  • They run as a separate service shared by every web server
  • They are written in PHP
  • They never need a TTL
  • They cache compiled bytecode

Answer: They run as a separate service shared by every web server. Redis/Memcached run as their own service every server connects to, so the cache is shared — essential past one machine.

What does OPcache cache?

  • Database query results
  • User session data
  • The compiled bytecode of your PHP scripts
  • Rendered HTML fragments

Answer: The compiled bytecode of your PHP scripts. OPcache caches compiled bytecode so PHP doesn't reparse and recompile every .php file per request — a 2-3x speed-up, configured in php.ini.

How do you enable OPcache?

  • Call opcache_start() in every script
  • Set opcache.enable=1 in php.ini (no application code)
  • composer require opcache
  • Wrap each function in a cache() call

Answer: Set opcache.enable=1 in php.ini (no application code). OPcache is switched on in php.ini with opcache.enable=1; you write no application code for it.

What is a cache stampede (dog-piling)?

  • The cache server runs out of disk
  • Two users edit the same record
  • A TTL is set too long
  • A hot key expires and many requests miss at once, all running the same expensive query

Answer: A hot key expires and many requests miss at once, all running the same expensive query. When a popular key expires, dozens of requests miss simultaneously and all recompute, hammering the database. Locks, early expiration, and TTL jitter prevent it.

What is cache invalidation, and why isn't a TTL alone enough?

  • Restarting the cache server nightly
  • Deleting/overwriting a key when its source changes; a TTL only bounds how long it's wrong
  • Encrypting cache keys
  • Setting per_page limits

Answer: Deleting/overwriting a key when its source changes; a TTL only bounds how long it's wrong. On a write that affects a cached value you del() the key for immediate correctness; a TTL only limits the staleness window.

Which Redis command stores a value with an expiry (TTL)?

  • get
  • del
  • setex (SET with EXpiry)
  • ping

Answer: setex (SET with EXpiry). setex stores a value as a string with a TTL in seconds; del removes a key to invalidate it; get reads it (null on a miss).

Continue this course

Frequently asked questions

What is the cache-aside pattern?

Cache-aside (also called lazy loading) means the application is in charge of the cache: it looks in the cache first, and only on a miss does it run the slow query, store the result in the cache, and return it. Every later request for the same key is a fast hit. It is the most common caching strategy in PHP because it is simple, only caches data that is actually requested, and survives a cache that is empty or down — a miss just falls through to the database.

What is a TTL and why does every cache entry need one?

TTL stands for time-to-live: the number of seconds an entry is allowed to stay in the cache before it is treated as expired and recomputed. Without a TTL, a value can become stale and live forever — users keep seeing yesterday's price or an old article. Setting a sensible TTL (seconds for fast-moving data, minutes or hours for slow-moving data) is the simplest form of cache invalidation and the single most important habit when caching.

What is the difference between APCu, Redis, and Memcached?

APCu is an in-memory cache built into PHP itself: extremely fast but local to a single server and wiped on restart, so it suits one-machine apps and per-process data. Redis and Memcached are separate services your app connects to over the network, so they are shared by every web server in a cluster — essential once you scale past one machine. Redis adds richer data types (lists, sets, hashes), persistence, and pub/sub; Memcached is a leaner pure key/value store. Reach for APCu on a single server, Redis when you need a shared or more capable cache.

What is OPcache and do I need to write code for it?

OPcache caches the compiled bytecode of your PHP scripts so PHP does not reparse and recompile every .php file on every request — typically a 2-3x speed-up for free. You do not write any code: it is enabled in php.ini with opcache.enable=1. In production also set opcache.validate_timestamps=0 so PHP stops checking files for changes (remember to reload PHP-FPM after a deploy). OPcache caches code; Redis/APCu/file caches store data — they solve different problems and you usually use both.

What is a cache stampede and how do I prevent it?

A cache stampede (or 'dog-piling') happens when a popular key expires and dozens of requests all miss at the same instant, so they all run the same expensive query at once and hammer the database. Common fixes are: a short lock so only the first request recomputes while the others wait or serve the old value; 'early expiration', where one request refreshes the entry slightly before the real TTL; and adding a little random jitter to TTLs so many keys do not all expire on the same second.

Related lessons