Challenge #6 – The Chicken or the Egg

8 January 2019 Solved Php Difficult

A common thing you might want to be informed about is when an entry’s status is changed, but it is deceivingly hard to detect. This challenge involves writing a module to solve this problem in as elegant a way as possible.

Challenge

The challenge is to create a module that detects and logs whenever an entry’s status is changed after being saved. It should achieve this using the events in the Elements service and should output 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 several possible solutions, this may be an opportunity to finally get your hands dirty with a feature of Yii and attach a custom Behavior to entries that have been saved, ‘nuf said.

Rules

The module must listen for and detect whenever an entry‘s status is changed after being saved. Whenever it detects a status changed, it should output it to Craft‘s main log file as described above. It should not rely on any plugins and the code will be evaluated based on the following criteria in order of priority:

  1. Readability
  2. Brevity
  3. Performance

Therefore the code should be readable and easy to understand, using native Craft components wherever possible. It should be concise and non-repetative, but not at the expense of readability. It should be performant, but not at the expense of readability or brevity.

Tips

The craft\services\Elements class contains all the events you need to detect a status change, however you will need to use them in combination.

Every installation of Craft created using the Craft project as documented in the installation instructions comes with a modules/Module.php file. You can use this file as a starting point or read the guide on how to build a module and create your own.

For an in-depth explanation of modules you can read the article Enhancing a Craft CMS 3 Website with a Custom Module.

Thanks to Oliver Stark for suggesting this challenge.

Solution

Our goal here is to create two event listeners, one that is triggered before an entry is saved and another 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 determine whether the status is changed in the process.

Setting up the basic file structure, class autoloading and application config for a module is covered in the docs. We begin with the basic outline of a module 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 create an array $_entryStatuses as a private property of the class. We will use this to keep track of entry statuses before they are saved, so that we can compare them with the entry statuses later.

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

Next we create an event listener that will be called whenever the Elements::EVENT_BEFORE_SAVE_ELEMENT event is triggered. We do this in the init() method of the module (so that it will be attached to every request to the CMS) using Event::on and pass in 3 parameters: the class name which contains the event; the name of the event; and a handler that will handle the logic of what we want the event to do. The handler can be anything that is callable and we will use an anonymous function (or closure) to handle the logic.

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

Looking at the source code of the event, we can see that it passes in a new object of type ElementEvent with 2 parameters: the element and a boolean representing whether the element 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 handler function to accept a parameter 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 element which is available as a property of the event. Then we ensure that the element is an entry by checking if it is an instance of the Entry class and exit with a return statement if it is not. Next we check if this is a new entry using the event's isNew attribute and exit with a return statement if it is not, as there is no point in trying to find the status of an element that did not previously exist.

Finally, we get the original entry from database and add its status to the $_entryStatuses array. Since the array is a class property, we access it as $this->_entryStatuses. We use the original entry’s ID as the key and its status as the value, so that we can easily reference it later.

The reason we fetch the original entry from database instead of using the provided $event->element, is that $event->element represents the element’s state as it is about to be saved. What we need is the status of the element as it exists in its original state and the only way we can do that is by fetching it from the database. We use the getEntryById() method of the Entries service 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 create an event listener that will be called whenever the Elements::EVENT_AFTER_SAVE_ELEMENT event is triggered. All we need to do now is compare the entry’s status with its previous status, as stored in the $_entryStatuses array. If the value is different, 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'
        );
    }
});

Putting it all together, we get the solution in a single, rather compact 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'
                );
            }
        });
    }
}

Another approach that could be taken, as hinted at above, is to create and attach a custom behavior to entries that have been saved. Behaviors are a key concept in Yii and essentially allow you to inject properties and methods into an original class.

In this case, we will attach the behavior to any entry before it is saved and prompt it to save its original status in a property. We make the property public so that other classes can access it and we give it a blank string as the default value. The method that we will call, as well as the property to store the original 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 owner is the class that it was previously attached to.

What we’ve essentially done is moved some of the logic from the main plugin class into the behavior that will be attached to every entry that is saved. This results in the following solution which removes the need for the $_entryStatuses property in the main module class, since each entry now keeps track of its status in the $statusBeforeSave property. Both classes are shown together 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'
            );
        }
    }
}

Similar solutions: Oliver Stark.


Personally, I've encountered the requirement to detect status changes in several custom plugin builds, often enough to wonder if it deserves its own package. So while putting together the solution to this challenge, I went on a little tangent and ended up building an extension that triggers its own events whenever the status of any element (not just entries) changes. That makes it ideal as a helper extension for other Craft modules and plugins. It is available on github and will be a good follow-up read for anyone who has made it this far. View the Element Status Events extension.

So after all of that, which did indeed come first, the chicken or the egg? Well, the module above certainly doesn’t help us in answering that question, or does it? No, it definitely does not. Computers don’t fare well when race conditions are involved.

Submitted Solutions

  • Oliver Stark