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. |
|
Access control handler |
Answers "may this account view, update or delete this entity, or create one of this bundle?" and field access. |
|
List builder |
Builds the administrative table of entities at |
|
View builder |
Turns an entity into a render array for a view mode. |
|
Form objects |
Entity add, edit and delete forms by operation name. |
|
Route provider, views data, translation handler, moderation handler |
Generate routes, expose fields to Views, drive translation and moderation. |
|
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;
}
}
Source: Working with the Entity API
The other services you will meet
-
entity_type.manager(EntityTypeManagerInterface): definitions and handlers, as above.getDefinition('node')returns theEntityTypeInterfaceobject;getDefinitions()returns all of them;hasDefinition()tests existence. -
entity.repository(EntityRepositoryInterface): convenience lookups that the storage does not offer, for exampleloadEntityByUuid($type, $uuid)andgetTranslationFromContext($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]);
Source: Working with the Entity API
Rules worth remembering:
-
load()returnsNULLfor a missing ID. It does not throw. Always handle theNULL. -
loadMultiple()is much cheaper than callingload()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_loadandhook_entity_load(and the entity-type-specific variants) once per load, so those hooks must be cheap. -
Procedural helpers such as
Node::load($nid)ornode_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));
Source: EntityRepositoryInterface
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->originalalso 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);
}
}
Source: EntityStorageInterface
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 withloadMultiple(). -
Every entity query must call
accessCheck(TRUE)oraccessCheck(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. ChooseTRUEfor anything that reaches a visitor andFALSEonly 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 NULLand 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();
Source: Working with the Entity API
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
Source: Drush: entity:delete
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;
}
Source: Working with the Entity API
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();
}
Source: Entity validation API
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), callvalidate()yourself, as above. If validation is flagged as required and you skip it,save()raises aLogicException("Entity validation was skipped"); forms callsetValidationRequired(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)beforesave(); 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
EntityChangedconstraint to refuse a save when the storedchangedtimestamp is newer than the form’s. Programmatic code gets no such protection unless it callsvalidate()on an entity loaded earlier, or usesloadUnchanged()to compare. -
Saving a loaded translation (see below) saves the whole entity, with all its translations.
The lifecycle of a save
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));
}
Source: Working with the Entity API
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 calldeleteRevision($revisionId)on the storage (the default revision cannot be deleted). -
Hooks:
hook_entity_predeleteruns while the entity is still in storage (read related data here; throwing an exception aborts the delete), thenhook_entity_deleteruns after it has been removed. Dependent clean-up belongs inpredelete; clean-up of external systems belongs indeleteor, better, in a queued job. -
Core does not cascade through entity references. Deleting a
collection_objectleaves anyexhibitionthat 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-formroute 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 |
|
|
Field item list |
|
|
Field item |
|
|
Property |
|
|
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
->valueor->target_idstyle access names. Text fields havevalueandformat, a link field hasuri,titleandoptions, an entity reference field hastarget_idand the computedentityproperty. See Field types and entity references. -
->entityon a reference item loads the target entity on demand. Inside a loop over many entities this becomes an N+1 problem; collect IDs and useloadMultiple()orreferencedEntities(), which loads the targets together. -
Reading
->valueon an empty field returnsNULL, and->first()returnsNULL. An empty list is not an error. -
$object->get('nonexistent')throws anInvalidArgumentException;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;
}
Source: Entity validation API
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();
}
}
Source: Entity Translation API
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 withhasTranslation()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 (passFALSEto exclude the default one). -
getUntranslated()returns the original-language object from any translation object, andisDefaultTranslation()tells whether the current object is the original. -
removeTranslation($langcode)removes a translation (the default translation cannot be removed); it fireshook_entity_translation_delete. Creating fireshook_entity_translation_create(object created) andhook_entity_translation_insert(stored); each has an entity-type-specific variant.
Behaviour that surprises developers:
-
Untranslatable fields are shared.
accession_numberandon_displayhave 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 checkshasTranslation()and published status. -
On a revisioned entity, translations belong to revisions, and a flag marks which revisions affected which translation (
isRevisionTranslationAffected()andsetRevisionTranslationAffected()), 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);
}
Source: Making an entity revisionable
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) andgetLatestRevisionId($entityId). -
On the entity (
RevisionableInterface):isNewRevision(),setNewRevision(),isDefaultRevision(),isLatestRevision(),getRevisionId()andgetLoadedRevisionId(). For translatable entities,isLatestTranslationAffectedRevision()andhasTranslationChanges().
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 thenloadRevision(). Saving a draft is$entity->isDefaultRevision(FALSE)plus a new revision, and the moderation workflow sets this for you through themoderation_statefield. 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 beforesave(); 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. UsecreateRevision(). -
$entity->originaland revision comparisons usegetLoadedRevisionId(); do not rely ongetRevisionId()aftersave()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 aspublic $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();
Source: Entity Validation API overview
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
instanceoffirst. -
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
UniqueFieldquery 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 |
|
Add handlers, constraints or fields to entity types owned by another module |
Create |
|
Set computed defaults on a new object; adjust a duplicated or cloned entity |
Load |
|
Attach runtime data to loaded entities (keep cheap) |
Save |
|
Normalise before writing; react after writing |
Delete |
|
Clean dependent data |
Display |
|
Adjust the render array; see Render API and cache metadata |
Forms |
|
Adjust the entity form before it is built |
Access |
|
Add or veto access, see Entity access |
Operations |
|
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_loadsparingly: 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);
Source: Working with the Entity API
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
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 invokeshook_entity_accessandhook_ENTITY_TYPE_accessfrom every module, merges the results, and, when no module forbids, combines that with the result ofcheckAccess()using an "or". -
createAccess()does the same withhook_entity_create_access,hook_ENTITY_TYPE_create_accessandcheckCreateAccess(). -
checkAccess()andcheckCreateAccess()are the protected methods a custom handler overrides. The default forbidsdeleteon 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()andhook_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');
}
}
Source: Access on entities
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);
}
}
Source: hook_entity_access
Points to remember about entity access:
-
Access is not enforced for PHP code that calls
save(),delete()orloadByProperties(). It is enforced for routes that declare_entity_accessrequirements, for entity forms, foraccessCheck(TRUE)queries, for JSON:API and for Views that use query access. Callaccess()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 withaccessCheck(TRUE)and by Views, not by$node->access()alone, which also checks the grants in the node access handler. -
hook_entity_accessruns 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_switcherservice:switchTo($account)…switchBack()in atry/finally. -
Field access (
$field->access('edit')) hides fields in forms and responses. It is not applied by$entity->set(). -
The
viewoperation on a translation checks the published status of that translation, andupdaterequires 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'));
}
}
Source: PHPUnit in Drupal: Kernel tests
Security checklist
-
Always pass
accessCheck()deliberately:TRUEfor output that visitors see,FALSEfor trusted jobs. A forgottenFALSEin a controller discloses unpublished or restricted content. -
Treat
loadByProperties(),loadMultiple()andload()as unfiltered. They return anything in storage. -
Never pass raw request data to
create()orset(). An attacker who controls the values can setuid,status,promote,revision_logor 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:evalor similar debugging helpers in production, and nevereval()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 |
|---|---|---|
|
The query has no |
Add |
|
Typo in the entity type ID, the module is not installed, or the plugin cache is stale. |
Check the ID against |
|
|
Test with |
|
Validation was marked required (forms do this) but |
Call |
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 |
|
A fixed ID was assigned to a new entity without |
Call |
Memory exhausted in Drush or cron |
|
Process in chunks of 50 to 200 and call |
Draft saved as the live version |
|
Let Content Moderation set the state, or call |
Hook does not run |
Wrong hook name in |
Check the name against |
A constraint is ignored |
Cached field definitions, a wrong plugin ID or a |
Rebuild the cache; make the attribute |
Access denied for a curator in a queue worker |
The worker runs as the anonymous user. |
Switch accounts with |
Version notes and corrections to older sources
-
Hooks: since Drupal 11.1 hook implementations are methods marked
#[Hook('...')]on autowired classes insrc/Hook; ordering, removal and replacement attributes arrived in 11.2, and themes can implement hooks as classes from 11.3. A handful of hooks, includinghook_install,hook_uninstall,hook_schema,hook_requirements,hook_update_Nandhook_post_update_NAME, must remain procedural. Older books such as the Drupal 10 Development Cookbook (2023) show every entity hook as a.modulefunction. -
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 abandoneddoctrine/annotationsit 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 asnode_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
-
Define the entity itself: Custom entity types.
-
Queries and tables: Database API and entity queries.
-
Permissions,
AccessResult, node grants and the Access Policy API: Access control and permissions in code. -
Multilingual configuration and interface strings: Multilingual and Translation in code.
-
Moderated revisions: Content moderation, workflows and revisions.
-
Hooks and events: Hooks and OOP hooks and Events and subscribers.
-
Reading and writing entities over HTTP: JSON:API.