The Events System of CakePHP

erik-am edited this page Jun 19, 2013 · 3 revisions

The Events System of CakePHP

Written by @ChrisTitos, @erik-am, @ntimal and @wilcowisse.

Introduction

The Events system of CakePHP was introduced in CakePHP 2.1 and is therefore relatively new. The CakePHP 2.1 Migration Guide lists the following description of it:

  • A new generic events system has been built and it replaced the way callbacks were dispatched. This should not represent any change to your code.
  • You can dispatch your own events and attach callbacks to them at will, useful for inter-plugin communication and easier decoupling of your classes.

The second bullet lists the main functionality that the Events subsystem provides and the motivation for its introduction. The Events system allows one to apply the Observer design pattern to avoid highly coupled objects. The Cookbook clearly explains how Events can be used in an application for this purpose.

However, the focus of this document is on the first bullet, namely that the Events system replaced the way callbacks were dispatched. CakePHP has a lot of default callbacks for Model, View, Controller, Behavior, Helper and Component classes, which are now triggered by Events. However, there are concerns that this change has led to a decrease in performance and that the current way is inefficient. In this document, we try to indentify the problems and explore some possible solutions.

Firstly, we will give a short overview of the functionality of Events. Then we compare the way that the callbacks used to be triggered in previous versions to the current approach using Events. After that we present some profiling results. Then we give an overview of the problems and we discuss some solutions. We end with our conclusion.

Dispatching Events and Listening

The Cookbook already explains in detail how Events are used in the general way, but we will give a quick overview of the things that matter to understand the rest of the document.

The most important class is the CakeEventManager. In short, there are two things one can do with it:

  • One can dispatch an event through CakeEventManager::dispatch(CakeEvent event).

    The CakeEvent class provides an abstraction of an event. An event is created using three parameters. An event can be identified by the parameter name. The parameter subject refers to the object executing the particular event. Additional data for the event can be passed through the third parameter payload.

  • One can attach an object that implements the CakeEventListener interface through CakeEventManager::attach(CakeEventListener listener).

    The object is then subscribed to the events that are generated by the instance of CakeEventManager. The implementation should contain a method implementedEvents() that provides a mapping of names of events to methods that should be called when the event is triggered. The CakeEventManager keeps track of all attached listeners.

Callbacks - before and after

Models, Views and Controllers have a number of callbacks. These are empty methods by default, but when those classes are extended by an application, the methods can be overridden. For example, a Controller has a callback called beforeFilter(). A Component for Controllers additionaly has callbacks called initialize() and startup(). A Model has many callbacks, such as beforeSave(), afterSave(), afterDelete(), et cetera. These can be seen as hooks to add additional business logic.

Callbacks used to be called directly in most cases, but with the advent of the Event system in CakePHP 2.1, the callbacks are triggered when certain events are dispatched. To that end, the classes providing callbacks have become event listeners. Below, we compare the new situation to the old one. In the comparison, Models and Behaviors are used as examples, but keep in mind that the same applies to Views and Controllers with their Components and Helpers.

Before (CakePHP 2.0)

The sequence diagram below gives an example of how the callback Model.afterDelete() is triggered. The callback is first executed on each Behavior, if there are any, and then on the Model itself.

Situation before

Some observations:

  • Note that a Model always has a BehaviorCollection, but it can be empty. Therefore, the BehaviorCollection is always triggered.
  • Model.afterDelete() and Behavior.afterDelete() are empty callbacks by default that can be overridden by subclasses.
  • Model.afterDelete() will always be called, even it is empty. The call happens via a direct invocation.
  • The call to Behavior.afterDelete() does not happen via a direct invocation, but via call_user_func_array(). According to comments in the PHP manual, this function is slow. However, it will only be called if there actually is a Behavior.

After (CakePHP ≥ 2.1)

From CakePHP 2.1 onwards, Model, View, Controller classes and their corresponding BehaviorCollection, ComponentCollection and HelperCollection classes have all become event listeners. They all implement implementedEvents(), which maps event names to the actual callback methods. For example, there is a mapping from the event name "afterDelete" to the method Model.afterDelete(). Just as before, these callbacks exist by default and may be empty.

Listeners

As explained, listeners need to be attached to the CakeEventManager. Therefore, each Model passes through the following initialization phase:

Initialization of Model events

Now we return to the example scenario, where the delete() method is called. In the new situation, the following happens:

Example of a dispatch

Some observations:

  • Instead of immediately triggering the BehaviorCollection and calling Model.afterDelete() directly, a CakeEvent with the name "afterDelete" is constructed, which is then dispatched.
  • The CakeEventManager knows that there are two listeners, the Model itself and the BehaviorCollection, and queries their implementedMethods(). It then calls the callback methods that belong to the "afterDelete" event.
  • Model.afterDelete() is no longer called directly, but via call_user_func_array(). This also goes for BehaviorCollection.trigger().

Now, consider a Model that has no Behaviors and has not implemented any callbacks (in other words, their bodies are empty). In this case, only the rightmost lifeline and the corresponding loop in the sequence diagram is gone. This means that the notorious call_user_func_array() is still called twice, whereas it would not be used in the situation before. Furthermore, note that this would not only happen for afterDelete(), but also for beforeDelete(), beforeSave(), et cetera.

So, a very simple application with Model classes with no Behaviors and empty callbacks, would make 2 * #models * #callbacks (8 by default) calls to call_user_func_array(), whereas it would do none in the previous situation. If call_user_func_array() is indeed as slow as mentioned by others, then this might be a source of problems.

Experimental Findings

We performed a number of experiments to compare the performance between CakePHP 2.0 and 2.1 with respect to the callbacks. We used a simple application with only a Controller and a View and another application that also included Components. Finally, we upgraded an actual CakePHP application with many Behaviors, Components and overridden callbacks from version 2.0 to 2.1 and compared the performance before and after.

We expected to see more calls to call_user_func_array() and, partly because of that, we expected to see a significant decrease of the performance.

Our full test setup and the experimental results are described in Results profiling CakePHP event system. Here, we list our main findings:

  • In CakePHP ≥ 2.1, CakeEventManager->dispatch() is among the most time consuming methods. However, in CakePHP 2.0, ObjectCollection->trigger() is among the most time consuming methods. It consumes less time than CakeEventManager->dispatch() in the new version, but the difference is not significant.
  • Indeed, call_user_func_array() is performed much more often (twice as much in the complex application), but again, the difference in performance is not significant.
  • In the complex application that had quite some callbacks, ObjectCollection->trigger() did not perform more time in CakePHP 2.1 than in version 2.0.

Therefore, we conclude from our experiments that the change from callbacks to events between version 2.0 and 2.1 did not decrease the performance significantly. However, this does not imply that there are no performance issues. Though, it implies that, if the Events system is currently slow, then the old implementation of callbacks was also slow.

A second conlusion is that call_user_func_array() did not negatively impact the performance very much, in contrast to what we presumed. Still, a more natural way of invoking the callback methods should be preferred, because call_user_func_array() does not really fit in with Object Oriented Programming.

Problems

So, the good news is that the Events system has not made CakePHP slower than it was. The bad news is that apparently it was already slow in the old situation. We identified a number of problems that are common to both versions:

  1. ObjectCollections such as BehaviorCollection, ComponentCollection and HelperCollection are always attached to the CakeEventManager, even if they are empty. This results in many unneccesary function calls.
  2. Callbacks are always triggered, even if they are empty, which is the default. Considering the fact that there are so many callbacks (especially for Models) and that most applications only use a subset of them, this also results in many unnecessary function calls.

Solutions

  • An alternative is to attach the Objects instead of the ObjectCollections. The following sequence diagram shows this. The number of calls to call_user_func_array() is decreased by 1 per Model, because the collection is no longer an event listener.

    Initialization of Model events (alternative)

    A problem inherent to this solution is that the CakeEventManager->dispatch() function has to call more listeners. But the ObjectCollection->trigger() function is eliminated the same number of times, which will save time.

    Another problem is that the ObjectCollection->trigger() function has some edge cases: the signatures of the behavior and component classes differ from the callback of the corresponding model and controller classes. For example in the Model model.afterFind($query) is called, while in a Behavior behavior.afterFind($model, $query) is called. This is implemented in the ObjectCollection.trigger() function in the following way. If the callback is an instance of CakeEvent, ObjectCollection.trigger() will prepend the subject to the list of arguments, such that the model is passed to the behaviors. However, the callback functions of the Helper classes don't have the subject as argument in contrast to the Behavior and Component classes. That is why the HelperCollection.trigger() overrides the ObjectCollection.trigger() function, and adds an attribute 'omitSubject' to the event. This causes that the subject is not passed as argument to helper classes.

    If we would adopt this behavior in the dispatch() function, we should have to take into account which listeners we are dealing with to determine their signature. A solution to the complex behavior of ObjectCollection->trigger() should be found, to prevent the CakeEventManager->dispatch() function to become overly complex. We consider this in the next bullet point.

  • When an event is dispatched, listeners receive a proper CakeEvent object as an attribute. So in the ideal case, the signature of, for example, the afterFind callback would look like afterFind(CakeEvent $event).

    However, the callback had always looked like afterFind(array $results, boolean $primary = false). To make callbacks compatible with the Events system without breaking backwards compatibility, the following solution was introduced: when implemented methods are attached to the CakeEventManager, one can specify the option 'passParams' => true. This has the result that, when the listener is called, the data from the event object are passed as attributes instead of the event object itself.

    In the previous bullet point, we had the problem that callbacks between Models and Behaviors are different, while they correspond to the same event. In this case, it would be afterFind(Model $Model, array $results, boolean $primary = false) for the behavior.

    A potential solution for this problem, that is in the spirit of the passParams solution, is to introduce yet another option, such as passSubject => true in Behavior.implementedMethods() that denotes that the subject (in this case the Model) should be appended to the passed attributes. However, this is not a very object oriented manner and in this way, everything becomes a bit of a hack. In our opinion, adding all these extra checks for the sake of backwards compatiblity has an impact on maintainability. So there is a trade-off to make.

    We would recommend to remove the passParams option altogether, so that callbacks only receive a CakeEvent object. This makes callbacks compliant with the general event system and reduces all the checks for edge cases. This decision could be made for the next major version, version 3.0. The downside of this move would be that it breaks all existing plug-ins. However, postponing this decision would only increase the technical debt.

Further Observations

The Behaviors, Components and Helpers that are used are stored as an array of strings in the Models and Controllers. They actual Behavior/Component/Helper objects are loaded via ObjectCollection->load(String $name). This creates an instance of the class if it is requested for the first time and returns it on subsequent calls. In this way, the loading of Behaviors, Components and Helpers is deferred. We investigated if this reduces the number of objects that are loaded and found the following:

  • When Dispatcher.invoke() is called on a user request, then the Controller is initialized and all the Components in the ComponentCollection are loaded.
  • All Behaviors in the BehaviorCollection are loaded from the constructor of the Model.
  • All Helpers in the HelperCollection are only loaded when a View is rendered.

So, in practice Components, Behaviors and Helpers are always loaded eventually, except for the rare case where a View is not rendered. Therefore, ObjectCollection may be simplified by always loading its objects.

A loaded object can be either enabled or disabled. A disabled object should not respond to events. However, only the AuthComponent appears to be disabled sometimes.

The ObjectCollection violates the Single Responsibility Principle since it has too many responsibilities: creating the objects, maining states (enabled/disabled), relaying events (ObjectCollection.trigger()). The event relaying should be taken out as recommended under Solutions. Perhaps an Abstract Factory Pattern can be applied so that there is a Factory, instead of the ObjectCollection, responsible for the creation of Behavior, Component and Helpers.

Conclusion

In this document we gave an overview of how the callback methods in CakePHP have become part of the event system and we analysed the differences. Although we found that after the change, CakePHP has not not really performed worse than before, we still found some problems that we pointed out. Solving these could potentially improve the performance, but most of all it would make the code more clean and increase maintainability, because right know there are many different checks in the code for handling different kinds of callbacks.

We gave some pointers to possible solutions. Though we realise that our suggestions are somewhat shallow and that an actual refactoring would require quite some more work, we hope that this document at least makes it easier to discuss the problems, that it may serve as a reference regarding the problems that currently exist and that it may be a starting point for working towards an actual solution for the new major version of CakePHP.