Attach & detach behaviors at run-time in CakePHP Models

Behaviors are one of the best things that have been added to CakePHP 1.2, they allow you to add functionality to your models in a very elegant and modular fashion. They also promote a lot of code reuse.

Perhaps a real life example would illustrate it better. In Cheesecake Photoblog when a new photo was added it was the controller which checked if a proper file was uploaded, resized it and then move it, once this was done the EXIF vendor class was used to extract the EXIF info and store that in the database. The code for the photos/add method was fairly bloated and ugly. However with behaviors, in Cheesecake Photoblog 2.0, we were able to move these two task away from the action and now all that the add action code does is call $this->Photo->save() the attached 'file' and 'exif' behaviors automagically take care of the rest. Also with the file upload handling moved code to a behavior it can be reused with any model which needs to handle file uploads

While writing code for V2.0 I needed to detach some behaviors at run-time. More specifically I did not want the File Upload and EXIF behavior to act during an edit action when the picture was not being replaced! This functionality was not built in to CakePHP 1.2 last I checked and I wanted it NOW - solution? code it!

Detaching behavior was simple in the controller I did

PHP:
  1. unset($this->Photo->behaviors['file']);
  2. unset($this->Photo->behaviors['exif']);

This got me thinking... why not have a method which will do it more elegantly. The result was

PHP:
  1. /**
  2. * Method used to un-set behaviors at run-time
  3. *
  4. * @access public
  5. */
  6. function dontActAs()
  7. {
  8.     // Get method arguments
  9.     $behaviors = func_get_args();
  10.  
  11.     // Loop through method arguments
  12.     foreach($behaviors as $index => $behavior)
  13.     {
  14.         // If method agrument is an array
  15.         if (is_array($behavior))
  16.         {
  17.             // If method agrument contains more than one element then merge it with method arguments
  18.             if (count($behavior)> 0)
  19.             {
  20.                 $behaviors = array_merge($behaviors, $behavior);
  21.             }
  22.  
  23.             // Unset method argument from method arguments
  24.             unset($behaviors[$index]);
  25.         }
  26.     }
  27.  
  28.     // Loop through passed behaviors
  29.     foreach ($behaviors as $behavior)
  30.     {
  31.         // Un-set the behavior
  32.         unset($this->behaviors[$behavior]);
  33.     }
  34. }

If you want to use the method in your controller - do

PHP:
  1. $this->MyModel->dontActAs('mybehav1', 'mybehav2');

or

PHP:
  1. $behaviorsToDetach = array('mybehav1', 'mybehav2');
  2.  
  3. $this->MyModel->dontActAs($behaviorsToDetach);

The benefit of being allowed to code on Open Source projects during work is that you can do what you want and as you want. While just detaching behaviors at runtime would be enough for most projects, For safety I wanted a method to attach a behavior at runtime.

A bit of delving in to cake's core model's constructor code (related to behavior attachment) I ended up with following code

PHP:
  1. /**
  2. * Method used to set behaviors at run-time
  3. *
  4. * @access public
  5. * @param array $behaviors List of behaviors to set at run-time
  6. */
  7. function nowActsAs($behaviors = array())
  8. {
  9.     // If passed behaviour is array and contain more than one element
  10.     if (is_array($behaviors) && count($behaviors)> 0)
  11.     {
  12.         // Initialize behavior's callback methods and then normalize passed behaviors array
  13.         $callbacks = array('setup', 'beforeFind', 'afterFind', 'beforeSave', 'afterSave', 'beforeDelete', 'afterDelete', 'afterError');
  14.         $behaviors = Set::normalize($behaviors);
  15.  
  16.         // Loop through passed behaviors
  17.         foreach ($behaviors as $behavior => $config)
  18.         {
  19.             // Build behavior's class name
  20.             $className = $behavior . 'Behavior';
  21.  
  22.             // Behavior's class file included successfully
  23.             if (loadBehavior($behavior))
  24.             {
  25.                 // If behavior's class object is in registry
  26.                 if (ClassRegistry::isKeySet($className))
  27.                 {
  28.                     // If PHP5 then get object directly
  29.                     if (PHP5)
  30.                     {
  31.                         $this->behaviors[$behavior] = ClassRegistry::getObject($className);
  32.                     }
  33.                     else
  34.                     {
  35.                         // If not PHP5 then get object by reference
  36.                         $this->behaviors[$behavior] =& ClassRegistry::getObject($className);
  37.                     }
  38.                 }
  39.                 else
  40.                 {
  41.                     // If PHP5 then create/get object directly
  42.                     if (PHP5)
  43.                     {
  44.                         $this->behaviors[$behavior] = new $className;
  45.                     }
  46.                     else
  47.                     {
  48.                         // If not PHP5 then create/get object by reference
  49.                         $this->behaviors[$behavior] =& new $className;
  50.                     }
  51.  
  52.                     // Store object in registry
  53.                     ClassRegistry::addObject($className, $this->behaviors[$behavior]);
  54.                 }
  55.  
  56.                 // Call behavior's 'setup' method and then get behavior's map methods
  57.                 $this->behaviors[$behavior]->setup($this, $config);
  58.                 $methods = $this->behaviors[$behavior]->mapMethods;
  59.  
  60.                 // Loop through behavior's map methods
  61.                 foreach ($methods as $method => $alias)
  62.                 {
  63.                     // If map method is not already added in array then add it
  64.                     if (!array_key_exists($method, $this->__behaviorMethods))
  65.                     {
  66.                         $this->__behaviorMethods[$method] = array($alias, $behavior);
  67.                     }
  68.                 }
  69.  
  70.                 // Get behaviour's and model behaviour's class methods
  71.                 $methods = get_class_methods($this->behaviors[$behavior]);
  72.                 $parentMethods = get_class_methods('ModelBehavior');
  73.  
  74.                 // Loop through behavior's class methods
  75.                 foreach ($methods as $m)
  76.                 {
  77.                     // If behavior's class method is not present in model behaviour's class methods then proceed further
  78.                     if (!in_array($m, $parentMethods))
  79.                     {
  80.                         // If behavior's class method is not protected/private, it is not in behavior methods and also not in callback methods
  81.                         if (strpos($m, '_') !== 0 && !array_key_exists($m, $this->__behaviorMethods) && !in_array($m, $callbacks))
  82.                         {
  83.                             // Store behavior's class method details
  84.                             $this->__behaviorMethods[$m] = array($m, $behavior);
  85.                         }
  86.                     }
  87.                 }
  88.             }
  89.         }
  90.     }
  91. }

You can use above method in controller like same as you define your actsAs array

PHP:
  1. $behaviorsToAttach = array
  2. (
  3.     'mybehav1' => array
  4.     (
  5.         'setting11' => 'value11'
  6.         , 'setting12' => 'value12'
  7.     )
  8.     , 'mybehav2' => array()
  9. );
  10.  
  11. $this->MyModel->nowActsAs($behaviorsToAttach);

Of Course - you all know that the above two methods should go into your app_model.php - right?

P.S. Thanks to Dr. Tarique Sani for his help with this article

Update Cakebaker blog tells us that attaching and detaching behaviors on the fly is now built in CakePHP core (we obviously have been doing something right ;)


About this entry