Entity API: Loading, Saving, Translating, Validating and Securing Entities

This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production.

This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages.

Almost everything a Drupal site stores is an entity: content items (node), users, taxonomy terms, media items, files, comments, custom blocks, and, on the Meridian Museum site, the museum objects of the collection_object entity. Almost every piece of module code that touches site data therefore goes through the Entity API: a controller loads an object to show it, a form creates one, a queue worker updates dozens of them, a Drush command deletes the retired ones, a hook reacts when one is saved, and an access handler decides who may see it. This page is the reference for that API. It explains how to get entities in and out of storage with the entity_type.manager service, how field values and typed data behave, how translations and revisions are handled in code, how entity validation constraints work, which hooks and events fire during the entity lifecycle, and how entity access is decided. Defining a brand-new entity type is a separate topic covered in Custom entity types.

This page was checked against the official documentation, the API reference, change records and the Drupal 11.x source on 2026-10-30. It is written against Drupal 11.4.x, Drush 13 or 14 and PHP 8.3 or later (see Requirements and hosting). Drupal 10 reaches its end of life on 2026-12-09, so new code targets Drupal 11. Drupal 12.0.0 is planned for the week of 2026-12-07, and the version notes near the end say which parts of this API change there. The examples belong to the fictitious Meridian Museum site from the sub-section index: they extend the custom module meridian_collection (see Module anatomy and info.yml) and use the content type exhibition, the content entity collection_object and the languages English (default) and Spanish. For collection_object the examples assume the entity type built in Custom entity types: a content entity that is revisionable and translatable, whose label key is name, with the fields accession_number, period, material, dimensions and on_display.

The pieces of the Entity API

An entity type is a kind of thing Drupal can store, such as node or collection_object. A bundle is a subtype of an entity type that can carry its own fields (the content types exhibition and event are bundles of node). A field is a named, typed slot on an entity. An entity is one instance, an object of a PHP class, holding a field item list for every field. Content entities hold site content and live in the database; configuration entities (content types, views, image styles) are exported with configuration management. The concepts are introduced from the site builder’s side in Content types and fields. This page is about the PHP objects behind them.

The central design idea is that an entity type definition names a set of handlers, each a small class with one responsibility. Code never instantiates a handler directly: it asks the entity type manager.

Handler or service Responsibility How to get it

Storage handler

Creates, loads, saves and deletes entities of one type; builds entity queries. SqlContentEntityStorage for most content entities.

$entityTypeManager->getStorage('collection_object')

Access control handler

Answers "may this account view, update or delete this entity, or create one of this bundle?" and field access.

$entityTypeManager->getAccessControlHandler('collection_object')

List builder

Builds the administrative table of entities at /admin/content/... style routes.

$entityTypeManager->getListBuilder('collection_object')

View builder

Turns an entity into a render array for a view mode.

$entityTypeManager->getViewBuilder('collection_object')

Form objects

Entity add, edit and delete forms by operation name.

$entityTypeManager->getFormObject('collection_object', 'default')

Route provider, views data, translation handler, moderation handler

Generate routes, expose fields to Views, drive translation and moderation.

$entityTypeManager->getHandler('collection_object', 'views_data')

flowchart LR Code["Your code: controller, service, form, hook, Drush command"] --> ETM["entity_type.manager"] ETM --> Def["Entity type definition: plugin with attribute"] Def --> Storage["Storage handler"] Def --> Access["Access control handler"] Def --> Builders["List builder, view builder, form objects"] Storage --> DB[("Entity tables, field tables, revision tables")] Storage --> Cache[("Static cache and cache.entity bin")] Storage --> Entity["Entity object: ContentEntityBase subclass"] Entity --> Fields["Field item lists, items, properties: typed data"] Entity --> Hooks["Entity hooks, called through the module handler"]

The entity type definition is a plugin discovered from PHP attributes on the entity class (#[ContentEntityType] or #[ConfigEntityType]; in older releases annotations such as @ContentEntityType did the same job). Besides handlers it records the entity keys (id, uuid, label, bundle, revision, langcode, published, owner), the database tables, the link templates (canonical, edit-form, delete-form, collection), the permissions and the validation constraints. The Entity API reference at Entity types explains the structure of the definition.

Getting the entity services

Constructor injection

In classes, ask for the entity type manager in the constructor. The interface Drupal\Core\Entity\EntityTypeManagerInterface is autowirable, since core aliases it to the entity_type.manager service (autowiring aliases are available for core services since 10.1, see Services and dependency injection). The official page on working with the Entity API recommends injecting the entity type manager rather than calling Node::load() or Entity::create() statically, because it keeps the code decoupled and unit-testable.

The service below is the single place where the Meridian Museum module reads and writes collection_object entities. Controllers, the queue worker that syncs objects from the collections-management API, and the Drush command meridian:sync all depend on it instead of repeating storage calls.

# meridian_collection.services.yml
services:
  meridian_collection.object_repository:
    class: Drupal\meridian_collection\CollectionObjectRepository
    autowire: true

  logger.channel.meridian_collection:
    parent: logger.channel_base
    arguments: ['meridian_collection']
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Reads and writes collection objects.
 */
class CollectionObjectRepository {

  public function __construct(
    protected EntityTypeManagerInterface $entityTypeManager,
    #[Autowire(service: 'logger.channel.meridian_collection')]
    protected LoggerInterface $logger,
  ) {}

  /**
   * The storage handler is cheap to ask for: the manager caches the instance.
   */
  protected function storage(): EntityStorageInterface {
    return $this->entityTypeManager->getStorage('collection_object');
  }

  /**
   * Loads one object by its numeric ID, or NULL when it does not exist.
   */
  public function load(int $id): ?ContentEntityInterface {
    $entity = $this->storage()->load($id);
    return $entity instanceof ContentEntityInterface ? $entity : NULL;
  }

  /**
   * Loads several objects at once, keyed by ID, in a single set of queries.
   *
   * @param int[] $ids
   *
   * @return \Drupal\Core\Entity\ContentEntityInterface[]
   */
  public function loadMany(array $ids): array {
    return $this->storage()->loadMultiple($ids);
  }

  /**
   * Finds the objects currently on display, ordered by accession number.
   *
   * accessCheck(TRUE) restricts the result to what the current user may view.
   *
   * @return \Drupal\Core\Entity\ContentEntityInterface[]
   */
  public function loadOnDisplay(int $limit = 50): array {
    $ids = $this->storage()->getQuery()
      ->accessCheck(TRUE)
      ->condition('on_display', 1)
      ->sort('accession_number')
      ->range(0, $limit)
      ->execute();
    return $this->storage()->loadMultiple($ids);
  }

  /**
   * Finds an object by accession number (no access check; internal use).
   */
  public function loadByAccessionNumber(string $accession): ?ContentEntityInterface {
    $found = $this->storage()->loadByProperties(['accession_number' => $accession]);
    $entity = reset($found);
    return $entity instanceof ContentEntityInterface ? $entity : NULL;
  }

}

The other services you will meet

  • entity_type.manager (EntityTypeManagerInterface): definitions and handlers, as above. getDefinition('node') returns the EntityTypeInterface object; getDefinitions() returns all of them; hasDefinition() tests existence.

  • entity.repository (EntityRepositoryInterface): convenience lookups that the storage does not offer, for example loadEntityByUuid($type, $uuid) and getTranslationFromContext($entity), which picks the translation matching the current content language.

  • entity_field.manager (EntityFieldManagerInterface): field definitions of an entity type or bundle (getBaseFieldDefinitions(), getFieldDefinitions($type, $bundle), getFieldStorageDefinitions()).

  • entity_type.bundle.info (EntityTypeBundleInfoInterface): the list of bundles of an entity type, with labels.

  • typed_data_manager: creates and validates standalone typed data.

  • current_user, account_switcher: the account for access checks, and a way to run code as another account.

In procedural code (a .install file, a hook_update_N implementation, a test) the static Drupal::entityTypeManager() works, but a class should never call \Drupal:: statics. The old \Drupal::entityManager() service was split into the services above and removed in Drupal 9, so any book or snippet that uses it is obsolete.

Loading entities

Load by ID, by IDs and by property values

The storage handler offers three direct lookups, all documented on the official page for working with the Entity API:

$storage = $this->entityTypeManager->getStorage('collection_object');

// One entity by ID: the entity object, or NULL when there is no such ID.
$object = $storage->load(17);

// Several entities by ID, in one pass: an array keyed by entity ID.
// IDs that do not exist are simply missing from the result.
$objects = $storage->loadMultiple([17, 18, 19]);

// Every entity of the type, when called without an argument. Use with care.
$everything = $storage->loadMultiple();

// By exact field values. Matches ALL given properties.
$bronze = $storage->loadByProperties(['material' => 'Bronze', 'on_display' => 1]);

Rules worth remembering:

  • load() returns NULL for a missing ID. It does not throw. Always handle the NULL.

  • loadMultiple() is much cheaper than calling load() in a loop, because the field data is read with one query per field table instead of one set of queries per entity. Collect IDs first, then load them together.

  • loadByProperties() is implemented with an entity query that explicitly turns the access check off. It returns entities the current user may not be allowed to see. Use it in trusted back-end code (a queue worker, a Drush command), not to build output for visitors.

  • loadMultiple() with no arguments loads every entity of the type into memory. On a site with 50,000 collection objects that is an out-of-memory error. Load in chunks (see below).

  • Loading from storage runs hook_entity_storage_load and hook_entity_load (and the entity-type-specific variants) once per load, so those hooks must be cheap.

  • Procedural helpers such as Node::load($nid) or node_load() (removed) are shorthand for the storage call. Prefer the injected storage in classes.

Which revision and which translation you get

load() returns the default revision in the default translation language (the language the entity was created in). Both facts matter on a moderated, multilingual site. A draft that a Reviewer is looking at is a non-default revision, so load() returns the previous published content; and a Spanish page is reached with getTranslation('es') on the loaded entity (see Translations in code). To get the translation for the current request, use the entity repository:

// $entityRepository is an injected EntityRepositoryInterface.
$object = $this->entityRepository->getTranslationFromContext($storage->load(17));

Loading in chunks and the entity cache

Loaded entities are kept in a static cache for the rest of the request, and content entities are also stored in the persistent cache.entity bin (cache bin entity), so a second load(17) in the same request returns the same PHP object. The consequences:

  • Two pieces of code that load the same entity share one object. If one changes a field without saving, the other sees the change. Treat loaded entities as shared and clone intentionally.

  • loadUnchanged($id) returns a fresh copy from the database, ignoring unsaved in-memory changes. Hooks use it to compare the stored values with the values about to be saved ($entity->original also holds the stored version while saving).

  • resetCache([$id]) drops entities from the static cache. A long-running process must call it, otherwise memory grows with every entity it loads.

The queue worker that synchronises objects reads in chunks and resets the cache after every chunk:

public function refreshAll(): void {
  $storage = $this->entityTypeManager->getStorage('collection_object');
  $ids = $storage->getQuery()->accessCheck(FALSE)->sort('id')->execute();
  foreach (array_chunk($ids, 100) as $chunk) {
    foreach ($storage->loadMultiple($chunk) as $object) {
      $this->refreshOne($object);
    }
    // Free the loaded objects: essential for Drush commands and workers.
    $storage->resetCache($chunk);
  }
}

For long jobs prefer a queue or the Batch API over one giant loop (see Queue, Batch and cron APIs).

Entity queries and accessCheck()

An entity query selects entity IDs by conditions on fields and properties without writing SQL. It is the right tool when the match is a condition, sort or range on field values. The full query language, aggregate queries and the Database API are covered in Database API and entity queries; the rules that belong to this page are:

  • Obtain the query from the storage: $storage->getQuery(). It returns IDs, not entities; load the entities with loadMultiple().

  • Every entity query must call accessCheck(TRUE) or accessCheck(FALSE) explicitly. Omitting it throws an exception since Drupal 10 (it was a deprecation before). accessCheck(TRUE) removes the entities the current user may not view (node access grants and the entity query access of the entity type); accessCheck(FALSE) returns everything. Choose TRUE for anything that reaches a visitor and FALSE only for trusted back-end work that is not shown to a user.

  • Conditions use field names, optionally with a property: ->condition('period.value', 'Roman'), ->condition('field_gallery.entity.name', 'Planetarium') (a join through an entity reference), ->condition('on_display', 1). The default operator is =; IN, CONTAINS, STARTS_WITH, BETWEEN, IS NULL, IS NOT NULL and others are available.

  • ->condition($query->orConditionGroup()->...) builds OR groups.

  • The query returns the default revision by default. ->latestRevision() returns the latest revision of every entity (useful with Content Moderation) and ->allRevisions() returns every revision, keyed by revision ID.

  • In Drupal 12, query objects are no longer serialisable, so never keep a query in a property that gets serialised (for example inside a render array that is cached or inside a queue item).

// Roman objects on display whose label starts with "Coin", newest accession first.
$ids = $this->entityTypeManager->getStorage('collection_object')->getQuery()
  ->accessCheck(TRUE)
  ->condition('on_display', 1)
  ->condition('period', 'Roman')
  ->condition('name', 'Coin', 'STARTS_WITH')
  ->sort('accession_number', 'DESC')
  ->pager(20)
  ->execute();

The entity query is language-aware: by default it matches field values in any language of the entity, and the langcode condition narrows it. Fields that are not translatable are matched once.

From the command line

Drush is useful to look at entities while developing. With DDEV (see Installation with Composer and DDEV):

# Evaluate PHP on the booted site (development only; never expose this).
ddev drush php:eval "print_r(\Drupal::entityTypeManager()->getStorage('collection_object')->load(17)->toArray());"

# Delete entities of a type (asks for confirmation; use --bundle for one bundle).
ddev drush entity:delete node --bundle=event

# Clear the caches after changing hooks or entity type definitions.
ddev drush cache:rebuild

Creating and saving entities

Creating

create() on the storage returns a new, unsaved entity object. Nothing is written until save() is called. The values array uses field names as keys and accepts a plain value for single-valued fields, an array of properties for multi-value or compound fields, and the entity keys (type for the bundle of a node, name for the label of collection_object):

public function create(string $accession, string $name, string $period): ContentEntityInterface {
  $object = $this->storage()->create([
    'name' => $name,
    'accession_number' => $accession,
    'period' => $period,
    'material' => 'Unknown',
    'on_display' => FALSE,
    'langcode' => 'en',
  ]);
  $this->logger->info('Created collection object %accession.', ['%accession' => $accession]);
  return $object;
}

Creating runs hook_entity_create and hook_ENTITY_TYPE_create, which is the place to fill defaults that depend on code. For a bundle-bearing entity the bundle is mandatory: $storage->create(['type' => 'exhibition', 'title' => ...]) for a node. The static form Node::create([...]) does the same thing, but it reaches the container through a static call; it is fine in procedural code and in tests but not in an injectable class.

Field values may also be set after creation (see Field values and typed data), and a few entity types give you shortcuts, for instance setOwnerId() on entities that implement EntityOwnerInterface, setPublished() on those that implement EntityPublishedInterface, and setCreatedTime() on EntityCreatedInterface entities.

Saving

save() stores a new or changed entity and returns SAVED_NEW or SAVED_UPDATED (the constants 1 and 2). The same call serves both cases: the entity knows whether it is new (isNew()), which is true until the first save.

public function importRow(array $row): int {
  $object = $this->loadByAccessionNumber($row['accession']) ?? $this->create($row['accession'], $row['name'], $row['period']);
  $object->set('material', $row['material']);
  $object->set('dimensions', $row['dimensions']);
  $object->set('on_display', (bool) $row['on_display']);

  // Run entity validation before writing: save() itself does not validate.
  $violations = $object->validate();
  if ($violations->count() > 0) {
    foreach ($violations as $violation) {
      $this->logger->warning('Row @row rejected: @field @message', [
        '@row' => $row['accession'],
        '@field' => $violation->getPropertyPath(),
        '@message' => (string) $violation->getMessage(),
      ]);
    }
    throw new \InvalidArgumentException('Invalid collection object.');
  }
  return $object->save();
}

Facts about save() that cause many support questions:

  • Programmatic saves are not validated. Entity forms validate the entity before saving; save() does not. If your code builds an entity from outside data (an API response, a CSV row), call validate() yourself, as above. If validation is flagged as required and you skip it, save() raises a LogicException ("Entity validation was skipped"); forms call setValidationRequired(TRUE) for exactly this safety net.

  • save() does not check access. Any code that can call it can write any entity. The access system protects forms, routes and JSON:API, not PHP code. Check $entity->access('update') first when you save on behalf of a user.

  • Field-level access is not applied either. $entity->set('uid', 1) works regardless of who is logged in.

  • To save an entity with a fixed ID as new (an import into an empty table), call $entity->enforceIsNew(TRUE) before save(); otherwise Drupal tries to update a row that does not exist.

  • save() on a content entity wraps the writes in a database transaction. If a hook throws, the transaction is rolled back and the exception reaches the caller.

  • Concurrent edits: entity forms use the EntityChanged constraint to refuse a save when the stored changed timestamp is newer than the form’s. Programmatic code gets no such protection unless it calls validate() on an entity loaded earlier, or uses loadUnchanged() to compare.

  • Saving a loaded translation (see below) saves the whole entity, with all its translations.

The lifecycle of a save

flowchart TD A["save() called"] --> B["Entity preSave: set timestamps, uuid, revision metadata"] B --> C["hook_entity_presave and hook_ENTITY_TYPE_presave"] C --> D["Storage writes base, data, field and revision tables"] D --> E["Entity postSave"] E --> F{"New entity?"} F -->|"yes"| G["hook_entity_insert and hook_ENTITY_TYPE_insert"] F -->|"no"| H["hook_entity_update and hook_ENTITY_TYPE_update"] G --> I["Cache tags of the entity invalidated"] H --> I

hook_entity_presave runs before anything is written and may still change the entity: this is the place to normalise data (as the accession-number example on Hooks and OOP hooks does). hook_entity_insert and hook_entity_update run after the rows are written, inside the same transaction; they are for side effects such as queueing work or invalidating a cache, and changing the entity there needs another save(). In hook_entity_update, $entity->original holds the entity as it was before the save, so a hook can compare old and new values (hasField() and get() work on both).

Deleting entities

public function retire(int $id): void {
  $object = $this->load($id);
  if ($object !== NULL) {
    // Deletes the entity, all its translations and all its revisions.
    $object->delete();
  }
}

public function retireMany(array $ids): void {
  $storage = $this->storage();
  // Pass entity objects keyed by ID. Deleting by IDs is not supported.
  $storage->delete($storage->loadMultiple($ids));
}

What deletion does and does not do:

  • delete() on an entity that was never saved does nothing. $storage->delete() takes an array of entity objects, not IDs.

  • Deleting removes all revisions and all translations. To remove only one translation call removeTranslation() and save; to remove only one old revision call deleteRevision($revisionId) on the storage (the default revision cannot be deleted).

  • Hooks: hook_entity_predelete runs while the entity is still in storage (read related data here; throwing an exception aborts the delete), then hook_entity_delete runs after it has been removed. Dependent clean-up belongs in predelete; clean-up of external systems belongs in delete or, better, in a queued job.

  • Core does not cascade through entity references. Deleting a collection_object leaves any exhibition that references it with a dangling reference value (the reference field simply stops rendering it, and entity reference validation fails the next time that exhibition is edited). If objects must not disappear while exhibitions use them, prevent the deletion with an access check or a hook, or offer "unpublish" instead of "delete".

  • Deleting content with files leaves the files as temporary files that cron removes later (see File system and private files).

  • Mass deletion in a request or a Drush command must be chunked, with resetCache(), or run through a queue.

  • A delete form or link must use a CSRF-protected route or POST request, never a plain GET link (the generated entity delete-form route is a confirmation form for this reason).

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Entity delete hooks for the Meridian Museum collection.
 */
class CollectionDeleteHooks {

  public function __construct(
    #[Autowire(service: 'logger.channel.meridian_collection')]
    protected LoggerInterface $logger,
  ) {}

  /**
   * The object still exists in storage here, so its data can be logged.
   */
  #[Hook('collection_object_predelete')]
  public function logRetirement(EntityInterface $entity): void {
    $this->logger->notice('Retiring collection object %number (ID @id).', [
      '%number' => $entity->get('accession_number')->value,
      '@id' => $entity->id(),
    ]);
  }

}

Field values and typed data

Entities, field item lists, items and properties

A content entity does not store values directly. It stores field item lists, and each list holds field items, and each item holds properties. All of them are typed data objects, which is the data model underneath the Entity API:

Level Interface Example for period

Entity

ContentEntityInterface (complex data)

$object

Field item list

FieldItemListInterface (a list of items; one per delta)

$object->get('period') or $object->period

Field item

FieldItemInterface (complex data)

$object->get('period')->first(), or $object->get('period')[0]

Property

TypedDataInterface (a primitive: string, integer, boolean, entity reference)

$object->get('period')->first()->get('value')

The official page on how the Entity API implements the Typed Data API shows the same path for an image field: get('image') is the list, offsetGet(0) the item and get('alt') the property, with getValue() returning the primitive value. The magic accessors shorten it: $entity->image[0]->alt is the explicit form and $entity->image->alt reads the first item.

Reading values

// Single-valued scalar fields: the "main property" is value.
$name = $object->label();
$accession = $object->get('accession_number')->value;
$onDisplay = (bool) $object->get('on_display')->value;

// Safe checks before reading.
if ($object->hasField('dimensions') && !$object->get('dimensions')->isEmpty()) {
  $dimensions = $object->get('dimensions')->value;
}

// Multi-value field: iterate the items.
foreach ($exhibition->get('field_related_objects') as $delta => $item) {
  $targetId = $item->target_id;
}

// Entity reference fields: the loaded entities, with access and translation applied.
foreach ($exhibition->get('field_related_objects')->referencedEntities() as $related) {
  // Each $related is a collection_object entity.
}

// Raw arrays: one property array per item, or the whole entity as an array.
$rawValues = $object->get('period')->getValue();
$all = $object->toArray();

// A string rendering of the whole field (items joined), and the first item or NULL.
$text = $object->get('period')->getString();
$first = $object->get('period')->first();

Details that matter:

  • The main property of a field type is the one that ->value or ->target_id style access names. Text fields have value and format, a link field has uri, title and options, an entity reference field has target_id and the computed entity property. See Field types and entity references.

  • ->entity on a reference item loads the target entity on demand. Inside a loop over many entities this becomes an N+1 problem; collect IDs and use loadMultiple() or referencedEntities(), which loads the targets together.

  • Reading ->value on an empty field returns NULL, and ->first() returns NULL. An empty list is not an error.

  • $object->get('nonexistent') throws an InvalidArgumentException; hasField() tests for existence first. This is the common error when code runs against a bundle that lacks the field.

Writing values

// Replace a single value; the value is wrapped into the field's main property.
$object->set('period', 'Bronze Age');
$object->set('on_display', TRUE);

// Equivalent magic form (property access and assignment).
$object->period = 'Bronze Age';

// Replace all items of a multi-value or compound field.
$exhibition->set('field_related_objects', [
  ['target_id' => 17],
  ['target_id' => 18],
]);

// Add one item, remove one by delta, or empty the field.
$exhibition->get('field_related_objects')->appendItem(['target_id' => 19]);
$exhibition->get('field_related_objects')->removeItem(0);
$exhibition->set('field_related_objects', []);

// Set one property of an existing item.
$object->get('period')->first()->set('value', 'Iron Age');

$object->save();

Setting a value changes only the in-memory object; the database changes at save(). A value is stored as given: no sanitisation is applied on write, because Drupal escapes on output (see Render API and cache metadata and the Twig autoescaping rules). For a text field with a text format, set both properties (['value' => $html, 'format' => 'basic_html']); a missing format leaves the field without filtering decisions and renders as the fallback format.

Field definitions and the typed data manager

Definitions describe fields without reading values:

// All field definitions of an entity (base fields plus configured fields of the bundle).
$definitions = $object->getFieldDefinitions();

// The definition behind one field, its storage, and the properties of its items.
$definition = $object->get('period')->getFieldDefinition();
$label = $definition->getLabel();
$type = $definition->getType();                // for example "string"
$isMultiple = $definition->getFieldStorageDefinition()->isMultiple();
$properties = $definition->getFieldStorageDefinition()->getPropertyDefinitions();

The typed data system is also usable on its own, to validate arbitrary data with the same constraint plugins that entity fields use. A service can create a typed data object with the typed_data_manager service (autowired as TypedDataManagerInterface):

use Drupal\Core\TypedData\TypedDataManagerInterface;

public function isValidAccessionLength(string $value): bool {
  // $this->typedDataManager is an injected TypedDataManagerInterface.
  $definition = $this->typedDataManager->createDataDefinition('string');
  $definition->addConstraint('Length', ['max' => 24]);
  $data = $this->typedDataManager->create($definition, $value);
  return $data->validate()->count() === 0;
}

Computed fields (virtual values, for example the number of exhibitions that use an object) are implemented with a computed property class (TypedData subclass with setComputed(TRUE)) and are covered in the field plugins page, Block, field type, widget and formatter plugins.

Translations in code

A translatable entity is one entity object that holds one or more translations. The class stays the same; each translation is a view of the same object bound to a language, and getTranslation() returns that view. Fields that are translatable hold per-language values; fields that are not translatable share one value across all translations. The configuration side (languages, which fields are translatable, content translation settings) is covered in Multilingual, and interface string translation in Translation in code. This section is about content entities in PHP.

public function ensureSpanish(ContentEntityInterface $object, string $name, string $period): void {
  if (!$object->isTranslatable()) {
    throw new \LogicException('Enable content translation for collection_object first.');
  }

  if ($object->hasTranslation('es')) {
    $translation = $object->getTranslation('es');
    $translation->set('name', $name);
    $translation->set('period', $period);
  }
  else {
    // Creates the translation object; nothing is stored until save().
    $translation = $object->addTranslation('es', [
      'name' => $name,
      'period' => $period,
    ]);
  }

  // One save writes every translation of the entity.
  $object->save();
}

public function describe(ContentEntityInterface $object): array {
  $languages = [];
  foreach ($object->getTranslationLanguages() as $langcode => $language) {
    $translation = $object->getTranslation($langcode);
    $languages[$langcode] = $translation->label();
  }
  return $languages;
}

public function dropSpanish(ContentEntityInterface $object): void {
  if ($object->hasTranslation('es')) {
    $object->removeTranslation('es');
    $object->save();
  }
}

The translation API methods, as documented on the official translation page:

  • getTranslation($langcode) returns the translation object. An unknown language code throws an exception, so test with hasTranslation() first.

  • addTranslation($langcode, $values) creates a translation, optionally with initial values, and does not save.

  • hasTranslation($langcode) is the existence check; getTranslationLanguages() returns the languages present, including the original (pass FALSE to exclude the default one).

  • getUntranslated() returns the original-language object from any translation object, and isDefaultTranslation() tells whether the current object is the original.

  • removeTranslation($langcode) removes a translation (the default translation cannot be removed); it fires hook_entity_translation_delete. Creating fires hook_entity_translation_create (object created) and hook_entity_translation_insert (stored); each has an entity-type-specific variant.

Behaviour that surprises developers:

  • Untranslatable fields are shared. accession_number and on_display have a single value for all languages. Changing one through the Spanish translation object changes it for English too. Entity validation includes a constraint (EntityUntranslatableFields) that rejects, in a translation form, a change to an untranslatable field when more than one translation exists, because it silently changes the other languages.

  • A query condition on a translatable field matches any translation unless it filters by langcode.

  • The language of an entity object is $entity->language()->getId() (the translation it represents), and $entity->getUntranslated()->language() is the original.

  • getTranslationFromContext() (entity repository) falls back through the language negotiation fallback candidates and returns the original if there is no suitable translation. It also checks hasTranslation() and published status.

  • On a revisioned entity, translations belong to revisions, and a flag marks which revisions affected which translation (isRevisionTranslationAffected() and setRevisionTranslationAffected()), which the content translation module uses to show per-language revision histories. Set a revision-translation-affected flag only when you know what you are doing.

Revisions in code

A revisionable entity type stores each saved version of an entity in a revision table. Every revision has a revision ID; one revision of an entity is the default revision (the live one). Nodes and the collection_object entity are revisionable; taxonomy terms and media are revisionable too, while files are not. Entity definition for revisions needs a revision_table, a revision entity key and revisionable field definitions (setRevisionable(TRUE)), as the official page on making an entity revisionable explains; an entity class that extends RevisionableContentEntityBase receives the revision metadata fields (revision user, creation time and log message) automatically.

use Drupal\Core\Entity\RevisionLogInterface;

public function saveWithLog(ContentEntityInterface $object, string $message, int $userId): void {
  // Ask the storage for a new revision instead of overwriting the current one.
  $object->setNewRevision(TRUE);
  if ($object instanceof RevisionLogInterface) {
    $object->setRevisionLogMessage($message);
    $object->setRevisionUserId($userId);
    $object->setRevisionCreationTime(time());
  }
  $object->save();
}

public function revert(int $id, int $revisionId): void {
  /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
  $storage = $this->storage();
  $old = $storage->loadRevision($revisionId);
  if ($old === NULL || (int) $old->id() !== $id) {
    throw new \InvalidArgumentException('Unknown revision.');
  }
  // createRevision() copies the old revision into a new, default revision.
  $revert = $storage->createRevision($old);
  $revert->setRevisionLogMessage(sprintf('Reverted to revision %d.', $revisionId));
  $revert->save();
}

public function history(int $id): array {
  /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
  $storage = $this->storage();
  $revisionIds = $storage->getQuery()->accessCheck(TRUE)->allRevisions()
    ->condition('id', $id)->sort('revision_id', 'DESC')->execute();
  return array_keys($revisionIds);
}

The revision methods in use, from the storage and entity interfaces:

  • On the storage (RevisionableStorageInterface): loadRevision($id) (NULL when absent), loadMultipleRevisions(), createRevision($entity, $default = TRUE), deleteRevision($id) (not the default revision) and getLatestRevisionId($entityId).

  • On the entity (RevisionableInterface): isNewRevision(), setNewRevision(), isDefaultRevision(), isLatestRevision(), getRevisionId() and getLoadedRevisionId(). For translatable entities, isLatestTranslationAffectedRevision() and hasTranslationChanges().

Pitfalls:

  • load() returns the default revision. With Content Moderation, the latest revision can be a draft that is not the default one; to load it, ask for $storage->getLatestRevisionId($id) and then loadRevision(). Saving a draft is $entity->isDefaultRevision(FALSE) plus a new revision, and the moderation workflow sets this for you through the moderation_state field. Code that updates a moderated node should load the latest revision and let moderation decide the state. See Content moderation, workflows and revisions.

  • setNewRevision(TRUE) must be called before save(); afterwards the loaded entity represents the new revision. Entity forms do this when the "Create new revision" checkbox is on. Code that creates revisions on every cron run quickly fills the revision tables; prune old revisions with a queue or a contrib module.

  • Do not edit an old revision object and call save(): saving a non-default, non-new revision rewrites history. Use createRevision().

  • $entity->original and revision comparisons use getLoadedRevisionId(); do not rely on getRevisionId() after save() for the value before the save.

  • Workspaces (see Workspaces) add another dimension: an entity can have a workspace-specific revision, and entity queries are filtered by the active workspace.

  • Revisions are visible only to accounts with the "view all revisions" style permissions of the entity type, which an access handler must honour (see Entity access).

Entity validation constraints

Drupal validates entities with the Symfony Validator. Rules are constraint plugins: a constraint class (the message and the options) and a matching validator class (the logic). Constraints are attached to an entity type, a bundle, a field or a single field property, and $entity->validate() runs them all and returns an EntityConstraintViolationListInterface. This is separate from Form API validation (which only checks what the form cares about). The official documentation lists four ways to attach constraints: in baseFieldDefinitions(), in the entity type definition, through hook_entity_bundle_field_info_alter() for a bundle field and through hook_entity_type_alter() for an entity type defined elsewhere.

Using the constraints that already exist

Core ships many constraints (Length, Range, Regex, NotNull, AllowedValues, Email, UniqueField, ValidReference, EntityChanged and others). Attach them to a base field with addConstraint() (the constraint applies to the whole field item list) or addPropertyConstraints() (it applies to one property of every item):

use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;

public static function baseFieldDefinitions(EntityTypeInterface $entity_type): array {
  $fields = parent::baseFieldDefinitions($entity_type);

  $fields['accession_number'] = BaseFieldDefinition::create('string')
    ->setLabel(t('Accession number'))
    ->setRequired(TRUE)
    ->setSetting('max_length', 24)
    ->setRevisionable(TRUE)
    // Value must be unique among collection objects (case-insensitive by default).
    ->addConstraint('UniqueField')
    // Format check on the "value" property only.
    ->addPropertyConstraints('value', [
      'Regex' => ['pattern' => '/^MM\.\d{4}\.\d{3}$/'],
    ]);

  return $fields;
}

Writing a constraint plugin

Constraint plugins live in src/Plugin/Validation/Constraint/ and are discovered from the Constraint attribute, Drupal\Core\Validation\Attribute\Constraint (its arguments are id, label, type and deriver; annotations still work but plugin classes should use attributes; attribute discovery has been available since 10.2 and all core plugin types support it in 11.2, see the change record on plugin attributes). The attribute’s type lists the data types the constraint can validate; entity marks an entity-level constraint, as core’s EntityChanged does.

The rule below is entity-level: an object cannot be marked on display while its period is empty.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\Validation\Constraint;

use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Validation\Attribute\Constraint;
use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint as SymfonyConstraint;

/**
 * An object on display needs a known period.
 */
#[Constraint(
  id: 'MeridianOnDisplayNeedsPeriod',
  label: new TranslatableMarkup('Object on display needs a period', [], ['context' => 'Validation']),
  type: ['entity'],
)]
class OnDisplayNeedsPeriodConstraint extends SymfonyConstraint {

  #[HasNamedArguments]
  public function __construct(
    public string $message = 'An object on display must have a period.',
    mixed ...$args,
  ) {
    parent::__construct(...$args);
  }

}
<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Plugin\Validation\Constraint;

use Drupal\Core\Entity\FieldableEntityInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
 * Validates OnDisplayNeedsPeriod. The class name is the constraint name plus "Validator".
 */
class OnDisplayNeedsPeriodConstraintValidator extends ConstraintValidator {

  public function validate(mixed $value, Constraint $constraint): void {
    assert($constraint instanceof OnDisplayNeedsPeriodConstraint);
    if (!$value instanceof FieldableEntityInterface) {
      return;
    }
    if ($value->get('on_display')->value && $value->get('period')->isEmpty()) {
      // atPath() ties the violation to the field, so the form highlights it.
      $this->context->buildViolation($constraint->message)
        ->atPath('period')
        ->addViolation();
    }
  }

}

The constraint is attached to the entity type in its definition (constraints: ['MeridianOnDisplayNeedsPeriod' => []] in the #[ContentEntityType] attribute, see Custom entity types), or to an entity type owned by another module with a hook. The hook form below belongs in a hook class, and the cache must be rebuilt for it to take effect (an update function is not required):

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Hook\Attribute\Hook;

/**
 * Attaches Meridian constraints to entity types owned by other modules.
 */
class CollectionConstraintHooks {

  /**
   * Implements hook_entity_type_alter().
   *
   * @param \Drupal\Core\Entity\EntityTypeInterface[] $entity_types
   */
  #[Hook('entity_type_alter')]
  public function entityTypeAlter(array &$entity_types): void {
    if (isset($entity_types['node'])) {
      // Applies to every node. Constraints can be narrowed per bundle with
      // hook_entity_bundle_field_info_alter() on the bundle fields instead.
      $entity_types['node']->addConstraint('MeridianExhibitionDates');
    }
  }

}

Version notes for constraint plugins:

  • Constraint constructors that take an options array are deprecated (deprecation message from 11.3) and removed in Drupal 12 together with Symfony 8, so write constructors with named arguments and #[HasNamedArguments] (change record published for 11.4). Older tutorials and the Drupal 10 Development Cookbook (2023) show annotations (@Constraint(id = ..., label = ..., type = ...)) and public properties such as public $message, with options passed as an array; those forms still run on 11.4 but are on the deprecation path.

  • Public typed properties instead of dynamic ones are advisable, since PHP 8.2 deprecates dynamic properties.

Running validation and reading violations

$violations = $object->validate();

// All violations as messages, keyed by property path.
foreach ($violations as $violation) {
  printf("%s: %s\n", $violation->getPropertyPath(), $violation->getMessage());
}

// Only violations of one field, or only entity-level ones.
$periodViolations = $violations->getByField('period');
$entityLevel = $violations->getEntityViolations();

// Which fields failed.
$failedFields = $violations->getFieldNames();

Several rules apply:

  • Violation messages use %value-style placeholders and must be translatable. getMessage() may return a markup object; cast it to string before logging.

  • A validator receives the field item list (field-level constraint), the item property value (property-level) or the entity itself (entity-level). Check the type with instanceof first.

  • Validation is also what REST and JSON:API call before saving, so a constraint protects every write path that validates (see JSON:API), and only the paths that validate.

  • Validation can be skipped for a bulk import by simply not calling validate(). Document that decision.

  • Constraints are cached with the field definitions. After adding or changing one, rebuild the cache.

  • Unique checks like UniqueField query the database; a high-volume import should validate in batches and expect one query per row.

Entity hooks and events

Entity hooks

Entities are the main producers of hooks. Each hook exists in a generic form (hook_entity_presave) and per entity type (hook_collection_object_presave, written in the attribute as #[Hook('collection_object_presave')]). The generic hook runs for every entity type, so it must start by checking the entity type or bundle. The hooks documented in entity.api.php, grouped by phase:

Phase Hooks Typical use

Definitions

hook_entity_type_build, hook_entity_type_alter, hook_entity_bundle_info, hook_entity_bundle_info_alter, hook_entity_bundle_create, hook_entity_bundle_delete, hook_entity_base_field_info, hook_entity_base_field_info_alter, hook_entity_bundle_field_info, hook_entity_bundle_field_info_alter

Add handlers, constraints or fields to entity types owned by another module

Create

hook_entity_create, hook_entity_duplicate, hook_entity_revision_create, hook_entity_translation_create

Set computed defaults on a new object; adjust a duplicated or cloned entity

Load

hook_entity_preload, hook_entity_storage_load, hook_entity_load (and ENTITY_TYPE variants)

Attach runtime data to loaded entities (keep cheap)

Save

hook_entity_presave, hook_entity_insert, hook_entity_update, hook_entity_translation_insert

Normalise before writing; react after writing

Delete

hook_entity_predelete, hook_entity_delete, hook_entity_revision_delete, hook_entity_translation_delete

Clean dependent data

Display

hook_entity_prepare_view, hook_entity_view, hook_entity_view_alter, hook_entity_build_defaults_alter, hook_entity_view_mode_alter, hook_entity_display_build_alter

Adjust the render array; see Render API and cache metadata

Forms

hook_entity_prepare_form, hook_entity_form_mode_alter, hook_entity_form_display_alter

Adjust the entity form before it is built

Access

hook_entity_access, hook_entity_create_access, hook_entity_field_access (and ENTITY_TYPE variants)

Add or veto access, see Entity access

Operations

hook_entity_operation, hook_entity_operation_alter

Add links to list builders

Source: entity.api.php

The class below reacts to Spanish translations and to the save of exhibitions. Hook classes live in src/Hook, are autowired and need no service definition (Drupal 11.1 and later; the rules, ordering attributes and the hooks that must stay procedural are on Hooks and OOP hooks).

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Cache\CacheTagsInvalidatorInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\node\NodeInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

/**
 * Cache and consistency reactions to entity changes.
 */
class CollectionEntityLifecycleHooks {

  public function __construct(
    #[Autowire(service: 'cache_tags.invalidator')]
    protected CacheTagsInvalidatorInterface $cacheTagsInvalidator,
  ) {}

  /**
   * A new translation was stored: the language specific listings change too.
   */
  #[Hook('collection_object_translation_insert')]
  public function translationAdded(EntityInterface $translation): void {
    $this->cacheTagsInvalidator->invalidateTags(['meridian_collection:objects']);
  }

  /**
   * Generic hook: runs for every entity type, so filter first.
   */
  #[Hook('entity_presave')]
  public function normaliseExhibition(EntityInterface $entity): void {
    if (!$entity instanceof NodeInterface || $entity->bundle() !== 'exhibition') {
      return;
    }
    $entity->setTitle(trim($entity->getTitle()));
  }

  /**
   * Compare the stored version with the saved one.
   */
  #[Hook('node_update')]
  public function exhibitionUpdated(NodeInterface $node): void {
    if ($node->bundle() !== 'exhibition') {
      return;
    }
    $original = $node->original ?? NULL;
    if ($original instanceof NodeInterface && $original->isPublished() !== $node->isPublished()) {
      $this->cacheTagsInvalidator->invalidateTags(['meridian_collection:exhibitions']);
    }
  }

}

Older books and tutorials, including the Drupal 10 Development Cookbook (2023), put the same code in functions such as meridian_collection_entity_presave() in the .module file and call \Drupal:: statics. Those procedural hooks still run in 11.x, but since 11.1 the class form above is the recommended one.

Practical rules for entity hooks:

  • Keep presave hooks idempotent and cheap: they run on every save from every source, including migrations and imports.

  • In hook_entity_insert/_update, the entity is already stored. Saving it again triggers the hooks again; guard against recursion (for example with a flag in a static property or by testing whether the value actually changed).

  • Hooks cannot veto a save. To refuse a change, use a validation constraint (for forms and API writes) or throw an exception in presave/predelete (which aborts the operation and rolls back the transaction).

  • Use hook_entity_storage_load/hook_entity_load sparingly: they run for every load, and heavy work there slows every page.

  • Inject the entity type manager into hook classes instead of calling \Drupal::entityTypeManager(); static calls are the source of most untestable entity code.

Events

Core does not dispatch Symfony events for content entity creation, update or deletion; those are hook-only. The Symfony event system (see Events and subscribers) is used for definition-level changes: Drupal\Core\Entity\EntityTypeEvents (create, update and delete of an installed entity type definition) and Drupal\Core\Field\FieldStorageDefinitionEvents (the same for field storage definitions) are dispatched when the entity definition update manager installs changes, and are what a module subscribes to when it must react to a schema change. Contributed modules exist that bridge entity hooks to Symfony events; evaluate their Drupal 11.4 compatibility before depending on them. For the actual save and delete of an entity, implement the hooks above.

Entity access

The access API

Every entity answers $entity->access($operation, $account = NULL, $return_as_object = FALSE). The operations are view, update and delete; entities with revisions add view all revisions, view revision, revert revision and delete revision (named per entity type); creation uses createAccess() on the handler because there is no entity yet. A missing account means the current user.

// Boolean answer for the current user.
if ($object->access('update')) {
  // The user may edit this object.
}

// Rich answer with cacheability metadata, for use in render arrays and access callbacks.
$result = $object->access('view', $account, TRUE);

// Create access goes through the handler, because creating a temporary entity is costly.
$handler = $this->entityTypeManager->getAccessControlHandler('collection_object');
$mayCreate = $handler->createAccess('collection_object', $account);

access() returns a boolean by default; with $return_as_object = TRUE it returns an AccessResultInterface (allowed, neutral or forbidden, plus cacheability), which controllers and blocks should prefer so the cache contexts and tags of the decision reach the render cache. A decision that depends on the user’s roles but has no cache context leaks one user’s access to another from the page cache; see Access control and permissions in code for the full AccessResult vocabulary, route access, node grants and the Access Policy API for the curator rules.

How a decision is composed

flowchart TD A["entity access with operation and account"] --> B["Cached for entity, operation, language, account"] B -->|"miss"| C["hook_entity_access and hook_ENTITY_TYPE_access: results merged"] C --> D{"Any module forbids?"} D -->|"yes"| E["Forbidden"] D -->|"no"| F["Handler checkAccess result, combined with the hook results by or"] F --> G{"Allowed by someone?"} G -->|"yes"| H["Allowed"] G -->|"no"| I["Neutral, which means no access"]

The default handler, Drupal\Core\Entity\EntityAccessControlHandler, works as follows (from the API reference):

  • access() caches the result per entity, operation, language and account. It invokes hook_entity_access and hook_ENTITY_TYPE_access from every module, merges the results, and, when no module forbids, combines that with the result of checkAccess() using an "or".

  • createAccess() does the same with hook_entity_create_access, hook_ENTITY_TYPE_create_access and checkCreateAccess().

  • checkAccess() and checkCreateAccess() are the protected methods a custom handler overrides. The default forbids delete on a new entity and allows the operation for accounts with the entity type’s administer permission; otherwise it returns neutral.

  • fieldAccess() checks access to a single field (the field item’s default access, checkFieldAccess() and hook_entity_field_access). It forbids editing an entity ID (unless it is a new string ID) and a UUID of an existing entity.

Three outcomes exist: allowed, forbidden and neutral. Neutral means "this part has no opinion", and a result that stays neutral is treated as "no access". One forbidden from any module wins over any number of allowed results, so a hook can veto but only a handler or permissions can grant.

The access control handler for collection_object

The custom entity type names its handler in the entity type definition (handlers: ['access' => CollectionObjectAccessControlHandler::class] in the #[ContentEntityType] attribute). The Meridian Museum rules: anyone with the permission "view collection objects" sees objects that are on display, curators also see objects in storage, editors update, and only curators delete.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;

/**
 * Access control for collection objects.
 */
class CollectionObjectAccessControlHandler extends EntityAccessControlHandler {

  /**
   * {@inheritdoc}
   */
  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account): AccessResultInterface {
    // Curators and administrators can do anything (the parent grants the administer permission).
    $parent = parent::checkAccess($entity, $operation, $account);

    $result = match ($operation) {
      'view' => $entity->get('on_display')->value
        ? AccessResult::allowedIfHasPermission($account, 'view collection objects')
        : AccessResult::allowedIfHasPermission($account, 'view unpublished collection objects'),
      'update' => AccessResult::allowedIfHasPermission($account, 'edit collection objects'),
      'delete' => AccessResult::allowedIfHasPermission($account, 'delete collection objects'),
      default => AccessResult::neutral(),
    };

    // The decision depends on the permissions of the account and on the on_display value of the entity.
    return $parent->orIf($result)->addCacheableDependency($entity);
  }

  /**
   * {@inheritdoc}
   */
  protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL): AccessResultInterface {
    return AccessResult::allowedIfHasPermissions($account, ['administer collection objects', 'create collection objects'], 'OR');
  }

}

The allowedIfHasPermission() results already carry the cache context user.permissions; the handler adds the entity as a cache dependency so the decision is invalidated when the object (and its on_display flag) changes. Forgetting the dependency is the classic bug: a listing keeps showing an object after it has been withdrawn from display.

Access hooks from another module

A different module can add or veto access without replacing the handler. The hook below forbids deleting an exhibition for everyone but administrators, so an exhibition with published visitor bookings cannot be removed by accident. It returns a cacheable result, as every access hook must.

<?php

declare(strict_types=1);

namespace Drupal\meridian_collection\Hook;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Session\AccountInterface;

/**
 * Entity access additions.
 */
class CollectionAccessHooks {

  /**
   * Implements hook_entity_access(): runs for every entity type.
   */
  #[Hook('entity_access')]
  public function entityAccess(EntityInterface $entity, string $operation, AccountInterface $account): AccessResultInterface {
    $forbid = $operation === 'delete'
      && $entity->getEntityTypeId() === 'node'
      && $entity->bundle() === 'exhibition'
      && !$account->hasPermission('administer collection objects');

    return AccessResult::forbiddenIf($forbid, 'Exhibitions can only be deleted by administrators.')
      ->cachePerPermissions()
      ->addCacheableDependency($entity);
  }

}

Points to remember about entity access:

  • Access is not enforced for PHP code that calls save(), delete() or loadByProperties(). It is enforced for routes that declare _entity_access requirements, for entity forms, for accessCheck(TRUE) queries, for JSON:API and for Views that use query access. Call access() yourself in custom code that acts for a user.

  • Node access grants (hook_node_grants, hook_node_access_records) apply only to nodes and are applied by entity queries with accessCheck(TRUE) and by Views, not by $node->access() alone, which also checks the grants in the node access handler.

  • hook_entity_access runs for every entity type, on every access check, so it must be cheap and start by testing the type.

  • To act as another user in a Drush command or a queue worker (for example to test the museum’s curator rules), use the account_switcher service: switchTo($account) …​ switchBack() in a try/finally.

  • Field access ($field->access('edit')) hides fields in forms and responses. It is not applied by $entity->set().

  • The view operation on a translation checks the published status of that translation, and update requires access to the default translation in some entity types (content translation permissions).

Testing entity code

Entity code is best covered with Kernel tests: they boot a minimal container and a real (SQLite) database, install the entity schema and run the storage, hooks, constraints and access handlers for real, in a few milliseconds per test. Unit tests suit services that only talk to mocked storage. Core uses PHPUnit 11 with attributes since 11.2 (see Automated testing).

<?php

declare(strict_types=1);

namespace Drupal\Tests\meridian_collection\Kernel;

use Drupal\KernelTests\KernelTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Tests the collection_object entity API usage.
 */
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
class CollectionObjectEntityTest extends KernelTestBase {

  protected static $modules = ['system', 'user', 'language', 'content_translation', 'meridian_collection'];

  protected function setUp(): void {
    parent::setUp();
    $this->installEntitySchema('user');
    $this->installEntitySchema('collection_object');
    $this->installConfig(['language']);
  }

  public function testSaveValidateAndRevision(): void {
    $storage = $this->container->get('entity_type.manager')->getStorage('collection_object');
    $object = $storage->create([
      'name' => 'Bronze lamp',
      'accession_number' => 'MM.2024.017',
      'period' => 'Roman',
      'on_display' => TRUE,
    ]);
    $this->assertCount(0, $object->validate());
    $this->assertSame(SAVED_NEW, $object->save());

    // A second save with a new revision keeps the first one.
    $object->setNewRevision(TRUE);
    $object->set('period', 'Late Roman');
    $object->save();
    $this->assertCount(2, $storage->getQuery()->accessCheck(FALSE)->allRevisions()->condition('id', $object->id())->execute());
  }

  public function testOnDisplayNeedsPeriod(): void {
    $object = $this->container->get('entity_type.manager')->getStorage('collection_object')->create([
      'name' => 'Unknown fragment',
      'accession_number' => 'MM.2024.018',
      'on_display' => TRUE,
    ]);
    $violations = $object->validate();
    $this->assertCount(1, $violations->getByField('period'));
  }

}

Security checklist

  • Always pass accessCheck() deliberately: TRUE for output that visitors see, FALSE for trusted jobs. A forgotten FALSE in a controller discloses unpublished or restricted content.

  • Treat loadByProperties(), loadMultiple() and load() as unfiltered. They return anything in storage.

  • Never pass raw request data to create() or set(). An attacker who controls the values can set uid, status, promote, revision_log or any field the code did not mean to expose. Copy only an allow-list of keys.

  • Run validate() on data that comes from outside the site, and use constraints for business rules so the same rule applies to forms, JSON:API and your own code.

  • Check $entity->access() (and $field->access()) when code acts on behalf of a user, and never rely on hiding links or form elements as protection.

  • Return cacheable access results with proper cache contexts and tags. An access decision cached for the wrong audience is an authorisation bypass through the page cache.

  • Log changes to sensitive entities from hooks with the account that made them, not by trusting a submitted user ID.

  • Do not expose drush php:eval or similar debugging helpers in production, and never eval() strings that include entity data.

  • Mass changes belong in a queue or a batch so a timeout cannot leave data half-processed.

Common errors and how to fix them

Symptom Likely cause Fix

QueryException: "Entity queries must explicitly set whether the query should be access checked or not"

The query has no accessCheck() call (an exception since Drupal 10).

Add ->accessCheck(TRUE) or ->accessCheck(FALSE) to the query.

PluginNotFoundException for the entity type

Typo in the entity type ID, the module is not installed, or the plugin cache is stale.

Check the ID against getDefinitions(), enable the module, then drush cache:rebuild.

InvalidArgumentException: field does not exist

get() on a bundle without the field, or a field removed by an update.

Test with hasField() before reading.

LogicException: Entity validation was skipped

Validation was marked required (forms do this) but validate() was not called before save().

Call $entity->validate() and handle the violations, or do not mark it required.

A saved entity does not show new values

Render cache or page cache still holds the old output; the entity cache tag was not invalidated, or the object is a stale copy from the static cache.

Make renders depend on the entity cache tags; use loadUnchanged() when you need fresh data; clear caches while developing.

EntityStorageException: "Duplicate entry" or attempt to update a missing row

A fixed ID was assigned to a new entity without enforceIsNew(TRUE), or the ID already exists.

Call enforceIsNew(TRUE) for a deliberate import, or let the database assign the ID.

Memory exhausted in Drush or cron

loadMultiple() without chunks; the static cache keeps every entity.

Process in chunks of 50 to 200 and call resetCache().

Draft saved as the live version

setNewRevision(TRUE) with default revision on a moderated entity.

Let Content Moderation set the state, or call isDefaultRevision(FALSE) for drafts.

Hook does not run

Wrong hook name in #[Hook()], class outside the Drupal\meridian_collection\Hook namespace, or the cache was not rebuilt.

Check the name against entity.api.php, move the class to src/Hook, run drush cache:rebuild.

A constraint is ignored

Cached field definitions, a wrong plugin ID or a type that does not match the target.

Rebuild the cache; make the attribute id match the one used in addConstraint().

Access denied for a curator in a queue worker

The worker runs as the anonymous user.

Switch accounts with account_switcher or call the API with accessCheck(FALSE) deliberately.

Version notes and corrections to older sources

  • Hooks: since Drupal 11.1 hook implementations are methods marked #[Hook('...')] on autowired classes in src/Hook; ordering, removal and replacement attributes arrived in 11.2, and themes can implement hooks as classes from 11.3. A handful of hooks, including hook_install, hook_uninstall, hook_schema, hook_requirements, hook_update_N and hook_post_update_NAME, must remain procedural. Older books such as the Drupal 10 Development Cookbook (2023) show every entity hook as a .module function.

  • Plugin attributes: plugins, including entity types and constraint plugins, use PHP attributes (#[ContentEntityType], #[Constraint]) since 10.2, and all core plugin types support them in 11.2. Annotations still work but are on a deprecation path. Core forked the parts of the abandoned doctrine/annotations it needs in 10.6.

  • Services: constructor injection and autowiring are the norm; \Drupal:: statics belong in procedural code and tests. Books from 2023 often call \Drupal::entityTypeManager() inside classes, which this page replaces with injection.

  • Entity queries: accessCheck() is mandatory (exception since 10.0). In Drupal 12, query objects are no longer serialisable.

  • Constraint plugins: array-style options are deprecated since 11.3 and removed in 12 (Symfony 8); use named arguments with #[HasNamedArguments] (change record for 11.4).

  • Removed APIs: \Drupal::entityManager() (removed in Drupal 9) and the procedural loaders such as node_load(). Use the storage.

  • Drupal 12 (planned for the week of 2026-12-07; Drupal 10 ends on 2026-12-09) requires PHP 8.5 and Symfony 8. For this API the practical consequences are the constraint constructor change and non-serialisable queries. Start upgrades from 11.3 or later (11.4 or 11.5 recommended), and run Drupal Rector and the deprecation checks before moving.

  • Documentation state: several Entity API pages on drupal.org are still marked as stubs or "to be determined" (the page on supported validation constraints and the page on access on entities), so the API reference on api.drupal.org and the core source (core/lib/Drupal/Core/Entity) are the authoritative descriptions of behaviour.

Where a book and this page disagree, the official documentation and the 11.x source win. See Where older books and tutorials are outdated for the collected list.

Where to go next