Extending CakePHP’s CacheHelper to use Cache Engines

framework-cakephpCakePHP is an MVC PHP framework which can make your life easier and your development several times faster. Despite the fact that it is considered a relatively slow framework, it comes with a large number of Cache Engines (FileCache, ApcCache, Wincache, XcacheEngine, MemcacheEngine and RedisEngine) which can help you improve the speed of your website or PHP application.

The above Cache Engines allow you to cache SQL results, serialized Objects, HTML blocks etc. Unfortunately, despite the fact that CakePHP 2.x supports Full-Page caching (which can improve drastically the speed of the applications), the aforementioned engines are NOT used internally. Instead CakePHP uses the CacheHelper which stores the HTML source directly on webserver’s file system.

Why CakePHP’s current approach is problematic?

This approach is problematic in terms of speed and architecture. First of all, other Cache Engines (such as ApcCache) are significantly faster because they store the cache in Memory. Also from architecture’s point of view, it is better to handle the caching from a single class. Another reason why you don’t want to have the Cached files stored locally on the hard disk of your webserver is when you do Load Balancing, that is when you use multiple Web Servers to host the same website. In this scenario, using Memcache enables you to access the cached pages from all the servers of your cluster. Even though it would be possible to use a network-attached storage file system such as GlusterFS, CephFS or even NFS, this would increase the complexity of your infrastructure and affect the overall speed.

Even though Mark Story (a core developer of CakePHP) has proposed to correct this behavior in 2010, no one has done it so far. Few weeks ago, I bumped into it and I decided to extend the framework in order to use Cache Engines internally for full page caching. I contributed the source to the community, nevertheless unfortunately it has not yet been included in the CakePHP framework (possibly because of their plans to change the way that Caching works on the next version or because I have not bothered to send a pull request on Git. Either way, the problem persists.).

Below I post the PHP code that extends the framework. Note that the actual new code is not more than 15 lines of code, nevertheless due to the way that CakePHP is written, copy-pasting a lot of code from the framework was necessary. Finally note that instead of modifying directly the framework, we chose to extend it by introducing 3 custom classes.

Create a custom CacheHelper

A custom CacheHelper is necessary to force CakePHP to use Cache Engines instead of writing the HTML code directly to the disk:

<?php  
 /**
 * CakePHP Patch: Extending CakePHP's CacheHelper to use Cache Engines
 * http://www.datumbox.com/
 *
 * Copyright 2013, Vasilis Vryniotis
 * Licensed under MIT or GPLv3, see LICENSE
 */
 
//In /app/View/Helper/MyCacheHelper.php we change the way that the cache is written
//=================================================================
  
App::uses('CacheHelper', 'View/Helper');
class MyCacheHelper extends CacheHelper {
     
    /**
    *
    * The below _writeFile() fuction is almost identical to the original with only difference that we don't write directly the files
    * but instead we use Cache::write() to do so.
    * 
    */
    protected function _writeFile($content, $timestamp, $useCallbacks = false) {
        //Below this point the code is the SAME as in CakePHP's method
        //============================================================
        $now = time();
  
        if (is_numeric($timestamp)) {
            $cacheTime = $now + $timestamp;
        } else {
            $cacheTime = strtotime($timestamp, $now);
        }
        $path = $this->request->here();
        if ($path === '/') {
            $path = 'home';
        }
        $prefix = Configure::read('Cache.viewPrefix');
        if ($prefix) {
            $path = $prefix . '_' . $path;
        }
        $cache = strtolower(Inflector::slug($path));
  
        if (empty($cache)) {
            return;
        }
        $cache = $cache . '.php';
        $file = '<!--cachetime:' . $cacheTime . '--><?php';
  
        if (empty($this->_View->plugin)) {
            $file .= "
            App::uses('{$this->_View->name}Controller', 'Controller');
            ";
        } else {
            $file .= "
            App::uses('{$this->_View->plugin}AppController', '{$this->_View->plugin}.Controller');
            App::uses('{$this->_View->name}Controller', '{$this->_View->plugin}.Controller');
            ";
        }
  
        $file .= '
                $request = unserialize(base64_decode(\'' . base64_encode(serialize($this->request)) . '\'));
                $response = new CakeResponse();
                $controller = new ' . $this->_View->name . 'Controller($request, $response);
                $controller->plugin = $this->plugin = \'' . $this->_View->plugin . '\';
                $controller->helpers = $this->helpers = unserialize(base64_decode(\'' . base64_encode(serialize($this->_View->helpers)) . '\'));
                $controller->layout = $this->layout = \'' . $this->_View->layout . '\';
                $controller->theme = $this->theme = \'' . $this->_View->theme . '\';
                $controller->viewVars = unserialize(base64_decode(\'' . base64_encode(serialize($this->_View->viewVars)) . '\'));
                Router::setRequestInfo($controller->request);
                $this->request = $request;';
  
        if ($useCallbacks) {
            $file .= '
                $controller->constructClasses();
                $controller->startupProcess();';
        }
  
        $file .= '
                $this->viewVars = $controller->viewVars;
                $this->loadHelpers();
                extract($this->viewVars, EXTR_SKIP);
        ?>';
        $content = preg_replace("/(<\\?xml)/", "<?php echo '$1'; ?>", $content);
        $file .= $content;
        //Up to this point the code is the SAME as in CakePHP's method
        //============================================================
            
        $cacheEngine='_cake_views_';
        $cacheKey = 'helper_cached_views_'.md5(CACHE . 'views' . DS . $cache);        
        if(!Cache::write($cacheKey, $file, $cacheEngine)) {
            return NULL;
        }
         
        return $file;
    }
}

Create a custom Cache Dispatcher

A custom Cache Dispatcher is required in order to force CakePHP to read the cache from the Cache Engine and not directly from the hard disk:
 

<?php  
/**
 * CakePHP Patch: Extending CakePHP's CacheHelper to use Cache Engines
 * http://www.datumbox.com/
 *
 * Copyright 2013, Vasilis Vryniotis
 * Licensed under MIT or GPLv3, see LICENSE
 */
 
//In /app/Lib/Routing/Filter/MyCacheDispatcher.php we change the way that the cache is written
//============================================================================================
  
App::uses('CacheDispatcher', 'Routing/Filter');
App::uses('ClassRegistry', 'Utility');
  
class MyCacheDispatcher extends CacheDispatcher {
     
    public $priority = 8; //we place smaller priority than its parent to run this Dispatcher before its parent
     
    public function beforeDispatch(CakeEvent $event) {        
        //BELOW this point the code is the SAME as in CakePHP's method
        //============================================================
        if (Configure::read('Cache.check') !== true) {
            return;
        }
         
        $path = $event->data['request']->here();
        if ($path === '/') {
            $path = 'home';
        }
        $prefix = Configure::read('Cache.viewPrefix');
        if ($prefix) {
            $path = $prefix . '_' . $path;
        }
        $path = strtolower(Inflector::slug($path));
  
        $filename = CACHE . 'views' . DS . $path . '.php';
        //Up to this point the code is the SAME as in CakePHP's method
        //============================================================
           
        $cacheEngine='_cake_views_';
        $cacheKey = 'helper_cached_views_'.md5($filename);
        $data = Cache::read($cacheKey, $cacheEngine);
         
         
        if ($data) {
            $controller = null;
            App::import('View','My');
            $view = new MyView($controller);
            $result = $view->renderCacheFromString($data, microtime(true));
            if ($result !== false) {
                $event->stopPropagation();
                $event->data['response']->body($result);
                return $event->data['response'];
            }
        }
         
        return;
    }
}

Create a custom View Class

A custom View Class is necessary in order to force CakePHP to parse the Cache Serialized Object from string instead from a file:

<?php
/**
 * CakePHP Patch: Extending CakePHP's CacheHelper to use Cache Engines
 * http://www.datumbox.com/
 *
 * Copyright 2013, Vasilis Vryniotis
 * Licensed under MIT or GPLv3, see LICENSE
 */
 
//The /app/View/MyView.php extends CakePHP's View class
//======================================================
App::uses('View', 'View');
class MyView extends View {
    /**
    *
    * The renderCacheFromString() is exactly the same us renderCache() function with only difference the fact that we dont 
    * read the contents of the cache from a file, but instead we have it in a string variable.
    * 
    */
    public function renderCacheFromString($out, $timeStart) {
        //simlar to renderCache(). The only difference is that instead of reading the data from file, it takes it directly from a string
        ob_start();
        //the original renderCache() function includes the cached file. The equivalent of including a file is evaluating the string
        eval("?>" . $out . "<?php "); //This is so ugly!!! Find alternatives?
        $out = ob_get_clean();
         
        //BELOW this point the code is the SAME as in CakePHP's method
        //============================================================
        if (preg_match('/^<!--cachetime:(\\d+)-->/', $out, $match)) {
            if (time() >= $match['1']) {
                //@codingStandardsIgnoreStart
                @unlink($filename);
                //@codingStandardsIgnoreEnd
                unset($out);
                return false;
            } else {
                if ($this->layout === 'xml') {
                    header('Content-type: text/xml');
                }
                return substr($out, strlen($match[0]));
            }
        }
        //Up to this point the code is the SAME as in CakePHP's method
        //============================================================
    }
}

Configure your bootstrap.php

Simply modify your bootstrap.php to load the new CacheDispatcher and to configure the way that HTML pages are stored:
 

<?php 
/**
 * CakePHP Patch: Extending CakePHP's CacheHelper to use Cache Engines
 * http://www.datumbox.com/
 *
 * Copyright 2013, Vasilis Vryniotis
 * Licensed under MIT or GPLv3, see LICENSE
 */
 
//In /app/Config/bootstrap.php we add the new cache dispatcher and the view cache configuration
//=============================================================================================

Configure::write('Dispatcher.filters', array(
    'AssetDispatcher',
    //'CacheDispatcher', //Comment out the default one
    'MyCacheDispatcher' //Use the new Dispatcher
));


//This is where you place your cache configuration for the views
Cache::config('_cake_views_', array(
    'engine' => $engine,
    'prefix' => $prefix . 'cake_views_',
    'path' => CACHE . 'views' . DS,
    'serialize' => ($engine === 'File'),
    'duration' => $duration
));

Configure your AppController.php

The last step is to inform your AppController to use your custom View Class and CacheHelper:

<?php 
 /**
 * CakePHP Patch: Extending CakePHP's CacheHelper to use Cache Engines
 * http://www.datumbox.com/
 *
 * Copyright 2013, Vasilis Vryniotis
 * Licensed under MIT or GPLv3, see LICENSE
 */
  
//In /app/Controller/AppController.php we add the custom view class
//=================================================================
  
App::uses('Controller', 'Controller');
class AppController extends Controller {
   public $viewClass = 'My'; //Inform the AppController about the new View Class
   public $helpers=array( //Inform the AppController to use the new CacheHelper
               'Cache' => array('className' => 'MyCache')
          );
}

That’s it!

The idea of the above code is fairly simple. We override the default CacheHelper and CacheDispatcher classes that are responsible for reading/writing the html code directly from/to the filesystem and we make them use the CacheEngine instead. Note that we also need to extend the default View Class in order to make it possible to parse the Cached Object from string and not from file. The only ugly part of the above solution is the use of PHP’s eval() function which in this case is unavoidable due to the way that CakePHP stores the cache.

Disclaimer: Even though the presented solution is part of a larger application which is heavily tested, the above snippets are developed ONLY as a proof of concept for the developers of CakePHP framework and thus THEY ARE NOT TESTED. Minor adaptations might be required to make the code functional. Use the code at your own risk.

Download the Patch

For those of you who are bored of copy pasting the code, I have created the files and put them in a zip (isn’t that nice?). To use the patch just copy the files in the appropriate folders and modify the appropriate files as described above. You can download the code here.

If you like this article, share it on social media and I promise to post more on the future. ;)

About 

My name is Vasilis Vryniotis. I'm a Data Scientist, Software Engineer, Statistics & Machine Learning enthusiast and author of Datumbox Machine Learning Framework. Learn more

Latest Comments
  1. James Alday

    Very nice! I’m currently looking into different ways of caching content that runs on CakePHP. I was a bit disappointed to see the flexibility of cache engines did not extend to their CacheHelper, but this looks like a fairly easy fix. Thanks for the writeup!

  2. Shivakumar

    how do we execute clearCache() in this scenario

  3. Vikas

    Hi

    First of all, thank you so much for this sharing.

    My question is, When we use caching in cakePHP by above example or default one, That generated file cache contain PHP code and variable. This is not PURE html generation.

    When i apply mod_deflate to compression the content. That time caching file does not execute PHP code.

    Kindly help and correct me, if i am wrong.

    Thanks & Regards
    Vikas Gupta

    • Vasilis Vryniotis

      Hi Vikas,

      CakePHP stores dynamic content in cache which includes variables and PHP code that is executed by “including” the code or in my implementation by using eval(). Given that, I would advise you to let CakePHP handle caching for you. Trying to perform caching outside CakePHP can lead to unexpected results and security problems (caching the profile of the user, basket details etc).

  4. Alexander Hofbauer

    Interesting write-up!

    You might want to override Model::_clearCache() to not use clearCache().
    Also, in MyView:33 there’s an “unlink” that definitely shouldn’t be there. I’m passing the key to renderCacheString instead letting it handle reading from / deleting cache entirely.


Leave a Reply

Your email address will not be published. Required fields are marked *


+ 7 = fourteen

You may use these HTML tags and attributes: <a href="" title="" rel=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>