Behaviors in CakePHP

I didn’t use behaviors for the first 12 months with Cake. I regret it now, because the code is no much cleaner and easier to extend.
Let’s make a test with a simple task. We need some additional statistics to a specific model. We could do that in the controller (very messy!) or with a custom model function. Second option can be used in different views etc. But to cover all kinds of find operations it is likely to get messy as well.
The easiest and cleanest approach is a behavior which goes over every found record, does the calculation, maybe gets some related data and attaches the result to the current record before it reaches the controller.

Workflow

Controller: Model->Behaviours->attach(‘MyStatistics’); # one line of code
Controller: Model->find(all)
Model: find(all)
Behaviour: afterFind() # here we add the statistics
Controller: passes data to view
View: displays data + statistics

The nice byproduct: We won’t have any overhead if we don’t need the statistics. The Behavior will simply not be included.

$model->alias or $model->name?

If we want to use a behavior in many different places and models, we cannot hardcode the model names. We need to dynamically retrieve them.
In most cases it is $this->alias or $model->alias.
$model->name is the original name and can cause errors if you use some non-standard model relations.

example:

$hasMany = array('Receiver' => array('className' => 'User'));

$model->alias is "Receiver" (which the behavior needs)
$model->name still is "User"

Basic set up

class MyStatisticsBehavior extends ModelBehavior {
	var $default = array(
		'fields' => array(),
		'prefix' => 'stat_',
		'primary' => true
	);
	var $config = array();
	/**
	* adjust configs like: $model->Behaviors-attach('MyStatistics', array('fields'=>array('xyz')))
	*/
	function setup(&$model, $config = array()) {
		$this->config[$model->alias] = $this->default;
		$this->config[$model->alias] = array_merge($this->config[$model->alias], $config);
	}
	/**
	* main function
	*/
	function afterFind(&$model, $result, $primary = false) {
		//modify the result here
		return $result;
	}
}

Now we want to do some operation for each of the given fields.
First we need to check if the result exists. In this example i simplified "primary". Usually you can control this with the above settings. In our case we only want the statistics if the model is the primary model (the one which initialized the query).
After that we loop over every record.

function afterFind(&$model, $result, $primary = false) {
	if (!$result || !$primary) {
		return $result;
	}
	
	# check for single of multiple model
	$keys = array_keys($result);
	if (!is_numeric($keys[0])) {
		$this->stats($model, $result);
	} else {
		foreach ($keys as $index) {
			$this->stats($model, $result[$index]);
		}
	}
	return $result;
}

Note that we use "reference" for passing the data. this way we don’t need to return the data from the sub-methods. it is the very same record we modify.

The stats() method:

function stats(&$model, &$data = null) {
	if ($data === null) {
		$data = &$model->data;
	}
	if (empty($data[$model->alias]['id'])) {
		return $data;
	}
	
	if (isset($this->config[$model->alias])) {
		$fields = $this->config[$model->alias]['fields'];
		
		foreach ($fields as $column) {
			$count = $this->_countRecords($model, $data, $column);
			$column = $this->config[$model->alias]['prefix'] . $column;
			
			$data[$model->alias][$column] = $count;
		}
	}
	return $data;
}

Any finally, the _countRecords() method. it could be located in any other model, as well. To simplify again, everythings in the same place.

function _countRecords(&$model, &$data, $column) {
	$id = $data[$model->alias]['id'];
	$count = 0;
	switch ($column) {
		case 'images':
			return $model->Image->find('count', array('contain'=>array(), 'conditions'=>array('user_id'=>$id)));
		case 'friends':
			return $model->Friend->find('count', array('contain'=>array(), 'conditions'=>array('Role.user_id'=>$id)));
		... # of course there could be more complex queries here
	}
	return $count;
}

Other callbacks

Of course you can build more complex behaviors, as well.
Some that modifiy the record prior to saving it. Or doing something after saving a record.

Result

In the controller index (or view):

# We want the stats "images" and "friends"
$this->User->Behaviours->attach('MyStatistics', array('images', 'friends'));
$user = $this->User->find(all, array(...)); # paginated or limited to xx users!
pr($user);

Besides the real database fields the statistics now show up:

[0] => Array
        (
            [Gallery] => Array
                (
                    [id] => 4c979553-1500-4922-8866-07d452b0caef
                    [name] => 'Test'
                    [created] => 2010-09-20 19:09:39
                    [modified] => 2010-09-20 19:09:39
                    [stat_images] => 3
                    [stat_friends] => 1

Available is:

beforeFind()
beforeValidate()
beforeSave()
afterSave()
beforeDelete()
afterDelete()
onError()

Tip: Look at the cake core behaviors. You can learn a lot from those (as will all available code).

Naming conventions

Usually plugins are in adjective-form ("Accessable", "Ratable", …).
Sometimes this sounds awkward: Plugin handling Points => "Pointable"? No – we can simply chose a noun then: "Points". Note the plural form. Models are singular, so use a plural name your behavior. You never know when a new model "Point" is introduced. And we want no conflicts whatsoever.

Issues to be aware of

The ordering can be very important. With Cake2 there is now a "priority" setting you can leverage to create the ordering of callbacks you need.

As of Cake2 there is still no support for callbacks on associated behaviors. There is quite an old ticket on this which is still open.
I personally see no reason not to support the after{Find/Save} callbacks.
But until that happens you might need to find yourself some workaround. For example by manually triggering the necessary callbacks yourself from the AppModel.

1.00 avg. rating (53% score) - 1 vote

Leave a Reply

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.