Positive cache expiry with CakePHP

Using core CakePHP functionality to implement positive cache expiry

I'm currently sat on the plane on the way back from CakeFest. It was a really enjoyable event in a great city and I think the organisers should be very proud of the event. A big thank you to them for all their hard work. I'm also very grateful for all the positive feedback I received following my talk entitled CakePHP at a Massive Scale on a Budget .

I wasn't very happy with the final piece of the talk where I was hoping to demonstrate positive cache expiry using core CakePHP functionality. As I've got eight hours to kill I thought I'd explain now.

What is positive cache expiry?

For those who didn't catch the talk I'll quickly explain what I mean by positive cache expiry. Often web developers simply cache data until it expires or simply delete cached items when things are updated. This is fine in most circumstances but can cause problems when your site needs to instantly update. A news site, for example. We have to cache listings of the latest news, features, reviews and blog articles on the homepage. When new articles are added we want them to instantly appear in the listing on the homepage.

I came up with what needed to be a very complicated solution to this when writing CyclingNews but you can add it to your CakePHP application fairly simply.

Our database

We have a table in our website called articles. Here is the SQL for it.

CREATE TABLE articles (
  id int(10) unsigned NOT NULL AUTO_INCREMENT,
  name varchar(255) NOT NULL,
  handle varchar(255) DEFAULT NULL,
  strapline varchar(255) DEFAULT NULL,
  article_type_id int(10) unsigned NOT NULL,
  publish_date datetime NOT NULL,
  body mediumtext NOT NULL,
  created datetime DEFAULT NULL,
  modified datetime DEFAULT NULL,
  PRIMARY KEY ( id ),
  UNIQUE KEY handle ( handle ),
  KEY article_type_id ( article_type_id ),
);

The field article_type_id signifies whether the article is a news, feature, review or blog article.

Our model

Fat models make our positive cache expiry much easier. Because we use the Article::getLatest() method to get the latest articles throughout the site we can easily setup an automatic cache update system with it.

class Article extends AppModel {
  var $name = 'Article';
  var $displayField = 'name';

  var $cachedMethods = array(array( 'getLatest', 'article_type_id' ));

  function getLatest($article_type_id = null, $force = false)
  {
    if (!$force) {
      $data = Cache::read('articles_latest_' . $article_type_id);
      if ($data !== false) {
        return $data;
      }
    }

    $params = array( 'conditions' => array( 'Article.published_state_id' => 4 ),
             'order' => array( 'Article.publish_date' => 'DESC' ),
             'limit' => 10 );

    if ($article_type_id) {
      $params['conditions']['Article.article_type_id'] = $article_type_id;
    }

    $data = $this->find('all', $params);

    Cache::write('articles_latest_' . $article_type_id, $data);

    return $data;
  }
}

Our website controller

We're using a separate controller to keep the website logic away from CMS logic. It's not really necessary for this example because we are only displaying articles on the homepage but for more complicated sites and in combination with something like LazyModel, it allows us to generate pages slightly faster.

class WebsitesController extends AppController {

  var $name = 'Websites';
  var $helpers = array('Javascript');

  var $uses = array();

  function beforeFilter()
  {
    $this->layout = 'website';
  }

  function home() {
    $this->loadModel('Article');

    $this->set('latestNews', $this->Article->getLatest(1));
    $this->set('latestFeatures', $this->Article->getLatest(2));
    $this->set('latestReviews', $this->Article->getLatest(3));
    $this->set('latestReports', $this->Article->getLatest(4));
    $this->set('latestPreviews', $this->Article->getLatest(5));
    $this->set('latestBlogs', $this->Article->getLatest(6));
  }
}

Our app_model.php

To enable us to do the automatic cache updating we've added the following AppModel::afterSave() method to app_model.php.

class AppModel extends Model {
  var $cachedMethods = array();

  function afterSave($created)
  {
    $this->cacheUpdate = true;

    $data = $this->read(null, $this->data[$this->name]['id']);

    Cache::write('model' . $this->name . '_' . $data[$this->name]['handle'], $data);

    foreach ($this->cachedMethods as $method) {
      $args = array();

      if (count($method) > 1) {
        for ($i = 1; $i < count($method); $i++) {
          $args[] = $data[$this->name][$method[$i]];
        }
      }

      $args[] = true; // last argument is always true to force update

      call_user_func_array(array($this, $method[0]), $args);
    }

    return true;
  }
}

So how does it work?

You update a news article. News articles have an article_type_id of 1. When AppModel::afterSave() is called it looks in the $cachedMethods array for methods that need to be updated. The Article model has the following in $cachedMethods:

var $cachedMethods = array(array( 'getLatest', 'article_type_id' ));

This tells the cache updating method to run the method Article::getLatest() and pass the article_type_id of the article that is being saved as the first parameter. This will put the latest news listing in the cache when a news article is updated. e.g. The following will be run:

$this->getLatest(1, true);

If you look back at the Article::getLatest() method you'll see the optional second parameter allows us to bypass the cache and fetch the latest data. We'll now have the latest data in the cache.

I hope that better explains what I was trying to get across at the end of my talk.