Challenge #6 – The Chicken or the Egg

8 January 2019 Solved Php Difficult

A com­mon thing you might want to be informed about is when an entry’s status is changed, but it is deceiv­ingly hard to detect. This chal­lenge involves writ­ing a mod­ule to solve this prob­lem in as eleg­ant a way as possible.

Chal­lenge

The chal­lenge is to cre­ate a mod­ule that detects and logs whenev­er an entry’s status is changed after being saved. It should achieve this using the events in the Ele­ments ser­vice and should out­put to the log file as follows.

[info][craftcodingchallenge] Entry 1234 status changed from "disabled" to "pending".
[info][craftcodingchallenge] Entry 1234 status changed from "pending" to "live".
[info][craftcodingchallenge] Entry 1234 status changed from "live" to "disabled".

Although there are sev­er­al pos­sible solu­tions, this may be an oppor­tun­ity to finally get your hands dirty with a fea­ture of Yii and attach a cus­tom Beha­vi­or to entries that have been saved, nuf said.

Rules

The mod­ule must listen for and detect whenev­er an entry’s status is changed after being saved. Whenev­er it detects a status changed, it should out­put it to Craft‘s main log file as described above. It should not rely on any plu­gins and the code will be eval­u­ated based on the fol­low­ing cri­ter­ia in order of priority:

  1. Read­ab­il­ity
  2. Brev­ity
  3. Per­form­ance

There­fore the code should be read­able and easy to under­stand, using nat­ive Craft com­pon­ents wherever pos­sible. It should be con­cise and non-repet­at­ive, but not at the expense of read­ab­il­ity. It should be per­form­ant, but not at the expense of read­ab­il­ity or brevity.

Tips

The craft\services\Elements class con­tains all the events you need to detect a status change, how­ever you will need to use them in combination.

Every install­a­tion of Craft cre­ated using the Craft pro­ject as doc­u­mented in the install­a­tion instruc­tions comes with a modules/Module.php file. You can use this file as a start­ing point or read the guide on how to build a mod­ule and cre­ate your own.

For an in-depth explan­a­tion of mod­ules you can read the art­icle Enhan­cing a Craft CMS 3 Web­site with a Cus­tom Mod­ule.

Thanks to Oliv­er Stark for sug­gest­ing this challenge.

Solution

Our goal here is to cre­ate two event listen­ers, one that is triggered before an entry is saved and anoth­er that is triggered after an entry is saved. We’ll keep track of the entry’s status before and after it is saved, so that we can determ­ine wheth­er the status is changed in the process.

Set­ting up the basic file struc­ture, class auto­load­ing and applic­a­tion con­fig for a mod­ule is covered in the docs. We begin with the basic out­line of a mod­ule class.

<?php
namespace modules;

class Module extends \yii\base\Module
{
    public function init()
    {
        parent::init();

        // Custom initialization code goes here...
    }
}

The first step is to cre­ate an array $_entryStatuses as a private prop­erty of the class. We will use this to keep track of entry statuses before they are saved, so that we can com­pare them with the entry statuses later.

class Module extends \yii\base\Module
{
    private $_entryStatuses = [];

Next we cre­ate an event listen­er that will be called whenev­er the Elements::EVENT_BEFORE_SAVE_ELEMENT event is triggered. We do this in the init() meth­od of the mod­ule (so that it will be attached to every request to the CMS) using Event::on and pass in 3 para­met­ers: the class name which con­tains the event; the name of the event; and a hand­ler that will handle the logic of what we want the event to do. The hand­ler can be any­thing that is callable and we will use an anonym­ous func­tion (or clos­ure) to handle the logic.

Event::on(Elements::class, Elements::EVENT_BEFORE_SAVE_ELEMENT, function() {});

Look­ing at the source code of the event, we can see that it passes in a new object of type ElementEvent with 2 para­met­ers: the ele­ment and a boolean rep­res­ent­ing wheth­er the ele­ment is new.

// Fire an 'afterSaveElement' event
if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_ELEMENT)) {
    $this->trigger(self::EVENT_AFTER_SAVE_ELEMENT, new ElementEvent([
        'element' => $element,
        'isNew' => $isNewElement,
    ]));
}

So we can update our event hand­ler func­tion to accept a para­met­er of type ElementEvent which we will call $event.

Event::on(Elements::class, Elements::EVENT_BEFORE_SAVE_ELEMENT, function(ElementEvent $event) {});

We can now begin adding the logic. We first get the ele­ment which is avail­able as a prop­erty of the event. Then we ensure that the ele­ment is an entry by check­ing if it is an instance of the Entry class and exit with a return state­ment if it is not. Next we check if this is a new entry using the event’s isNew attrib­ute and exit with a return state­ment if it is not, as there is no point in try­ing to find the status of an ele­ment that did not pre­vi­ously exist. 

Finally, we get the ori­gin­al entry from data­base and add its status to the $_entryStatuses array. Since the array is a class prop­erty, we access it as $this->_entryStatuses. We use the ori­gin­al entry’s ID as the key and its status as the value, so that we can eas­ily ref­er­ence it later.

The reas­on we fetch the ori­gin­al entry from data­base instead of using the provided $event->element, is that $event->element rep­res­ents the element’s state as it is about to be saved. What we need is the status of the ele­ment as it exists in its ori­gin­al state and the only way we can do that is by fetch­ing it from the data­base. We use the getEntryById() meth­od of the Entries ser­vice for convenience.

Event::on(Elements::class, Elements::EVENT_BEFORE_SAVE_ELEMENT, function (ElementEvent $event) {
    // Ignore any element that is not an entry
    if (!($event->element instanceof Entry)) {
        return;
    }
    
    // Ignore entries that are new
    if ($event->isNew) {
        return;
    }
    
    // Get original entry from database
    $originalEntry = Craft::$app->entries->getEntryById($event->element->id, $event->element->siteId);
    
    // Add entry's status to array
    $this->_entryStatuses[$originalEntry->id] = $originalEntry->status;
});

We’ve set up the event to store entry statuses before entries are saved. Next we’ll cre­ate an event listen­er that will be called whenev­er the Elements::EVENT_AFTER_SAVE_ELEMENT event is triggered. All we need to do now is com­pare the entry’s status with its pre­vi­ous status, as stored in the $_entryStatuses array. If the value is dif­fer­ent, or if the value does not exist in the array (because it is a new entry), then we log the difference.

Event::on(Elements::class, Elements::EVENT_AFTER_SAVE_ELEMENT, function (ElementEvent $event) {
	// Ignore any element that is not an entry
    if (!($event->element instanceof Entry)) {
        return;
    }
    
    // Get the stored status for this element ID (or a blank string if not set)
	$statusBeforeSave = empty($this->_entryStatuses[$event->element->id]) ? '' : $this->_entryStatuses[$event->element->id];
	
	// Compare the element's status with the status before save
    if ($event->element->status != $statusBeforeSave) {
	// Log the difference
        Craft::info(
            Craft::t('app', 'Entry {id} status changed from {before} to {after}.', [
                'id' => $event->element->id,
                'before' => $statusBeforeSave,
                'after' => $event->element->status,
            ]), 
            'craftcodingchallenge'
        );
    }
});

Put­ting it all togeth­er, we get the solu­tion in a single, rather com­pact class.

<?php
namespace modules;

use Craft;
use craft\elements\Entry;
use craft\events\ElementEvent;
use craft\services\Elements;
use yii\base\Event;

class Module extends \yii\base\Module
{
    private $_entryStatuses = [];
	
    public function init()
    {
        parent::init();
    
        Event::on(Elements::class, Elements::EVENT_BEFORE_SAVE_ELEMENT, function (ElementEvent $event) {
            // Ignore any element that is not an entry
            if (!($event->element instanceof Entry)) {
                return;
            }

            // Ignore entries that are new
            if ($event->isNew) {
                return;
            }

            // Get original entry from database
            $originalEntry = Craft::$app->entries->getEntryById($event->element->id, $event->element->siteId);

            // Add entry's status to array
            $this->_entryStatuses[$originalEntry->id] = $originalEntry->status;
        });

        Event::on(Elements::class, Elements::EVENT_AFTER_SAVE_ELEMENT, function (ElementEvent $event) {
            // Ignore any element that is not an entry
            if (!($event->element instanceof Entry)) {
                return;
            }

            // Get the stored status for this element ID (or a blank string if not set)
            $statusBeforeSave = empty($this->_entryStatuses[$event->element->id]) ? '' : $this->_entryStatuses[$event->element->id];

            // Compare the element's status with the status before save
            if ($event->element->status != $statusBeforeSave) {
                // Log the difference
                Craft::info(
                    Craft::t('app', 'Entry {id} status changed from {before} to {after}.', [
                        'id' => $event->element->id,
                        'before' => $statusBeforeSave,
                        'after' => $event->element->status,
                    ]), 
                    'craftcodingchallenge'
                );
            }
        });
    }
}

Anoth­er approach that could be taken, as hin­ted at above, is to cre­ate and attach a cus­tom beha­vi­or to entries that have been saved. Beha­vi­ors are a key concept in Yii and essen­tially allow you to inject prop­er­ties and meth­ods into an ori­gin­al class. 

In this case, we will attach the beha­vi­or to any entry before it is saved and prompt it to save its ori­gin­al status in a prop­erty. We make the prop­erty pub­lic so that oth­er classes can access it and we give it a blank string as the default value. The meth­od that we will call, as well as the prop­erty to store the ori­gin­al status, are what we will add in the form of a new class that extends the Behavior class.

class EntryStatusBehavior extends Behavior
{
    public $statusBeforeSave = '';

    public function onBeforeSaveStatus()
    {
        // Get entry from behavior's owner
        $entry = $this->owner;

        // Get original entry from database
        $originalEntry = Craft::$app->entries->getEntryById($entry->id, $entry->siteId);

        // Save entry's status
        $this->statusBeforeSave = $originalEntry->status;
    }

    public function onAfterSaveStatus()
    {
        // Get entry from behavior's owner
        $entry = $this->owner;

        // Compare the entry's status with the status before save
        if ($entry->status != $this->statusBeforeSave) {
            // Log the difference
            Craft::info(
                Craft::t('app', 'Entry {id} status changed from "{before}" to "{after}".', [
                    'id' => $entry->id,
                    'before' => $this->statusBeforeSave,
                    'after' => $entry->status,
                ]), 
                'craftcodingchallenge'
            );
        }
    }
}

Note how we get the entry above using $this->owner. The behavior’s own­er is the class that it was pre­vi­ously attached to.

What we’ve essen­tially done is moved some of the logic from the main plu­gin class into the beha­vi­or that will be attached to every entry that is saved. This res­ults in the fol­low­ing solu­tion which removes the need for the $_entryStatuses prop­erty in the main mod­ule class, since each entry now keeps track of its status in the $statusBeforeSave prop­erty. Both classes are shown togeth­er for readability.

<?php
namespace modules;

use Craft;
use craft\elements\Entry;
use craft\events\ElementEvent;
use craft\services\Elements;
use yii\base\Behavior;
use yii\base\Event;

class Module extends \yii\base\Module
{
    public function init()
    {
        parent::init();
    
        Event::on(Elements::class, Elements::EVENT_BEFORE_SAVE_ELEMENT, function (ElementEvent $event) {
            // Ignore any element that is not an entry
            if (!($event->element instanceof Entry)) {
                return;
            }

            // Attach behavior to element
            $event->element->attachBehavior('chickenegg', EntryStatusBehavior::class);
            
            // Call onBeforeSaveStatus if not a new element
            if (!$event->isNew) {
                $event->element->onBeforeSaveStatus();
            }
        });

        Event::on(Elements::class, Elements::EVENT_AFTER_SAVE_ELEMENT, function (ElementEvent $event) {
            // Ignore any element that is not an entry
            if (!($event->element instanceof Entry)) {
                return;
            }

            // Call onAfterSaveStatus if element has the behavior
            if ($event->element->getBehavior('chickenegg') !== null) {
                $event->element->onAfterSaveStatus();
            }
        });
    }
}

class EntryStatusBehavior extends Behavior
{
    public $statusBeforeSave = '';

    public function onBeforeSaveStatus()
    {
        // Get entry from behavior's owner
        $entry = $this->owner;

        // Get original entry from database
        $originalEntry = Craft::$app->entries->getEntryById($entry->id, $entry->siteId);

        // Save entry's status
        $this->statusBeforeSave = $originalEntry->status;
    }

    public function onAfterSaveStatus()
    {
        // Get entry from behavior's owner
        $entry = $this->owner;

        // Compare the entry's status with the status before save
        if ($entry->status != $this->statusBeforeSave) {
            // Log the difference
            Craft::info(
                Craft::t('app', 'Entry {id} status changed from "{before}" to "{after}".', [
                    'id' => $entry->id,
                    'before' => $this->statusBeforeSave,
                    'after' => $entry->status,
                ]), 
                'craftcodingchallenge'
            );
        }
    }
}

Sim­il­ar solu­tions: Oliv­er Stark.


Per­son­ally, I’ve encountered the require­ment to detect status changes in sev­er­al cus­tom plu­gin builds, often enough to won­der if it deserves its own pack­age. So while put­ting togeth­er the solu­tion to this chal­lenge, I went on a little tan­gent and ended up build­ing an exten­sion that trig­gers its own events whenev­er the status of any ele­ment (not just entries) changes. That makes it ideal as a help­er exten­sion for oth­er Craft mod­ules and plu­gins. It is avail­able on git­hub and will be a good fol­low-up read for any­one who has made it this far. View the Ele­ment Status Events extension.

So after all of that, which did indeed come first, the chick­en or the egg? Well, the mod­ule above cer­tainly doesn’t help us in answer­ing that ques­tion, or does it? No, it def­in­itely does not. Com­puters don’t fare well when race con­di­tions are involved.

Submitted Solutions

  • Oliver Stark