11

I can't decide if I'm missing something or this should be a bug. With the recent changes to Drafts/Revisions in Craft 3.7 it's not clear to me how to distinguish between New and Updated Entries using the craft\elements\Entry::EVENT_AFTER_SAVE

I've run several tests and documented what I think the key variables I need to be looking at are:

available new/updated entry information

Things I've noticed:

  • Some scenarios trigger the save behavior multiple times
  • The craft\elements\EVENT_AFTER_SAVE::$isNew value just relates to new elements in the db and has nothing to do with published, live entries.
  • The elements that match a "live" scenario also have Draft IDs, so using something like ElementHelper::isDraftOrRevision($entry) doesn't help rule them out
  • In the instances where I'm updating a Live Entry it appears a 'default' scenario exists

However, both New and Updated entries look very similar and the only way I see to distinguish between them is to watch the full request and identify the second 'default' scenario after the first 'live' scenario is triggered.

This feels complicated. Am I missing anything that would make it easier to distinguish between the New and Updated Entry scenarios in the Entry::EVENT_AFTER_SAVE event?

1
  • 2
    I think it would be helpful to specify a Craft version in the question. Craft 3.7 introduced a new entry edit process, as well as concepts such as canonical elements, modified fields and attributes, provisional drafts, etc. github.com/craftcms/cms/blob/develop/…
    – Ben Croker
    Jul 20 at 15:43
10

If you want to run some code when changes are published for an entry, you’re going to want to check !ElementHelper::isDraftOrRevision($entry), which has been the case since Craft 3.2. Those saves only use the essentials scenario because validation has already been run at that point, on the draft that is being applied to the entry.

Here’s a full rundown of what happens behind the scenes for each of the entry actions (tailored for 3.7.5+):

Press “New entry” from Entries index page:

  1. An unpublished draft is created
    • isDraft: true
    • isProvisionalDraft: false
    • isCanonical: true
    • firstSave: false
    • scenario: essentials

Make additional changes to the unpublished draft

  1. Unpublished draft is saved
    • isDraft: true
    • isProvisionalDraft: false
    • isCanonical: true
    • firstSave: false
    • scenario: essentials

Press “Create entry” from the Edit Entry page

  1. Draft is updated with any last-minute changes in the POST data and fully validated
    • isDraft: true
    • isProvisionalDraft: false
    • isCanonical: true
    • firstSave: false
    • scenario: live
  2. Draft is resaved as a normal entry
    • isDraft: false
    • isProvisionalDraft: false
    • isCanonical: true
    • firstSave: true
    • scenario: essentials

Start editing the Current revision of an entry

  1. The entry is duplicated as a provisional draft
    • isDraft: true
    • isProvisionalDraft: true
    • isCanonical: false
    • firstSave: false
    • scenario: essentials

Make additional changes to the provisional draft

  1. Provisional draft is saved
    • isDraft: true
    • isProvisionalDraft: true
    • isCanonical: false
    • firstSave: false
    • scenario: essentials

Press “Save” on the provisional draft

  1. Draft is updated with any last-minute changes in the POST data and fully validated
    • isDraft: true
    • isProvisionalDraft: true
    • isCanonical: false
    • scenario: live
  2. Draft is merged into the canonical entry
    • isDraft: false
    • isProvisionalDraft: false
    • isCanonical: true
    • firstSave: false
    • scenario: essentials

Press “Create a draft” on the Current revision (not a provisional draft)

  1. The entry is duplicated as a (normal) draft
    • isDraft: true
    • isProvisionalDraft: false
    • isCanonical: false
    • firstSave: false
    • scenario: essentials

Press “Create a draft” on a provisional draft

  1. The draft is resaved with isProvisionalDraft = false
    • isDraft: true
    • isProvisionalDraft: false
    • isCanonical: false
    • firstSave: false
    • scenario: essentials

Press “Apply draft” on a (normal) draft

  1. Draft is updated with any last-minute changes in the POST data and fully validated
    • isDraft: true
    • isProvisionalDraft: false
    • isCanonical: false
    • firstSave: false
    • scenario: live
  2. Draft is merged into the canonical entry
    • isDraft: false
    • isProvisionalDraft: false
    • isCanonical: true
    • firstSave: false
    • scenario: essentials
8
  • I'll take some time and review this and follow up if I still can't figure out how to distinguish between new and updated entries. I was using !ElementHelper::isDraftOrRevision($entry) in Sprout Email, which I believe you helped me lean on after 3.2, but have folks reporting bugs again after this 3.7. Jul 20 at 16:22
  • Thanks for the detailed answer Brandon!! Is it intended behaviour that all fields are marked as dirty when a "draft is merged into the canonical entry"?
    – Ben Croker
    Jul 20 at 16:43
  • And no fields are marked dirty when a "draft is resaved as a normal entry"...
    – Ben Croker
    Jul 20 at 17:12
  • 4
    Thanks Brandon! I’d love to see this answer expanded upon and added to the Craft docs, especially since behaviour has changed in recent Craft versions.
    – Ben Croker
    Jul 24 at 9:15
  • 1
    Yeah, we’re working on it :) Jul 24 at 13:02
6

Brandon's answer was a helpful starting point for me but I think the answer goes deeper and has more scenarios to consider. Here's my attempt at clarifying that:

Historic Context

Before Drafts and Revisions were Elements, this was simple. When a new Element was created before and after save events had an $isNew property and if it was true you could assume an Element was being saved the first time. If it was false an Element was being updated. Drafts and Revisions were saved in separate tables, using a separate process.

I believe in Craft 3.2, Drafts and Revisions get updated and become more like Elements in the way they store their data. Rather than their content being stored as a JSON blob in their own unique workflow, they start getting saved like Elements where they have a primary table that stores their ID data and their content gets stored in the content table like other Elements.

At this point things get more confusing and $element->resaving becomes a more important variable to know about because if you are running a task that re-saves all Elements, you are not only resaving all primary content in your database you are also potentially re-saving a bunch of Drafts and Revisions too. (Make sure any loosly written email triggering scripts are tended to before updating past 3.2, or when Craft resaves all Elements during it's upgrade process to 3.2, you might be triggering lots of emails!) I believe $isNew continued to mean the same thing.

In Craft 3.7, Craft made a change to keep the entry ID consistent between its unpublished draft and the real entry (#9104) which had the unintended consequence of changing the meaning of $isNew in the events where it was used. Brandon writes: "Before 3.7, the unpublished draft was duplicated into a normal entry, with a new ID, therefore $isNew was true." However, after 3.7, $isNew is only true when the initial unpublished draft is auto-saved in the control panel creating the first instance of the element in the db.

This was addressed in Craft 3.7.5 where a $firstSave property is introduced for entries.

The above summary is based on my experience and best recollections, so do your testing. My main goal here was to get a lot of these items written down so anybody else that runs into troubles and needs to figure out this stuff might find more information than I did while trying to make sense of it all. If anybody wants to confirm or correct specific version numbers, timeframes, or behaviors, please edit away.

For those new to Craft post 3.7.5, there will likely be less confusion or need to care about this than those of us that were working with the CMS from 1.x and 2.x and had to break down our original assumptions of what was going on here.

New and Updated Elements

Below I present a way to target new Elements and Updated Elements. These examples come from a discussion with Brandon Kelly about the topic here.

New Elements

To identify new elements, use the $element->firstSave property.

The craft\base\Element::EVENT_AFTER_PROPAGATE method only triggers once if you have a multi-site setup so you'll need less logic than if you use EVENT_AFTER_SAVE which gets triggered after Drafts, Revisions, and other multi-site versions of an Element getting saved.

Here's an example of an event that will trigger once when a new Element is saved:

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

Event::on(
    Entry::class,
    Element::EVENT_AFTER_PROPAGATE,
    function(ModelEvent $event) {
        /** @var Entry $entry */
        $entry = $event->sender;
        
        if ($entry->firstSave && $entry->getStatus() === Entry::STATUS_LIVE) {
            // boom
        }
    }
);

Updated Elements

For updated Elements, we need to check a few more things as there can be several scenarios where Elements get updated (canonical elements, drafts, revisions, multi-site). In addition to making sure we don't have a new element, and the element is not a Draft or Revision, you probably also want to make sure to not trigger anything when there is a mass re-save event such as when you update the URL Format of a Section and all Elements in that Section are re-saved. To avoid the ResaveElements queue job scenario we check for !$element->resaving.

Below is a helper method that would return true or false if an Element is being updated.

public function isUpdatedElement($element): bool
{
    return
        !$element->firstSave &&
        !ElementHelper::isDraftOrRevision($element) &&
        !$element->resaving;
}

Be sure to test. And note that each Element will have different "statuses" so you may need to target a status that is more meaningful to your scenario.

There may be a few other limitations to be aware of. In the case of trying to match a new, live Entry Element the $entry->firstSave && $entry->getStatus() === Entry::STATUS_LIVE check above would not match a scenario where an author created a new entry and marked it as disabled before they saved it for the first time. In that case, the disabled status of the new entry would not match your check, and only when the author updated the Entry Element to be enabled and re-saved would the element return true for something like the isUpdatedElement() method.

Similarly, saving a new Entry Element with a postDate in the future would return false for the $entry->firstSave && $entry->getStatus() === Entry::STATUS_LIVE check. No action takes place when a future date becomes a date in the past on the calendar, this type of "new entry" scenario would require some other method to be identified, such as using a cron-job.

List of Things that happen

While Brandon's list of actions in his answer is a helpful starting point if you ever need to dig into this more than the examples above can address, there's more to it so I would be careful to treat that list as comprehensive. Here's my own list of actions that I ran into while testing.

  • Press “New entry” from Entries index page
  • Make additional changes to the unpublished draft
  • Press “Create entry” from the Edit Entry page
  • Press “Command+S” from the Edit Entry page (save new entry)
  • Start editing the Current revision of an entry
  • Make additional changes to the provisional draft
  • Press “Save” on the provisional draft
  • Press “Command+S” from the Edit Entry page (save existing entry with no edits)
  • Press “Create a draft” on the Current revision (not a provisional draft)
  • Press “Create a draft” on a provisional draft
  • Press “Apply draft” on a (normal) draft
  • Enable a disabled Entry Element via Element Action
  • Disable an Entry Element via Element Action
  • Trigger a ResaveElements Task (a simple way to test is to update the URL Format in a Section)
  • When an Entry is propagated (If you run multi-site, every time you save a canonical entry you may be triggering an addition save for every other site. If you have a section where 10 Sites can be edited, you may be triggering 10 Elements to be saved behind the scenes)
  • Programmatic creation (new & updated scenarios may vary if you are creating Elements via custom code, depending on what methods you are using)
  • Guest Entries plugin (As an example of how custom code may differ, the Guest Entry Plugin calls Craft::$app->getElements()->saveElement($entry) directly vs sending the request to the EntriesController::actionSaveEntry method which preps a few things before calling the Elements service saveElement method.

Some related discussions

1
  • Thanks for this great write-up! I will definitely be referring to this a lot …
    – MoritzLost
    Aug 16 at 8:02
2

Ben's historical explanation is 100% accurate, as is Brandon's technical explanation.

Here is a practical explanation...

Much of the time, you won't want this event triggered for Drafts and Revisions. If not explicitly listening for Drafts/Revisions, you can simply exclude them based on the example in the docs...

if (ElementHelper::isDraftOrRevision($entry)) {
    // don’t do anything with drafts or revisions
    return;
}

This will give you the behavior that most people expect.

Filtering out Drafts & Revisions means that you will only ever see Published Entries. This means that there are realistically only two scenarios where your event will be fully fired...

  1. When an Entry is published for the very first time. ($entry->firstSave is set to true)
  2. When an existing published entry is re-saved. ($entry->firstSave is set to false)

That's basically it! Suppressing Drafts & Revisions makes it much easier to wrap your head around how this event operates.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.