Simple file-based PHP cache class

Filed under: PHP | 41 Comments

I needed a simple file-based cache for a PHP project the other day and figured I would share. The production code is slightly different, made into a CodeIgniter library*, but this version is now live up on the Mobile Drudgereport. It’s working well there, serving up millions of hits per month with no noticeable server load.

If you’re looking to cache rendered templates or total site caching, this is probably not the code for you. I wrote this to cache an XML data feed and then render the rest of the page dynamically. It can cache any PHP data type though so a good use case is to cache the results of database queries.

Usage is dead simple, initiate the class with a path to your cache location and then use JG_Cache::get() and JG_Cache::set() to play with your data. Each cache has a key, which you set. If you’re caching something with user data, make sure to put their unique ID in it. This is Memcached style, really basic. In fact you should be able to move to Memcached without much trouble if in the future you need higher performance. Cache data defaults to last an hour, but that’s flexible in the get() method (the second argument is the number of seconds you want the data to stick around).

Example usage

$cache = new JG_Cache('/path/to/cache'); //Make sure it exists and is writeable

$data = $cache->get('key');

if ($data === FALSE)
{
    $data = 'This will be cached';
    $cache->set('key', $data);
}

//Do something with $data

While we’re using a string here, the class uses serialize() and unserialize(), so you can cache whatever PHP data types you want. Objects are especially handy, at least for what I needed to use it for. The cache files themselves are stored as a hash of the key name so you don’t have to worry about file naming rules.

It’s designed to fail silently since your code should always be able to access the data without a cache. The beauty of it is there are only a few extra lines to implement the caching and the rest of your code can remain the same.

Cache class source code (also on Github)

class JG_Cache {
    
    function __construct($dir)
    {
        $this->dir = $dir;
    }

    private function _name($key)
    {
        return sprintf("%s/%s", $this->dir, sha1($key));
    }
    
    public function get($key, $expiration = 3600)
    {
        
        if ( !is_dir($this->dir) OR !is_writable($this->dir))
        {
            return FALSE;
        }
        
        $cache_path = $this->_name($key);
        
        if (!@file_exists($cache_path))
        {
            return FALSE;
        }
        
        if (filemtime($cache_path) < (time() - $expiration))
        {
            $this->clear($key);
            return FALSE;
        }
        
        if (!$fp = @fopen($cache_path, 'rb'))
        {
            return FALSE;
        }
        
        flock($fp, LOCK_SH);
        
        $cache = '';
        
        if (filesize($cache_path) > 0)
        {
            $cache = unserialize(fread($fp, filesize($cache_path)));
        }
        else
        {
            $cache = NULL;
        }

        flock($fp, LOCK_UN);
        fclose($fp);
        
        return $cache;
    }
    
    public function set($key, $data)
    {
                
        if ( !is_dir($this->dir) OR !is_writable($this->dir))
        {
            return FALSE;
        }
        
        $cache_path = $this->_name($key);

        if ( ! $fp = fopen($cache_path, 'wb'))
        {
            return FALSE;
        }

        if (flock($fp, LOCK_EX))
        {
            fwrite($fp, serialize($data));
            flock($fp, LOCK_UN);
        }
        else
        {
            return FALSE;
        }
        fclose($fp);
        @chmod($cache_path, 0777);
        return TRUE;
    }
    
    
    public function clear($key)
    {
        $cache_path = $this->_name($key);
        
        if (file_exists($cache_path))
        {
            unlink($cache_path);
            return TRUE;
        }
        
        return FALSE;
    }
}

Read the latest posts

41 Responses to “Simple file-based PHP cache class”

  1. FranL says:

    Hi!

    Thank you for sharing your cache class. I would like to use it in a script licensed under the GNU GPL.
    What license applies to your code?

    Thanks.

  2. Sam Dalton says:

    Excellent! Exactly what I needed, thanks!

  3. Sergey says:

    Thank you for the sharing!
    I’ve downloaded the class and now using it in my current project.
    Overall it seems great, but this feature I’ve commented out in my copy of the source:
    if (filemtime($cache_path) clear($key); */
    return FALSE;
    }
    I just don’t get WHY the cache should clear a value which is expired for PARTICULAR CALLER?
    Let’s consider a dialog:
    – Customer A
    Hey, Cache! Bring me the value of this key, but take into account – I don’t those stale values older than an hour, ok?
    – Cach
    Here it is, sir!

    – Customer A
    Thanks, Cachie! Bye!

    – Customer B
    Bring me the value of this key! But don’t bother to give anything older than five minutes! Only freshest things would fit my taste!

    – Cache
    I found the value, but I’m sorry, it was ten minutes old. So I knew you don’t need it, and I wiped it out.

    – Customer C
    Cache! Give me the value of this key. You know me, I don’t show up frequently here. Anything fresher than twelve hours would be kosher enough…

    – Cache
    Uhhh! Oooops! Mister B was here 5 minutes earlier…

    – Customer C
    Soooo?!..

    – Cache
    It was too stale for him…

    – Customer C
    and…

    – Cach
    I have deleted it!…
    (Both crying…Act drop)

    • My requirements didn’t include variable cache expiration for individual buckets. I suppose that could be handy, but in practice for how this class works it ends up being the same thing. If Customer B requests something new and doesn’t get it in my example it will go ahead and re-generate it and copy over the cache. Customer C will go ahead and get the data that B generated. The net result is the same as if you kept the old cache around since it would have been re-written. I suppose it’s not necessary to clear it if it will be re-written anyway, but that’s a different debate.

      I was simply trying to cache some API calls so they were only hit a max of once per hour. Nothing fancy required :).

  4. Sergey says:

    Anyway, this is a great peace of code, and my fiction story depicts just ONE possible scenario. It was just kinda joke, so never *really* mind ;)
    Cheers!

  5. Shimon Crown says:

    Just the thing I need for my mashup. Thanks.

  6. simon says:

    Hi

    thanks for the code

    I’m no code genius but does this lock cached files or serve an already generated page at page expiration.?

    thanks

    • JG says:

      At page expiration you get nothing back from the cache so you need to fetch it from wherever the non-cached data is (a database most commonly).

  7. Farhad Khan says:

    Really appreciate you publishing this piece of code. Very handy. However I agree with Sergey on his comment about removing the clear() step. Consider the following scenario:

    Client A: Triggers clear();
    .. in the mean time .. Client B: Triggers get();
    Client A: Triggers set();

    There is a very good chance for client B to not get any data at all, since the file has been cleared.

    Thanks.

    • JG says:

      If no data is returned by the get() operation you should just fetch it from the non-cached source (DB, API, whatever). Worst case you end up fetching from the non-cached source more than you need to. Clearing is a standard part of caching and is required for any operation that would invalidate the cached value. PHP’s APC has apc_delete(), memcached has delete(), etc etc.

  8. Serhat says:

    Hello there,
    I just used this peace of code in my 10k daily unique hit website. Before any DB cache site was coming up in 10 seconds or so now its flying. Thank you for quick reliable script.
    I also added a cron which deletes cached files every two hours. I didn’t want to end up with thousands of files in cache directory.

    I will use this script whenever i need DB cache (If i am not using any framework such as ZEND)

    Thanks again

    • JG says:

      Glad to hear it’s working well for you. If you use it like I described you shouldn’t need to worry about clearing out with cron. The get() method will delete the cache file if it’s older than the time you set.

  9. Pere says:

    Hi there!

    I used your code as a starting point for my needs :)! Very usefull!!

    Thanks you!

  10. simon says:

    Hi John

    If I wanted to skip caching of certain on page elements like logged in users how would I be able to exclude them. thanks

  11. Ben says:

    After sifting through the large caching extensions and finding they were not playing well on my environment, I’m very happy using your solution.

    Thanks tons.

  12. Der says:

    what object like media (flash, sound, video), image, and etc. can i cache those too, please reply it be very appreciated.

  13. Der says:

    please i’m a noob i really need help

    • mo says:

      you can use this to cache variables that you have calculated and probably are slow changing and heavily used and save them to a physical file with the identifier ‘key’ you provide. If you’re into caching media, google htaccess expiration headers and you’ll file loads of quick hacks that will set you on your way. This code is probably not what you were looking for i’m guessing.

  14. Ronald van Zon says:

    First of really liked your class.
    Made some changes myself, maybe they can be useful for others to.

    First:
    added:
    private $dir;
    CONST EXPIRATION = 604800; //My case a week before expiring.

    public function exists($key) {
    $cache_path = $this->_name($key);

    if (@file_exists($cache_path)) {
    return true;
    }

    if (filemtime($cache_path) clear($key);
    }

    return false;
    }

    changed:
    if (!@file_exists($cache_path))
    {
    return FALSE;
    }

    if (filemtime($cache_path) clear($key);
    return FALSE;
    }
    to
    if (!$this->exists($key)) {
    return FALSE;
    }

    removed:
    $expiration in function call // you can keep it and modified the check a little bit if you want to give files different expiration times.

    • JG says:

      No need for the CONST business, just change the default to 604800 (that would be expiration = 604800) and never provide an expiration argument. Leaving it in there means that if you do ever have a resource that you need to cache for a different time you don’t have to create a new version of the class to handle it.

      Also, with your exists method I don’t see how the key ever gets cleared–if the file exists you return true which means it will never try the filemtime condition.

  15. Javier says:

    Hi JG, I really love this class and I’ve been using it for a while now, I’ve been having the first problem with a site though, it works fine on my dev server but when I uploaded it to my live server ti stopped working. For what I could see it looks like the cache file is not been created for some reason, do I need to set any special file/folder permissions? I’m using 755 for folders and 644 for files but it’s the same as what I have on my dev server, maybe I need something else enabled in the php.ini config file?

    Thanks!

    • JG says:

      Yes, that sounds like a permissions issue. The exact permissions needed depend on your server setup, but in general the user that Apache runs as should have read/write access to the cache directory. The class is designed to fail silently so that your site doesn’t break if something happens to the cache. You could remove the @ signs which suppress said error messages to see what PHP is getting back when it tries to access the directory. I’d recommend adding them back in once you’re up and running though.

  16. Daniel says:

    By opening the file in ‘wb’ mode in the set method you are trashing the file before getting the exclusive lock. You should open the file in wr mode and truncate the file after acquiring the lock.

    • Daniel says:

      ‘r+’ would be the right mode

      • Daniel says:

        This is what I’m left with:

        public function set($key, $data)
        {
        $cache_path = $this->path($key);

        if (!$fp = fopen($cache_path, ‘r+’))
        {
        return FALSE;
        }

        if (flock($fp, LOCK_EX))
        {
        fwrite($fp, $data);
        ftruncate($fp, ftell($fp));
        flock($fp, LOCK_UN);
        fclose($fp);

        @chmod($cache_path, 0777);

        return TRUE;
        }

        fclose($fp);

        return FALSE;
        }

  17. Jeff_Drumgod says:

    OMG … chmod 777 … #fail
    change this ….

    • JG says:

      Change it to whatever works in your environment. I’m always in a virtualized environment and 777 is fine, there aren’t any other users to worry about and I don’t have to manage groups to get caching to work.

  18. Laura Y. says:

    In the get() method, you return NULL if the file size is less than or equal to zero. I don’t understand why you chose to return NULL. I would return FALSE in this situation; that way the program which invoked this method can get the data from the database. Also, in the program which invokes this method, you failed to check for NULL. If your method is going to return NULL, then you should check for it. This is just my recommendation.

    I appreciate you posting this code for us to use. It has been very helpful to me.

    • JG says:

      FALSE is returned when there is an issue with the cache (it’s not readable). NULL is used when there is no record of the cached entry, which is when you would fetch from the database or whatever you’re caching. This is similar to how the official Memcached PHP extension has the getResultCode() method that lets you know if the key existed or not (by returning Memcached::RES_NOTFOUND), but without requiring a separate method.

  19. Leo Cabral says:

    Hi JG.

    Try this on public function get:

    (…)
    if (filesize($cache_path) > 0)
    {
    $cache = unserialize(gzuncompress(stripslashes(fread($fp))), filesize($cache_path));
    }
    else
    (…)

    …and this on public function set:

    (…)
    if (flock($fp, LOCK_EX))
    {
    fwrite($fp, addslashes(gzcompress(serialize($data))));
    flock($fp, LOCK_UN);
    }
    else
    (…)

    Works for me. Still fast but smaller cache files.

    Thanks for the code.

  20. turkish says:

    slightly different approach to file caching: https://github.com/mpapec/simple-cache

  21. Ken Le says:

    Try to use http://www.phpfastcache.com , this class support Files , APC, XCache and Memcached. Very simple and don’t need to end up with a lot of editting.

  22. Ken Le says:

    try this class, it support file cache too . http://www.phpfastcache.com , also have X-Cache, Memcache, APC Cache

Leave a Reply