Skip to main content
    Courses/PHP/Caching Techniques

    Lesson 27 • Advanced

    Caching Techniques ⚡

    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.

    What You'll Learn in This Lesson

    • Explain why caching matters and what a hit, miss, and TTL are
    • Implement the cache-aside pattern: check, miss, store, return
    • Build a file-based cache that expires with a TTL
    • Use APCu for fast in-memory caching on a single server
    • Choose between Redis and Memcached for a shared cache
    • Invalidate safely and avoid stale data and cache stampedes

    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.

    The cache-aside pattern
    <?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
    Output
    MISS user:1
    HIT  user:1
    MISS user:2
    HIT  user:1
    Database calls: 2
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    File cache with a 60-second TTL
    <?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;
    }
    
    // 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";
    Output
    MISS report:daily (computing)
    HIT  report:daily
    Total: 42
    Same data reused: yes
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Caching an expensive calculation in APCu
    <?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";
    Output
    MISS fib:30 (computing)
    Result: 832040
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Redis object caching with TTL and invalidation
    <?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";
    This connects to a Redis server, so it needs Redis running and the predis/predis package installed. Run it locally with php cache.php after redis-server is up.

    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.

    OPcache: configured in php.ini, not in code
    ; In php.ini (production settings) — no application code required:
    
    opcache.enable=1                 ; turn OPcache on
    opcache.memory_consumption=128   ; 128 MB for compiled bytecode
    opcache.max_accelerated_files=10000  ; cache up to 10k files
    opcache.validate_timestamps=0    ; stop re-checking files for changes (prod)
    
    ; With validate_timestamps=0, PHP won't notice edited files —
    ; so reload PHP-FPM after every deploy:  systemctl reload php8.3-fpm
    This is php.ini configuration, not a script to run. Edit your server's php.ini, then check it loaded with php -i | grep opcache.enable.

    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.

    🎯 Your turn: finish the cache-aside lookup
    <?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
    ?>
    Output
    MISS price:ABC
    HIT  price:ABC
    Fill the two ___ blanks so a value is returned on a HIT and stored under $key on a MISS. You should see one MISS then one HIT.

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

    🎯 Your turn: expire stale data with a TTL
    <?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)
    ?>
    Output
    STALE: re-fetch needed (age 90s)
    Replace ___ with the TTL variable $ttl so the 90-second-old entry is reported as STALE.

    Common Errors (and the fix)

    • Stale data — users see old values after an update — you cached the result but never invalidate it when the source changes. On every write that affects a cached value, del() (or overwrite) the matching key. A TTL alone only bounds how long it's wrong; explicit invalidation fixes it immediately.
    • No TTL — the cache fills up and never expires — storing values with no expiry means stale entries live forever and memory grows without limit. Always pass a TTL: apcu_store($k, $v, 300) or $redis->setex($k, 300, $v). Pick seconds for fast-moving data, minutes or hours for slow-moving data.
    • Caching everything, including data that's already cheap — caching a fast lookup or per-user data with a global key adds complexity and bugs (one user sees another's data) for no speed win. Cache what's genuinely slow and reused; put the user ID in the key for per-user data: dashboard:user:42, not dashboard.
    • Cache stampede — the database spikes when a hot key expires — many requests miss at once and all recompute together. Add a short lock so only one request rebuilds the value, refresh slightly before expiry (early expiration), or add random jitter to TTLs so keys don't expire in lockstep.
    • "Call to undefined function apcu_fetch()" — the APCu extension isn't installed or enabled. Install it (pecl install apcu) and add extension=apcu to php.ini; for the CLI also set apc.enable_cli=1.

    Pro Tips

    • 💡 Turn on OPcache first. It's free, needs zero code changes, and is usually the biggest single win — do this before reaching for Redis.
    • 💡 Measure, don't guess. Profile the request before caching: the real bottleneck is often somewhere you didn't expect, and caching the wrong thing just hides it.
    • 💡 Name keys with a namespace and version. products:top:v2 lets you invalidate a whole family by bumping the version, and avoids collisions.
    • 💡 A cache must be optional. If Redis is down, a miss should fall through to the database — never let a cache outage take the whole site down.

    📋 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

    Frequently Asked Questions

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    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.

    🎯 Mini-Challenge: write a remember() cache wrapper
    <?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
    ?>
    Build a remember() function that returns a cached value on a hit and counts misses. Calling it twice with the same key should run the work once.

    🎉 Lesson Complete!

    • ✅ A cache stores slow work so you pay for it once — a hit is fast, a miss does the real work
    • Cache-aside is the core recipe: check → miss → store → return
    • ✅ A file cache needs no extra software; a TTL stops data going stale
    • APCu is fast in-memory per server; Redis/Memcached are shared across servers
    • OPcache caches compiled bytecode (php.ini, 2–3x faster) — different from data caches
    • Invalidate on change, and defend against the cache stampede with locks, early expiry, and jitter
    • Next lesson: Dynamic APIs — pagination, versioning, and HAL-format responses

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service