CakePHP Behaviours

 Apr 29, 2013

CakePHP behaviours are great. They’re kind of like Ruby’s mixins with a splash of the observer thrown in.

They are specifically for the model, and allow shared functionality that doesn’t belong in any one model to be, well… shared, across multiple models.

You drop them into a model simply by adding them to an $actsAs instance variable

<?php
class MyModel extends AppModel {

    public $actsAs = array('MyBehavior');
}

Plus they can be extended, or specialsed for the particular model by passing in settings

<?php
class MyModel extends AppModel {

    public $actsAs = array(
        'MyBehavior' => array(
            'setting1' => 'Lorem',
            'setting2' => 22
        )
    );
}

However, I seem to have come across a little gotcha in doing this with some of our custom behaviors.

Namely, it appears as though all models using a behaviour in the same request, will share the same instance of that behaviour. They will not get one each. Therefore, you must be very careful how you write your code to ensure that the settings passed in by each model are respected and not overwritten

From a recent case that led me to this conclusion:

We had 2 models that were loaded during a request.

<?php
class Model1 extends AppModel {
    public $actsAs = array(
        'Serializable' => array(
            'fields' => array('settings')
        )
    );
}

class Model2 extends AppModel {
    public $actsAs = array(
        'Serializable' => array(
            'fields' => array('options')
        )
    );
}

Not massively different apart from the value passed in for fields param

Now for the behavior

<?php
class SerializableBehavior extends ModelBehavior {

    public function setup(&$Model, $settings) {
        $this->settings = $settings;
    }


    public function beforeSave($Model) {
        //snip
    }

    public function afterFind($Model, $results, $primary) {
        //snip
    }
}

What I noticed was that when data from Model2 was requested or saved, the behaviour callbacks didn’t run. Or at least if they did they failed.

When I started to investigate this issue more thoroughly it turned out that the callbacks in the behaviour looked for a field in the data that matched one in the fields array passed in the actsAs. This field was never found. The reason being, the settings from Model1 were over writing the settings from Model2, meaning the behaviour could never work with Model2

In order

  • Model2 instantiated and calls the behaviour passing in a value for fields as the array array('options')
  • Behaviour assigns array('fields' => array('options')); as the value of instance var $settings
  • Model1 instantiated and calls the behaviour passing in a value for fields as the array array('settings')
  • Behaviour assigns new value to its instance var overriding the previous value
  • Method call expecting behaviour to work fails

What made this doubly annoying to track down, is that the unit tests pass. This is due to the tests loading the model and behaviour in exclusion to the rest of the system. In that microcosm of an environment the behaviour works as intended and everything passed, but, and a very big but, put this into the full request and response cycle and other factors cause the same code to fail.

I fixed this by amending the behaviour slightly

<?php
// snip
    public function setup(&$Model, $settings) {
        $this->settings[$Model->alias] = $settings;
    }

so that each models settings are stored against their alias key in the behaviours instance variable. This prevented any over writing.

I hope this information helps someone else, and I can only state that this shows the importance of integration tests as well as unit tests on your code. Sadly as we are slowly applying tests to legacy code in this app, we haven’t yet gotten that far.