Handling Entry Saves

In Craft, drafts and revisions are elements, just like their live versions. While this provides a clean, consistent way of working with versioned content in the control panel, templates, and API, it means that some events—particularly those emitted when saving entries—occur more frequently than you might expect.

The examples in this article are geared toward entries, but can be applied any of Craft’s built-in element types—or those provided by plugins!

The most common way to be notified about a change happening to an entry is via the Element::EVENT_BEFORE_SAVE event or Element::EVENT_AFTER_SAVE event:

use craft\base\Element;
use craft\elements\Entry;
use craft\events\ModelEvent;
use yii\base\Event;

Event::on(
    Entry::class,
    Element::EVENT_BEFORE_SAVE,
    function(ModelEvent $e) {
        /* @var Entry $entry */
        $entry = $e->sender;

        // ...
    }
);

This shell is valid for a variety of circumstances under which an element is “saved,” including the initial creation of an entry, an auto-save, or merging changes. What if you only want to act on changes to a live entry? In most cases, you’ll only need to check whether the entry is a draft or revision, and bail early:

use craft\base\Element;
use craft\elements\Entry;
use craft\events\ModelEvent;
use craft\helpers\ElementHelper;
use yii\base\Event;

Event::on(
    Entry::class,
    Element::EVENT_BEFORE_SAVE,
    function(ModelEvent $e) {
        /* @var Entry $entry */
        $entry = $e->sender;

        if (ElementHelper::isDraftOrRevision($entry)) {
            // We don’t care about these, so just skip it!
            return;
        }

        // Ok, it's a “canonical” entry!
        // ...
    }
);

The same applies to the element-type-agnostic Elements::EVENT_BEFORE_SAVE_ELEMENT and Elements::EVENT_AFTER_SAVE_ELEMENT—but you’ll check against $event->element instead of $event->sender.

Additional States #

Elements have a few properties and methods that can help distinguish states and hone in on precisely the kinds of updates you want to respond to. The following states may not apply to every site, configuration, or element type, but form the foundation of most comparisons you’ll do against the $->sender or $e->element in a save handler:

  • firstSave — Whether the element is being saved for the first time in a “normal” state (not as a draft or revision). See the note below about element visibility!
  • isNewForSite — The element is being saved to the site ($element->siteId) for the first time.
  • propagating and propagatingFrom — The element is being saved in the context of propagating another site's version of the element.
  • duplicateOf — If the element is being duplicated, this will be the original element.
  • resaving — When an element is being resaved in bulk (say, due to its section settings being changed, or manually via the CLI or a queue job), this will be true.
  • isNewSite — When an element is being propagated to a new site—typically in the process of resaving entries after a site is created in response to project config changes.
  • mergingCanonicalChanges — Set to true when a draft element is being updated with compatible changes from its canonical element. This happens automatically whenever an editor visits a draft’s edit screen in the control panel.
  • updatingFromDerivative — Set to true when changes from a derivative are being applied to a canonical element. The new canonical element is treated as a duplicateOf the draft or revision.

An element may also be “enabled” or “disabled” ($element->enabled) in any of the above circumstances, which means that checking against firstSave is not guaranteed to represent “live” or published content.

These transient qualities are joined by a handful of descriptions of the element in relation to its other versions:

  • Canonical — The primary, authoritative version of an element. Note that prior to an element’s firstSave, it can be both a draft and canonical! (See: isCanonical or getIsCanonical())
  • Derivative — The opposite of canonical; a revision or draft. (See: isDerivative or getIsDerivative())
  • Drafts and Revisions — Unique IDs tying a derivative element to records in the drafts or revisions tables, respectively. (See: draftId and revisionId)
  • Saved — A draft is “unsaved” for a brief time between when it is created and the first change is auto-saved to it. Unsaved drafts are not differentiable from saved drafts except in element queries, so that they may be filtered out of element indexes to avoid clutter. (See: craft\elements\db\ElementQuery::$savedDraftsOnly)
  • Outdated — Whether a derivative has unmerged changes from its canonical element. (See: ElementHelper::isOutdated())
  • Nested — Nested elements have a single “primary owner,” but may belong to multiple drafts and revisions—Craft does this to cut down on duplicate, unchanged elements. (See: getOwnerId())

If you are only concerned about changes to top-level entries, you can exclude nested entries from your handler like this:

/* @var Entry $entry */
$entry = $e->sender;

// Skip drafts and revisions:
if (ElementHelper::isDraftOrRevision($entry)) {
    return;
}

// Ignore nested elements:
if ($entry->getOwnerId()) {
    return;
}

Events’ isNew property is only an indication that the element that is about to be saved (or was just saved) is being persisted to the database for the first time. In the “before save” event, it will not yet have an ID ($element->id); in the “after save” event, it will, but Craft emits it with the same isNew value so that there is a way to know whether it was.

Scenarios + Validation #

We use Yii’s scenarios to distinguish which properties and values are validated in each state. The scenario property will always be set to one of the core SCENARIO_* constants on craft\base\Element (or of a subclass, if you are handling saves for a different element type).

/* @var Entry $entry */
$entry = $e->sender;

// Live?
if ($entry->scenario !== Entry::SCENARIO_LIVE) {
    return;
}

The best way to add validation rules to an element is with the dedicated EVENT_DEFINE_RULES event—but you can prevent a save for any reason by setting $e->isValid = false; in your handler:

/* @var Entry $entry */
$entry = $e->sender;

if (!strstr($entry->title, 'cool')) {
    $entry->addError('title', Craft::t('site', 'Title isn’t cool enough!'));
    $e->isValid = false;
}

Further Reading #

Applies to Craft CMS 5, Craft CMS 4, and Craft CMS 3.