Welcome to the Dark Side of Plugins in CakePHP

Posted by Felix Geisendörfer, on Jun 24, 2006 - in PHP & CakePHP » Core & Hacking

Deprecated post

The authors of this post have marked it as deprecated. This means the information displayed is most likely outdated, inaccurate, boring or a combination of all three.

Policy: We never delete deprecated posts, but they are not listed in our categories or show up in the search anymore.

Comments: You can continue to leave comments on this post, but please consult Google or our search first if you want to get an answer ; ).

Important: This is no official way to use plugins and also no complete step by step tutorial for the things I do with plugins. This post is aimed at advanced CakePHP users trying to get more out of plugins.

Working with plugins in CakePHP is tons of fun and I had good success with it so far. However, there were two things I struggled with: Inter-Plugin Communication as well as filter callbacks.

I want to begin to talk about filter callbacks. In SpliceIt!, I want plugins to be independent pieces of useful functionality that are very simple to integrate (just drop the folder into app/plugins). Out of the box, CakePHP plugins seem to be capeable of providing this structure, but at one point I hit a difficulty:

Plugin Callbacks / Hooks

Imagine you want to have a Statistics plugin, that logs every hit on your website and provides a nice interface for viewing those statistics. Doing the interface is easy in CakePHP, but for logging each hit, your plugin would need to be called every time an action is requested. Now, you can do this by using $this->requestAction(..) in your AppController's beforeFilter, but if you start to have lots of plugins that can be around 10-20 dispatching actions for every hit and performance might suffer. Another drawback to this strategy is, that you will always have to change code in your AppController to integrate a new plugin, which doesn't seem like a very RAD approach to me.

So in order to streamline such plugin callbacks, I created a function inside SpliceIt!, that allows plugins to hook into any AppController event, such as beforeFilter, afterFilter, beforeRender, etc. in order to make their own changes to the controller. So a Themes plugin can easily change the Controller::view and a Statistics plugin can make calls to a Model.

Here is the function I use for triggering those event's, if you want to see the complete implementation, I suggest you to checkout the splice_it.php from the current trunk of SpliceIt!.

php
  1. /**
  2.  * This function calls a specific hook out of any plugin's hooks.php that matches $pluginFilter
  3.  * The list of hooks.php files get's cached for a certain time depending on the value of DEBUG.
  4.  * The 3rd argument &$caller has to be a reference to the caller/variable that get's affected by
  5.  * the Hook.
  6.  *
  7.  * @param string $hook
  8.  * @param string $pluginFilter
  9.  * @param mixed $caller
  10.  */
  11. function callHooks($hook, $pluginFilter = '.+', &$caller)
  12. {
  13.     // pluginHooks contains an array of plugins that provide a hook File
  14.     static $hookPlugins = array();
  15.    
  16.     if (empty($pluginFilter))
  17.         $pluginFilter = '.+';
  18.        
  19.     $params = func_get_args();
  20.    
  21.     // Get rid of $hook, $pluginFilter and &$caller in our $params array
  22.     array_shift($params);
  23.     array_shift($params);
  24.     array_shift($params);
  25.        
  26.  
  27.     if (empty($hookPlugins))
  28.     {
  29.         $cachePath = 'hook_files';
  30.            
  31.         if (DEBUG==3)
  32.             $cacheExpires = '+5 seconds';
  33.         elseif (DEBUG==1 || DEBUG==2)
  34.             $cacheExpires = '+60 seconds';
  35.         else
  36.             $cacheExpires = '+24 hours';
  37.            
  38.         $hookFiles = cache($cachePath, null, $cacheExpires);
  39.        
  40.         if (empty($hookFiles))
  41.         {
  42.             uses('Folder');        
  43.             $Folder =& new Folder(APP.'plugins');
  44.             $hookFiles = $Folder->findRecursive('hooks.php');
  45.            
  46.             cache($cachePath, serialize($hookFiles));
  47.         }        
  48.         else
  49.             $hookFiles = unserialize($hookFiles);
  50.                    
  51.        
  52.         foreach ($hookFiles as $hookFile)
  53.         {
  54.             list($plugin) = explode(DS, substr($hookFile, strlen(APP.'plugins'.DS)));                
  55.             require($hookFile);
  56.            
  57.             $hookPlugins[] = $plugin;
  58.            
  59.             if (preg_match('/'.$pluginFilter.'/iUs', $plugin))
  60.             {
  61.                 $hookFunction = $plugin.$hook.'Hook';
  62.                 if (function_exists($hookFunction))
  63.                 {
  64.                     call_user_func_array($hookFunction, array_merge(array(&$caller), $params));
  65.                 }
  66.             }
  67.         }        
  68.     }
  69.     else
  70.     {
  71.         foreach ($hookPlugins as $plugin)
  72.         {
  73.             if (preg_match('/'.$pluginFilter.'/iUs', $plugin))
  74.             {
  75.                 $hookFunction = $plugin.$hook.'Hook';                    
  76.                 if (function_exists($hookFunction))
  77.                 {
  78.                     call_user_func_array($hookFunction, array_merge(array(&$caller), $params));
  79.                 }
  80.             }                  
  81.         }
  82.     }
  83. }

So now the only modification that needs to be made to the AppController, is to call this function for each filter. Here is an example for the beforeFilter:

php
  1. class AppController extends Controller
  2. {
  3.     function beforeFilter()
  4.     {
  5.         callHooks('beforeFilter', null, $this);
  6.     }
  7. }

So if you now want to make a Themes plugin you can simply create a file called hooks.php inside app/plugins/themes/ and make it look like this:

php
  1.  
  2. function themesBeforeFilterHook(&$controller)
  3. {    
  4.     if (file_exists(VIEWS.'theme.php'))
  5.     {
  6.         if ($controller->view=='View');
  7.             $controller->view = 'Theme';
  8.        
  9.         if (empty($controller->theme))
  10.             $controller->theme = 'default';
  11.     }
  12.     else
  13.     {
  14.         trigger_error('Themes Plugin present, but no theme.php file found in app/views/ ');
  15.     }
  16. }    
  17.  

I currently use those hooks for UrlRewrite (via $from_url in routes.php), AppController::beforeFilter(), AppController::__construct() and some other important points in my application. However, you can also make plugins trigger their own event's, like blogPostBeforeCreate and such.

Anyway, you remember how I told you, that one could avoid using requestAction for plugin communication? Here is what my current approach for SpliceIt! looks like:

Inter-Plugin communication

Generally spoken Controller::requestAction() isn't a bad way to exchange data between controllers. It's a clean interface and you don't have to plan in advance what data should be exchangeable and what data should not. However, there are a couple problems with it. The first and most obvious problem is, that every time you use requestAction(), the entire dispatching process is executed again, which is almost like having a second hit on your site (well not quite as bad, but still). In a normal application this isn't that big of a problem, since there won't be more then 1-3 requestAction's executed per page which doesn't hurt performance that bad. But if you have a system of plugins where you can't share Models,Views and Components as easily as you can in a regular app, you might need up to 20++ requestAction's per page to make things work. And at this point it really get's inefficiant. Because creating instances of Controllers, Models, and Components over and over again takes a lot of cpu cycles.

Another drawback to requestAction() is, that when a Controller/Action or View is missing, CakePHP will render an error page and execute exit; leaving no way of error handling to you. You could create your own AppError handler and change this behavior, but I didn't like this approach that much.

So what I figured was, that the best way of exchanging data between plugins, would be to have special ApiControllers, that do nothing but manage the exchange of data between plugins. They would be normal controllers hidden from the public and only one instance of them would be created when needed, and then shared amongst all other (plugin) controllers. Those ApiControllers normally wouldn't have any Views coupled to them, and therefor only be MVC pieces in your app.

So far I have a working implementation of this ApiController pattern of mine in SpliceIt! and it works like a charm. Performance made a significant jump (3-4x faster) compared to requestAction, and the code looks a lot prettier. I'll try to share the most significant parts of it now, but you should definitly checkout the SpliceIt! trunk for getting a deeper inside into the entire process.

First of all, I'll show you the SpliceItApiController, that I use as the base class for all my ApiController's. Since the ApiController's are sort-of Singletons I added a getInstance() function to them:

php
  1. class SpliceItApiController extends SpliceItAppController
  2. {
  3.     var $autoRender = false;
  4.    
  5.     function __construct($plugin)
  6.     {
  7.         $this->plugin = $plugin;
  8.    
  9.         parent::__construct();
  10.     }
  11.    
  12.   function &getInstance() {
  13.         return SpliceIt::getApiInstance($this->name);
  14.   }
  15. }

You don't have to know about the SpliceItAppController for now, just imagine it to be your normal AppController.

Now here is how one of this ApiController's could look like:

php
  1. class UsersApi extends SpliceItApiController
  2. {
  3.     var $name = 'Users';
  4.     var $uses = array('User');
  5.  
  6.     function addUser($user)
  7.     {
  8.         if ($this->User->save($user))
  9.         {
  10.             return $this->User->id;
  11.         }
  12.         else
  13.             return false;
  14.     }    
  15.  
  16.     function removeUser($id)
  17.     {                
  18.         if ($this->User->delete($id))
  19.             return $id;
  20.         else
  21.             return false;
  22.     }
  23.    
  24.     // ... More functions
  25. }

Now when you want to add a User using the UsersApi in one of your controllers, you can simply do it like this:

php
  1. class FooController extends SpliceItAppController
  2. {
  3.     var $name = 'Foo';
  4.     var $uses = array();
  5.     var $apis = array('Users');
  6.  
  7.     function bar()
  8.     {
  9.         $user = array('name' => 'Jim');
  10.         $this->UsersApi->addUser($user);
  11.     }
  12. }

Now the thing that is still missing, is the way how $this->UsersApi actually get's loaded. I use my own AppController called SpliceItAppController and it contains a function like this:

php
  1. class SpliceItAppController extends AppController
  2. {
  3.     var $apis = array();
  4.  
  5.     function constructClasses()
  6.     {
  7.         // Load all Apis used in this controller
  8.         if (!empty($this->apis))
  9.         {
  10.             if (is_array($this->apis))
  11.             {
  12.                 foreach ($this->apis as $api)
  13.                 {
  14.                     list($api) = SpliceIt::extractApiAndPlugin($api);
  15.                    
  16.                     $apiClass = $api.'Api';
  17.                     $this->$apiClass =& SpliceIt::getApiInstance($api);
  18.                 }
  19.             }
  20.             else
  21.             {
  22.                 list($api) = SpliceIt::extractApiAndPlugin($this->apis);
  23.                
  24.                 $apiClass = $api.'Api';
  25.                 $this->$apiClass =& SpliceIt::getApiInstance($api);
  26.             }
  27.         }        
  28.  
  29.         parent::constructClasses();
  30.     }
  31. }

And here is how SpliceIt::getApiInstance() looks like:

php
  1. function &getApiInstance($api)
  2. {
  3.     SpliceIt::loadApi($api);
  4.    
  5.     list($api, $plugin) = SpliceIt::extractApiAndPlugin($api);          
  6.  
  7.     $apiClass = $api.'Api';
  8.    
  9.     uses('class_registry');
  10.    
  11.     $classKey = 'SpliceIt[Apis]::'.$apiClass;
  12.     if (!ClassRegistry::isKeySet($classKey))
  13.     {
  14.         $apiInstance = &new $apiClass($plugin);
  15.         $apiInstance->constructClasses();
  16.        
  17.     foreach($apiInstance->components as $c)
  18.     {
  19.       if (isset($apiInstance->{$c}) && is_object($apiInstance->{$c}) && is_callable(array($apiInstance->{$c}, 'startup')))
  20.       {
  21.         $apiInstance->{$c}->startup($apiInstance);
  22.       }
  23.     }
  24.        
  25.         $apiInstance->beforeFilter();
  26.            
  27.         ClassRegistry::addObject($classKey, $apiInstance);
  28.  
  29.         return $apiInstance;              
  30.     }
  31.     else
  32.     {
  33.         $apiInstance = &ClassRegistry::getObject($classKey);
  34.        
  35.         return $apiInstance;
  36.     }
  37.  
  38. }

Now you see that all of this isn't that easy to do, and if your project isn't aimed at becoming all that big and using tons of plugins you can easily go with requestAction(). But if you are trying to make a heavily modularized application like I do with SpliceIt! you might find yourself in need of using similiar strategies as the ones presented above. If you have any questions concerning the code above, or SpliceIt! in general, feel free to ask I'll try to answer as good as possible ; ).

--Felix Geisendörfer aka the_undefined