Keeping your listeners in order

A couple of days ago I blogged about how Doctrine's SoftDelete behavior can keep other listeners' preDelete() hooks from firing; after a bit of coding this morning, I believe I have a solution.

In my initial setup, I had applied the SoftDelete behavior in the model itself via the following YAML config:

Post:
  actAs:
    SoftDelete:

Then, in my Zend_Application bootstrap, I was assigning my ACL listener like so:

$listener = new Blahg_Plugin_Doctrine_Record_Listener_Acl($acl);
$table->addRecordListener($listener);

Since behaviors are assigned very early on in the process (during the setTableDefinition() method), this chain of events resulted in the following order of listeners:

  • SoftDelete
  • Acl

Unfortunately, since SoftDelete changes the nature of the event from DELETE to UPDATE, this meant that by the time the Acl listener fired, the event looked like an update, and the update hooks got fired instead of the delete hooks.

The solution? Change the order in which the listeners are registered by implementing my ACL listener as part of an assignable behavior. That way I could assign both listeners in the actAs part of the YAML schema, like so:

Post:
  actAs:
    Blahg_Plugin_Doctrine_Template_Aclable:
    SoftDelete:

As long as SoftDelete is the last behavior attached, the ACL listeners will fire correctly.

This requires a couple of modifications to the original code. For starters, we need to modify the constructor of our listener to accept an array of options instead of just a Zend_Acl object:

class Blahg_Plugin_Doctrine_Record_Listener_Acl 
    extends Doctrine_Record_Listener
{
    protected $_options = array();

    public function __construct(array $options)
    {
        $this->_options = $options;
    }
}

Since we're no longer forcing constructor injection of the ACL object, we also need to allow the listener to go and find one from the application bootstrap if necessary. As a result, we modify getAcl() and setAcl() accordingly:

    public function getAcl()
    {
        if (!array_key_exists('acl', $this->_options) || null === $this->_options['acl']) {
            $fc = Zend_Controller_Front::getInstance();
            $bootstrap = $fc->getParam('Bootstrap');
            $bootstrap->bootstrap('acl');
            $this->setAcl($bootstrap->getResource('acl'));
        }
        return $this->_options['acl'];
    }

    public function setAcl(Zend_Acl $acl)
    {   
        $this->_options['acl'] = $acl;
        return $this;
    }

This still allows us to inject the ACL as necessary for tests and such, while allowing for graceful failover to the bootstrap object if one has not been provided (caveat: you have to register your bootstrap with your front controller instance for this to work).

In order to use this modified listener as a behavior, we also need to define a (very) simple Doctrine_Template implementation. All it'll need to do is register the listener during setTableDefinition():

class Blahg_Plugin_Doctrine_Template_Aclable extends Doctrine_Template
{
    protected $_options = array(
        'acl' => null,
    );  
            
    public function setTableDefinition()
    {       
        $this->addListener(new Blahg_Plugin_Doctrine_Record_Listener_Acl($this->_options));
    }   
}

And that's it! From here, just add the new Aclable behavior to your schema file, regenerate your model classes, and everything should happen in the right order from then on. Problem solved.

Comments

Really amazing series!

I had already decided on Zend_ACL as a plugin ACL solution for a Symfony (using Doctrine) project I'm working on, and was looking for the best way to integrate things - additonally, many of my models are already using the SoftDelete behavior. Your series has given me invaluable information and insight into how to move forward. Well written, clear, and concise...and it was like you already knew what I was trying to do. Awesome work. I can't thank you enough...gotta go - need to find my bookmark button.

Quick question

Hi Adam, Great series of articles - thanks for sharing the knowledge.

I have implemented something very close to this and ran into a problem. When loading fixtures, it invokes the listener which fails because there is no role in context. In a web context, the ability to create new records would require an admin role.

I resolved this by adding an isWebContext() check which checks for the existence of $_SERVER['HTTP_HOST'] and only runs the acl check if this returns true.

I'd be interested to know if you have a more elegant solution.

Thanks, Peter

(No subject)

Adam Jensen

@Peter: I had a similar issue when setting up my cron service. I got around it by having my cron service's Zend_Application bootstrapper register a special system user object with Zend_Auth:

$auth = Zend_Auth::getInstance(); $auth->getStorage()->write(new Blahg_Model_User_System());

The rest of my app (including the listeners) gets the role object for ACL checks directly from Zend_Auth …so any time an ACL check is done during the cron service run, this system user (who has full admin rights) is the user being checked. That way, all the ACL checks pass. Not sure if this is any more elegant than what you're doing, but it definitely gets the job done.

Thanks! Adam

Little problem with Zend_Acl_Resource_Interface

Hi Adam,

I've begun to implement a system in a Symfony/Doctrine project using your series (almost verbatim, as a starting point), and hit a bit of a block. I should also say that while I've built other Symfony/Propel systems, I'm pretty new to Doctrine - suffice it to say that this is my first attempt at writing a custom behavior.

The problem I'm having is in following your preInsert() example, the following line always returns false (line 09 from the original code):

if ($resource instanceof Zend_Acl_Resource_Interface)

I've correctly attached the behavior to the model/resource in question in a yaml file, so replacing the above line with the following line has the desired effect:

if($resource->getTable()->hasTemplate('MySignatureDoctrineTemplateAclable'))

This doesn't appear good enough, because I'm assuming my model needs to actually implement the Zend_Acl_Resource_Interface in order for Zend to work its ACL magic internally. My assumption from your example was that attaching the behavior to a model would have the effect of declaring it to implement the resource interface, but that has not been my experience in practice - so I must me missing something.

Any time that you could take out of your day to help point me in the right direction with this would be very much appreciated. Thanks again for a great series.

(No subject)

Adam Jensen

@stephenrs: sorry it's taken me so long to get back to you about this. You're right about Zend_Acl's expectations; if you pass an object as the resource argument to isAllowed(), it expects it to implement Zend_Acl_Resource_Interface. But as far as I know, there isn't a way to cause Doctrine to automagically declare that a model implements an interface; that's a lower-level language feature that you have to put in yourself.

Since Doctrine's model generation facilities only ever overwrite the "base" models it generates (e.g., Blahg_Model_Post_Base), you can actually manually modify the models themselves (e.g., Blahg_Model_Post) as much as you like. In my app, I add the interface declaration and implement a simple getResourceId() method that returns the name of the resource type: 'Post'. You could get more specific if you like ('Post 3'), but I haven't found that to be necessary in practice.

(No subject)

No problem, and thanks for getting back to me. The app I'm building has requirements for fairly complex user-managed ACLs, so rather than using your example as a way to build the system from the bottom up (while simultaneously writing my first Doctrine behavior - an experiment in itself), I decided it was wiser to start a bit higher level to make sure I fully understand how the pieces all fit together first.

The app also requires that ACLs be persisted in some way, and I think I've come up with a good technique for that (serializing to MySQL). The light bulb did eventually go off in my head, which made me realize I needed to explicitly implement the interface on my models and declare a getResourceId() (the Zend docs helped as well), but for the moment the work is being done by the models themselves - so my controllers are still pretty clean, but it's not quite as elegant as your solution. Since eventually many of my models will need this ACL behavior, I can see a refactoring in the not too distant future where I'll want to push the common logic off to a RecordListener/Behavior, now that I have a much better handle on things. I'll also maybe store default roles/resources/privs in a YAML file or something, rather than using the Symfony equivalent of Zend's bootstrap (Symfony makes parsing YAML really easy).

In any case, thanks again, you definitely helped point me in the right direction.

Add a comment