How to Detect Entity Changes in Drupal the Right Way

20 Aug. 2025
Graphic symbolizing changes

The following code snippets should be compatible with Drupal 10+.

Challenges in detecting meaningful entity changes

One working with entities in Drupal answering of the following seemingly simple questions can pose a challange:

Has this entity meaningfully changed since its last revision? Has it changed meaningfully since it was last saved?

Drupal doesn't provide a built-in mechanism for this. That's intentional, because what counts as a change can vary greatly between use cases. While there are ways to hack around the problem—such as comparing $entity->toArray() with $entity->original->toArray()—these approaches fall short quickly.

  • Timestamps, UUIDs, and metadata fields will almost always differ, even when the entity's "meaningful" data hasn't changed.
  • Some values are normalized differently on save (for example, 0 stored as an integer versus false cast as a boolean).
  • Rich text fields often come back from CKEditor with extra markup or formatting quirks, even when the human-readable content is the same.
  • The moderation module forces the creation of new revisions even if there have been no changes since the last version of the entity.

This makes raw array comparisons noisy and unreliable.

Enter the Diff Module

Luckily, Drupal already has a well-established module for detecting meaningful changes between entity revisions: Diff.

The Diff module is designed to highlight what actually changed between two revisions of an entity. It provides a user-friendly UI for visualizing differences, but behind that UI is a powerful service that can be reused programmatically.

The beauty of this approach is that you can first use the Diff UI to figure out what counts as a "real" change for your content type. Then, you can rely on the same comparison logic in your own code to determine whether a current (unsaved) entity is different from the stored version.

This is smarter than reinventing your own comparison logic because:

  • It already handles edge cases with different field types.
  • It lets you refine which differences you care about (text formatting, thumbnails, etc.).
  • It aligns with Drupal's revisioning system, which is how most editors think about changes anyway.

Implementation example: Extending a Content Entity

Here's an example of how you might extend a node type ("project") via a bundle class to add the desired functionality:

my_module.module:

<?php

/**
 * Implements hook_entity_bundle_info_alter().
 */
function my_module_entity_bundle_info_alter(array &$bundles): void {
  if (isset($bundles['node']['project'])) {
    $bundles['node']['project']['class'] = Drupal\my_module\Entity\Project::class;
  }
}

Drupal\my_module\Entity\Project:

<?php

namespace Drupal\my_module\Entity;

use Drupal\node\Entity\Node;

class Project extends Node {

  public function hasChanged(): bool {
    if ($this->isNew()) {
      return TRUE;
    }

    return !empty($this->getChanges());
  }

  public function getChanges(): array {
    $original = \Drupal::entityTypeManager()
      ->getStorage(static::ENTITY_TYPE)
      ->loadUnchanged($this->id());
      
    if (!$original) {
      throw new \LogicException('Cannot compare changes of a new entity.');
    }

    /** @var \Drupal\diff\DiffEntityComparison $comparison_service */
    $comparison_service = \Drupal::service('diff.entity_comparison');
    $diff_items = $comparison_service->compareRevisions($original, $this);

    $definitions = $this->getFieldDefinitions();
    $text_types = ['text', 'text_long', 'text_with_summary'];

    $changes = [];
    foreach ($diff_items as $key => $item) {
      if (!isset($item['#data'])) {
        continue;
      }
      $data = $item['#data'];
      $left = (string) ($data['#left'] ?? '');
      $right = (string) ($data['#right'] ?? '');
      $leftThumb = $data['#left_thumbnail'] ?? [];
      $rightThumb = $data['#right_thumbnail'] ?? [];

      // Parse field name from diff key: <entityId>:<entityType>.<field_name>
      $field_name = NULL;
      if (str_contains($key, ':')) {
        [, $rest] = explode(':', $key, 2);
        $pos = strpos($rest, '.');
        if ($pos !== FALSE) {
          $field_name = substr($rest, $pos + 1);
        }
      }

      // Normalize rich text fields to reduce false positives from editor formatting.
      if ($field_name && isset($definitions[$field_name]) && in_array($definitions[$field_name]->getType(), $text_types, TRUE)) {
        $left = $this->normalizeRichTextForDiff($left);
        $right = $this->normalizeRichTextForDiff($right);
      }

      $changed = ($left !== $right) || ($leftThumb !== $rightThumb);
      if ($changed) {
        $changes[$key] = [
          'label' => $item['#name'] ?? $key,
          'left' => $left,
          'right' => $right,
        ];
        if ($leftThumb || $rightThumb) {
          $changes[$key]['left_thumbnail'] = $leftThumb;
          $changes[$key]['right_thumbnail'] = $rightThumb;
        }
      }
    }

    return $changes;
  }

  protected function normalizeRichTextForDiff(string $html): string {
    if ($html === '') {
      return '';
    }
    $html = str_replace(["\r\n", "\r"], "\n", $html);
    $html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $text = strip_tags($html);
    // Replace common non-breaking space representations with normal space prior to removal.
    $text = str_replace(["\xc2\xa0", '&nbsp;'], ' ', $text);
    // Remove all whitespace (spaces, tabs, newlines, NBSP, etc.).
    $text = preg_replace('/\s+/u', '', $text) ?? '';

    return $text;
  }
}

Now we can call $entity->hasChanged() to see if there have been any meaningful changes since the entity was last saved.

What getChanges() Does

  • It loads the unchanged version of the entity from storage. In certain hooks like hook_ENTITY_update(), one has access to $entity->original without having to fetch it, but this way one can get access to the original entity at any time.
  • It runs the Diff module's compareRevisions() service between the stored entity and the current in-memory entity.
  • It loops through the comparison results and builds a simplified array of fields that differ.
  • For rich text fields, it applies a normalization step to prevent false positives.

The return value is an array of all actual changes, including optional thumbnails for media or images. This can be helpful to debug the diff module settings and our wrapper.

Why normalizeRichTextForDiff() Matters

If your editors save content via the entity form with CKEditor, the HTML is not guaranteed to come back exactly the same. CKEditor might:

  • Add or remove <p> tags,
  • Normalize quotes,
  • Insert extra line breaks or whitespace,
  • Replace spaces with non-breaking spaces,

—none of which represent a meaningful content change.

If you're also updating fields programmatically (for example, importing text from an external API), you don't want CKEditor's HTML "messiness" to trigger a false positive.

By normalizing HTML down to its raw text form, normalizeRichTextForDiff() ensures that the comparison focuses only on meaningful text differences, not editor quirks.

This normalization is optional and only necessary if you expect human editors and programmatic updates to both interact with the same rich text fields.

Extra example: Only create new revisions on meaningful entity changes

If using the moderation module, Drupal forces the creation of new revisions on each entity change. We can overwrite this behavior with our diff functionality and by overriding the moderation handler.

my_module.module:

<?php

/**
 * Implements hook_entity_type_alter().
 * 
 * Use custom moderation handler for the node entity type.
 */
function my_module_entity_type_alter(array &$entity_types): void {
  if (isset($entity_types['node'])) {
    $node_type = $entity_types['node'];
    $node_type->setHandlerClass('moderation', 'Drupal\my_module\Entity\Handler\ProjectModerationHandler');
  }
}

 Drupal\my_module\Entity\Handler\ProjectModerationHandler:

<?php

namespace Drupal\my_module\Entity\Handler;

use Drupal\content_moderation\Entity\Handler\NodeModerationHandler;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\my_module\Entity\Project;

/**
 * Changes forced revision creation set by moderation module to
 * conditional creation when the entity has changed meaningfully.
 */
class ProjectModerationHandler extends NodeModerationHandler {

  /**
   * {@inheritdoc}
   */
  public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
    if ($entity instanceof Project) {
      $entity->setNewRevision($entity->hasChanged() && !$entity->isSyncing());

      // Use parent's logic.
      if (($entity instanceof EntityPublishedInterface) && $entity->isPublished() !== $published_state) {
        $published_state ? $entity->setPublished() : $entity->setUnpublished();
      }
      return;
    }

    parent::onPresave($entity, $default_revision, $published_state);
  }

}

Conclusion

While Drupal doesn't provide a straightforward way to check whether an entity has changed, the Diff module gives us a battle-tested, flexible solution. Instead of reinventing entity comparison logic (and fighting with timestamps, changing metadata, and type normalization issues), we can:

  1. Use Diff's UI to decide what we consider a real change,
  2. build upon Diff's service layer to programmatically detect those changes
  3. while optionally normalizing rich text fields.
  4. Optionally implement conditional (change-dependent) creating of entity revisions.

This makes for a robust, future-proof solution that plays nicely with Drupal's revision system and saves you from maintaining your own complex comparison logic.

Neuen Kommentar hinzufügen

Der Inhalt dieses Feldes wird nicht öffentlich zugänglich angezeigt.

Restricted HTML

  • Erlaubte HTML-Tags: <a href hreflang target> <em> <strong> <cite> <blockquote cite> <pre> <ul type> <ol start type> <li> <dl> <dt> <dd> <h4 id> <h5 id> <h6 id>
  • Zeilenumbrüche und Absätze werden automatisch erzeugt.
  • Website- und E-Mail-Adressen werden automatisch in Links umgewandelt.

Angebot innerhalb von 24 Stunden

Ob ein großes kommerzielles System, oder eine kleine Business Seite, wir schicken ein Angebot ab innerhalb von 24 Stunden nachdem Sie diese Taste drücken: Angebot anfordern