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.
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.
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:
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.
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.
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.
Solution submitted by Oliver Stark on 9 January 2019.
<?php namespace modules;
use craft\elements\Entry;
use craft\events\ElementEvent;
use craft\services\Elements;
use yii\base\Behavior;
use yii\base\Event;
/**
* Class EntryStatusChangeDetector
*/
class EntryStatusChangeDetector extends \yii\base\Module
{
public function init()
{
parent::init();
Event::on(
Elements::class,
Elements::EVENT_BEFORE_SAVE_ELEMENT,
function (ElementEvent $event) {
if (!($event->element instanceof Entry)) {
return;
}
// Attach behavior to store the state
$event->element->attachBehavior('status', EntryStatusBehavior::class);
// Get status of existing Entry
if (!$event->isNew) {
$new = $event->element;
$old = \Craft::$app->getElements()->getElementById($new->getId(), get_class($new));
// Remember previous status
$event->element->setStatusBeforeSave($old->getStatus());
}
}
);
Event::on(
Elements::class,
Elements::EVENT_AFTER_SAVE_ELEMENT,
function (ElementEvent $event) {
// Is the Behavior attached?
if ($event->element->hasMethod('getStatusBeforeSave')) {
$before = $event->element->getStatusBeforeSave();
$after = $event->element->getStatus();
if ($before != $after) {
$entry = $event->element->getId();
\Craft::info(\Craft::t('app', "Status changed from '{before}' to '{after}' - Entry: {entry}", [
'before' => $before,
'after' => $after,
'entry' => $entry
]), 'craftcodingchallenge');
}
}
}
);
}
}
/**
* Class EntryStatusBehavior
*
* (Directly in the module to avoid multiple files for the challenge)
*/
class EntryStatusBehavior extends Behavior
{
protected $statusBeforeSave;
public function setStatusBeforeSave(string $status)
{
$this->statusBeforeSave = $status;
}
public function getStatusBeforeSave(): string
{
return $this->statusBeforeSave;
}
}