Custom Entity Types
|
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. |
A content entity type is the way Drupal stores structured, fieldable, translatable and revisionable data that belongs to
the site’s content rather than to its configuration. Nodes, users, taxonomy terms, media items, comments, custom blocks and
files are all content entity types. When none of them fits a domain object, a module can define its own. This page is for
module developers; site builders get the other half of the story, the screens that such an entity gives them, in the
"Admin UI" sections. It builds the collection_object entity of the meridian_collection module for the fictitious
Meridian Museum: one record for each museum object, with an accession number, a title, a description, a period, a
material, dimensions and an "on display" flag, administered under Content > Collection objects, revisioned,
translatable into Spanish and publishable.
It was checked on 2026-10-30 against Drupal 11.4.x, PHP 8.3+ and Drush 13/14. Defining an entity type with a PHP attribute requires Drupal 11.1 or later; Drupal 10 reaches end of life on 2026-12-09, so a new module targets Drupal 11. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and keeps the entity type system described here. The concepts that this page uses are introduced elsewhere and only recalled briefly: entities, bundles and fields in Content types and fields, field types and entity references in Field types and entity references, loading and saving entities in Entity API, routes and controllers in Routing and controllers, and the module scaffold in Module anatomy and info.yml.
When a custom entity type is the right tool
Defining an entity type is a long-term commitment: it adds database tables, permissions, routes and an API that other code and, later, migrations will depend on. Before writing one, check the alternatives.
| Need | Usually best | Why |
|---|---|---|
Editorial pages with a title, body, images and a URL |
A content type (a bundle of the |
Nodes already have moderation, revisions, translations, search, path aliases, Layout Builder and Canvas support. |
A handful of fields on an existing thing |
A configurable field on that bundle |
No code, no schema change, and the Field UI manages it. |
A catalogue of many records that are not pages: museum objects, inventory, bookings, sponsors |
A custom content entity type |
You control the schema, the permissions, the routes and the lifecycle, and the records do not pollute the node table or the content listing of editors. |
A list of options, a mapping or a setting that is exported with configuration |
A configuration entity type or simple configuration |
Content entities are not exportable with configuration management; see Configuration API and schema. |
Data owned by a third-party system that Drupal only displays |
An external service plus a plugin or a Views data source |
Copying the data into entities creates a synchronisation problem to maintain. |
The Meridian Museum treats the collection as a custom entity because there are tens of thousands of objects, they are managed by curators rather than editors, they have their own access rules (curators see unpublished objects that visitors cannot) and they are synchronised from a collections-management system by a queue worker (see Cron, queues and maintenance).
The anatomy of a content entity type
Four pieces cooperate. Understanding which piece does what makes the rest of the page much easier to follow.
-
The entity type definition is metadata: the ID, labels, entity keys, table names, link templates and the list of handlers. Since Drupal 11.1 it is a
#[ContentEntityType]attribute on the entity class; before 11.1 it was an@ContentEntityTypeannotation in a doc comment. -
The entity class (and its interface) is the object that represents one record. It extends
ContentEntityBaseor one of its subclasses, and declares the base fields with a static method. -
The handlers are small classes, each responsible for one concern, named by key in the definition:
storage,view_builder,list_builder,form,access,route_provider,views_dataand others. Core supplies sensible defaults for most of them, and a module replaces only the ones it needs to change. -
The supporting YAML files declare what is not an entity concern: permissions, menu links, local tasks and actions.
Installing the module reads these definitions, creates the tables from the base field definitions and records the definitions as the "last installed" state. From then on, any change to the code definition that differs from the stored state is a pending entity definition update, which must be applied by an update function (see Installing and updating the entity type).
Decide the shape before writing code
Several choices change the database schema and are expensive to reverse once data exists, so make them first.
| Question | Meridian Museum answer and consequence |
|---|---|
Is it fieldable? |
Yes. A content entity is fieldable when it declares |
Does it have bundles? |
No. A bundle-less entity type has one implicit bundle named after the entity type ID ( |
Is it revisionable? |
Yes. Curators need to see and revert earlier descriptions. This adds a revision table and |
Is it translatable? |
Yes (English and Spanish). This adds a data table and a |
Is it publishable? |
Yes. A |
Does it have an owner? |
Yes. The |
The decisions determine the superclass. ContentEntityBase gives a plain fieldable entity.
RevisionableContentEntityBase adds revisions and the revision log. EditorialContentEntityBase extends it with the
published status, which is what a content-like entity wants, and it is the base class used here.
Scaffolding with Drush
The code generator that ships with Drush writes a working skeleton, which is a faster and less error-prone start than an empty file. Run it inside the project, for example with DDEV:
# Create the module directory, then generate the entity into it.
ddev drush generate module # name: Meridian Collection, machine name: meridian_collection
ddev drush generate entity:content # answer the questions about labels, ID, revisions, translations, owner
ddev drush generate entity:content --dry-run # show the code without writing files
Source: Drush: entity:content generator
The generator asks for the module, the entity type label and ID, the class name, the base path, and whether the entity should be fieldable, have bundles, be revisionable and translatable, have an owner and standard fields, and so on. The set of questions and the files it writes depend on the version of the generator, which Drush installs as a dependency. It typically writes the entity class and interface, a list builder, a form, an access control handler, the routing, permissions, local task, action and menu YAML, and a Twig template. Treat the output as a draft:
-
Check whether the generated class uses an annotation or an attribute. Attributes are the form to use on Drupal 11.1 and later; an annotation still works through a compatibility layer, but it is on a deprecation path.
-
Replace
\Drupal::calls in generated classes by injected services, as in the examples below. -
Remove what the entity does not need. Every generated handler and permission is something to test and secure.
-
Run
drush generateagain later to scaffold other pieces; it does not update existing files.
Older books, such as Drupal 10 Development Cookbook (2023), show this generator and its annotation-based output; the
workflow is unchanged but the output now differs, and Drush 13 dropped the Drush 12 era drush.services.yml style of
registering commands, so do not copy old command classes. The step-by-step guide on drupal.org with the same name as this
page (Creating a content entity type in Drupal 8, last updated in November 2024) still uses the annotation form,
hand-written routes and an info.yml for Drupal 8.7 and 9, and itself recommends the generator as an alternative to typing the code.
The module scaffold
# web/modules/custom/meridian_collection/meridian_collection.info.yml
name: Meridian Collection
type: module
description: 'The museum collection: objects, their admin screens and synchronisation.'
package: Meridian Museum
core_version_requirement: ^11.1
dependencies:
- drupal:options
- drupal:text
- drupal:user
core_version_requirement: ^11.1 states the real floor: the entity type attribute does not exist in 10.x or 11.0. A
module that must also run on Drupal 10 has to keep the annotation, and cannot use the OOP hooks of the rest of this
site either. The site’s local environment is described in
Installation with Composer and DDEV; a minimal DDEV file for the
examples is:
# .ddev/config.yaml
name: meridian
type: drupal11
docroot: web
php_version: "8.3"
webserver_type: nginx-fpm
database:
type: mariadb
version: "10.11"
Source: DDEV: CMS quickstarts
Permissions
The entity needs its own permissions, so that curators, reviewers and visitors can be given different abilities in the
People > Permissions screen. A module declares them in meridian_collection.permissions.yml:
# meridian_collection.permissions.yml
administer collection objects:
title: 'Administer collection objects'
description: 'Manage the fields, displays and settings of collection objects. Grants every operation on every object.'
restrict access: true
view collection objects:
title: 'View published collection objects'
view unpublished collection objects:
title: 'View unpublished collection objects'
create collection objects:
title: 'Create collection objects'
edit collection objects:
title: 'Edit collection objects'
delete collection objects:
title: 'Delete collection objects'
view collection object revisions:
title: 'View collection object revisions'
revert collection object revisions:
title: 'Revert collection object revisions'
delete collection object revisions:
title: 'Delete collection object revisions'
restrict access: true puts a "Warning: give to trusted roles only" marker on the permission, which it should always carry
for an administrative permission. The same names are used in the routing alter of
Events and subscribers (edit collection objects) and in the access handler
below. Declaring permissions in code is covered in depth in
Access control and permissions in code. For a larger entity or
one with bundles, permissions can be generated from the entity type definition instead of typed by hand, driven by the
permission_granularity setting (entity_type by default, or bundle) of the definition and a permission provider
handler.
The interface and the entity class
An interface describing the public API of the entity lets other code depend on the contract rather than the class.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Entity;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\user\EntityOwnerInterface;
/**
* Provides an interface for museum collection objects.
*/
interface CollectionObjectInterface extends ContentEntityInterface, EntityChangedInterface, EntityOwnerInterface, EntityPublishedInterface, RevisionLogInterface {
/**
* Gets the museum inventory number, for example MM.1987.0042.
*/
public function getAccessionNumber(): string;
/**
* Tells whether the object is currently in a gallery.
*/
public function isOnDisplay(): bool;
/**
* Marks the object as on display or in storage.
*/
public function setOnDisplay(bool $on_display): static;
}
Source: API: ContentEntityInterface
The class below is the heart of the page. Read it in four parts: the attribute (the definition), the traits, the base fields and the small methods.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Entity;
use Drupal\Core\Entity\Attribute\ContentEntityType;
use Drupal\Core\Entity\ContentEntityDeleteForm;
use Drupal\Core\Entity\EditorialContentEntityBase;
use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Form\RevisionDeleteForm;
use Drupal\Core\Entity\Form\RevisionRevertForm;
use Drupal\Core\Entity\Routing\AdminHtmlRouteProvider;
use Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\meridian_collection\CollectionObjectAccessControlHandler;
use Drupal\meridian_collection\CollectionObjectListBuilder;
use Drupal\meridian_collection\CollectionObjectStorage;
use Drupal\meridian_collection\CollectionObjectViewsData;
use Drupal\meridian_collection\Form\CollectionObjectForm;
use Drupal\user\EntityOwnerTrait;
/**
* Defines the collection object entity: one record per museum object.
*/
#[ContentEntityType(
id: 'collection_object',
label: new TranslatableMarkup('Collection object'),
label_collection: new TranslatableMarkup('Collection objects'),
label_singular: new TranslatableMarkup('collection object'),
label_plural: new TranslatableMarkup('collection objects'),
label_count: [
'singular' => '@count collection object',
'plural' => '@count collection objects',
],
entity_keys: [
'id' => 'id',
'revision' => 'revision_id',
'uuid' => 'uuid',
'label' => 'title',
'langcode' => 'langcode',
'owner' => 'uid',
'published' => 'status',
],
handlers: [
'storage' => CollectionObjectStorage::class,
'list_builder' => CollectionObjectListBuilder::class,
'views_data' => CollectionObjectViewsData::class,
'access' => CollectionObjectAccessControlHandler::class,
'form' => [
'default' => CollectionObjectForm::class,
'delete' => ContentEntityDeleteForm::class,
'revision-delete' => RevisionDeleteForm::class,
'revision-revert' => RevisionRevertForm::class,
],
'route_provider' => [
'html' => AdminHtmlRouteProvider::class,
'revision' => RevisionHtmlRouteProvider::class,
],
],
links: [
'canonical' => '/collection/{collection_object}',
'collection' => '/admin/content/collection',
'add-form' => '/admin/content/collection/add',
'edit-form' => '/admin/content/collection/{collection_object}/edit',
'delete-form' => '/admin/content/collection/{collection_object}/delete',
'version-history' => '/admin/content/collection/{collection_object}/revisions',
'revision' => '/admin/content/collection/{collection_object}/revisions/{collection_object_revision}/view',
'revision-revert-form' => '/admin/content/collection/{collection_object}/revisions/{collection_object_revision}/revert',
'revision-delete-form' => '/admin/content/collection/{collection_object}/revisions/{collection_object_revision}/delete',
],
base_table: 'collection_object',
data_table: 'collection_object_field_data',
revision_table: 'collection_object_revision',
revision_data_table: 'collection_object_field_revision',
translatable: TRUE,
show_revision_ui: TRUE,
revision_metadata_keys: [
'revision_user' => 'revision_user',
'revision_created' => 'revision_created',
'revision_log_message' => 'revision_log_message',
],
admin_permission: 'administer collection objects',
field_ui_base_route: 'entity.collection_object.settings',
)]
class CollectionObject extends EditorialContentEntityBase implements CollectionObjectInterface {
use EntityChangedTrait;
use EntityOwnerTrait;
/**
* {@inheritdoc}
*/
public static function baseFieldDefinitions(EntityTypeInterface $entity_type): array {
// Adds id, uuid, revision_id, langcode, the revision log fields and the
// published status.
$fields = parent::baseFieldDefinitions($entity_type);
// Adds the uid field with the current user as default value.
$fields += static::ownerBaseFieldDefinitions($entity_type);
$fields['uid']
->setLabel(new TranslatableMarkup('Curator'))
->setRevisionable(TRUE)
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['title'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Title'))
->setRequired(TRUE)
->setTranslatable(TRUE)
->setRevisionable(TRUE)
->setSetting('max_length', 255)
->setDisplayOptions('form', ['type' => 'string_textfield', 'weight' => -5])
->setDisplayOptions('view', ['label' => 'hidden', 'type' => 'string', 'weight' => -5])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
// An identifier: not translatable, so every translation shares one value.
$fields['accession_number'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Accession number'))
->setDescription(new TranslatableMarkup('The museum inventory number, for example MM.1987.0042.'))
->setRequired(TRUE)
->setRevisionable(TRUE)
->setTranslatable(FALSE)
->setSetting('max_length', 32)
->addConstraint('UniqueField')
->setDisplayOptions('form', ['type' => 'string_textfield', 'weight' => -4])
->setDisplayOptions('view', ['label' => 'inline', 'type' => 'string', 'weight' => -4])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['description'] = BaseFieldDefinition::create('text_long')
->setLabel(new TranslatableMarkup('Description'))
->setTranslatable(TRUE)
->setRevisionable(TRUE)
->setDisplayOptions('form', ['type' => 'text_textarea', 'weight' => 0])
->setDisplayOptions('view', ['label' => 'hidden', 'type' => 'text_default', 'weight' => 0])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['period'] = BaseFieldDefinition::create('list_string')
->setLabel(new TranslatableMarkup('Period'))
->setRevisionable(TRUE)
->setSetting('allowed_values', [
'neolithic' => 'Neolithic',
'roman' => 'Roman',
'medieval' => 'Medieval',
'industrial' => 'Industrial age',
'modern' => 'Modern',
])
->setDisplayOptions('form', ['type' => 'options_select', 'weight' => 1])
->setDisplayOptions('view', ['label' => 'inline', 'type' => 'list_default', 'weight' => 1])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['material'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Material'))
->setTranslatable(TRUE)
->setRevisionable(TRUE)
->setSetting('max_length', 128)
->setDisplayOptions('form', ['type' => 'string_textfield', 'weight' => 2])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['dimensions'] = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Dimensions'))
->setDescription(new TranslatableMarkup('Height by width by depth, with the unit.'))
->setRevisionable(TRUE)
->setSetting('max_length', 128)
->setDisplayOptions('form', ['type' => 'string_textfield', 'weight' => 3])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['on_display'] = BaseFieldDefinition::create('boolean')
->setLabel(new TranslatableMarkup('On display'))
->setRevisionable(TRUE)
->setDefaultValue(FALSE)
->setSetting('on_label', new TranslatableMarkup('On display'))
->setSetting('off_label', new TranslatableMarkup('In storage'))
->setDisplayOptions('form', ['type' => 'boolean_checkbox', 'weight' => 4])
->setDisplayConfigurable('form', TRUE)
->setDisplayConfigurable('view', TRUE);
$fields['created'] = BaseFieldDefinition::create('created')
->setLabel(new TranslatableMarkup('Created'))
->setDescription(new TranslatableMarkup('When the record was created.'));
$fields['changed'] = BaseFieldDefinition::create('changed')
->setLabel(new TranslatableMarkup('Changed'))
->setTranslatable(TRUE)
->setRevisionable(TRUE)
->setDescription(new TranslatableMarkup('When the record was last edited.'));
return $fields;
}
/**
* {@inheritdoc}
*/
public function preSave(EntityStorageInterface $storage): void {
parent::preSave($storage);
// Keep accession numbers canonical so that the uniqueness check is reliable.
$this->set('accession_number', strtoupper(trim($this->getAccessionNumber())));
}
/**
* {@inheritdoc}
*/
public function getAccessionNumber(): string {
return (string) $this->get('accession_number')->value;
}
/**
* {@inheritdoc}
*/
public function isOnDisplay(): bool {
return (bool) $this->get('on_display')->value;
}
/**
* {@inheritdoc}
*/
public function setOnDisplay(bool $on_display): static {
$this->set('on_display', $on_display);
return $this;
}
}
Source: API: ContentEntityType attribute
Walk through the choices.
The attribute
The attribute’s arguments are the keys of the old annotation, written as PHP named arguments. Strings that were
@Translation("...") are new TranslatableMarkup('...') objects, and class names are ::class constants, which lets
the IDE and static analysis follow them. The arguments are:
| Argument | Purpose |
|---|---|
|
The machine name of the entity type. It is also the name of the route parameter, of the implicit bundle and of the
hook names ( |
|
Human-readable names used by the UI, by Views, by messages and by the permission screens. |
|
Maps the roles that core understands to field names. |
|
The classes that implement each concern; see Handlers. |
|
URL templates, keyed by link template name ( |
|
The database tables. See The tables. A table that is not named is not created, and the entity type’s behaviour then follows the combination that is named. |
|
Turns on the translation system for the entity type. The module |
|
Shows the revision information and the "Create new revision" control on the entity edit form, for users with the revision permissions. |
|
Maps the three revision metadata roles (user, created time and log message) to the fields that
|
|
The permission that grants every operation. Core’s default access handler and route provider read it. A
|
|
The route that Field UI attaches its Manage fields, Manage form display and Manage display tabs to. Without it the entity cannot be extended with configurable fields in the UI. |
|
Link a content entity to a configuration entity that defines its bundles. |
|
When |
|
The cache tag and contexts attached to every listing of the entity type. By default the tag is the entity type ID with a |
|
Switches for the three entity caches. All are on by default; turn one off only with a measured reason. |
Drupal 11.4 deprecates the uri_callback argument (it is removed in Drupal 13.0.0): declare a canonical link template
instead of computing the URL in a callback.
The traits
EntityChangedTrait implements getChangedTime() and setChangedTime() on top of the field named changed, and the
changed field type updates itself on every save. EntityOwnerTrait implements getOwner(), setOwner(),
getOwnerId() and setOwnerId() for the owner key, and ownerBaseFieldDefinitions() creates the uid field with the
current user as the default value. EditorialContentEntityBase already uses EntityPublishedTrait (with isPublished(),
setPublished() and setUnpublished()) and the revision log trait, so the class only adds what is missing.
Base fields
Base fields are defined once, in code, for every instance of the entity type. A few rules matter more than the others:
-
Every base field that should be versioned must call
setRevisionable(TRUE), and every base field that may have a value per language must callsetTranslatable(TRUE). Neither is the default. A field that is not revisionable keeps one value for all revisions, and reverting a revision does not restore it; a field that is not translatable is shared by all translations. The accession number is deliberately untranslatable. -
setRequired(),setDefaultValue()andaddConstraint()become validation rules. TheUniqueFieldconstraint rejects a second object with the same accession number during entity validation, which both the form and the JSON:API run before saving. -
setDisplayOptions()gives the default widget and formatter;setDisplayConfigurable()lets a site builder change them in the Field UI. WithoutsetDisplayConfigurable(), the display of the field is fixed in code. The field types, widgets and formatters are listed in Field types and entity references. -
A reference to another entity (a
gallerytaxonomy term, for instance) is anentity_referencebase field withtarget_typeand handler settings; that section of the other page has an example. -
The field names must be unique across base fields and configurable fields of the entity type, and the base field names appear as database column names, so keep them short and lower case.
preSave() is the place for normalisation that must happen for every save path: the form, an import, a queue worker and a
Drush script. Putting it in a form handler would skip the others.
Handlers
The handlers argument is a map from a concern to a class. Core calls them through the entity type manager:
$entity_type_manager->getStorage('collection_object'), ->getListBuilder(), ->getAccessControlHandler(),
->getFormObject('collection_object', 'default') and ->getViewBuilder(). A handler class receives its entity type and
usually its dependencies through createInstance() (it implements EntityHandlerInterface), which is where constructor
injection is wired.
| Key | Default | Role |
|---|---|---|
|
|
Loads, saves and deletes entities and creates the tables. Replace it with a subclass to add query helpers. |
|
|
Builds the table schema from the field definitions. Subclass it to add database indexes. |
|
|
Builds the render array of an entity for a view mode and applies the view display. |
|
none |
Builds the administrative listing table with its operation links. |
|
|
Maps form operations to classes. The route provider picks |
|
|
Decides whether an account may view, update or delete an entity, or create a new one. |
|
none |
Generates the routes from the |
|
none |
Describes the tables to Views, so that the entity can be listed, filtered and related in a view. |
|
added by Content Translation |
The translation UI handler. Content Translation adds it to a translatable type automatically. |
Storage
Query helpers that depend on the entity type belong in a storage subclass, so that controllers and workers do not repeat entity queries. The storage is a service-like object: it already holds the entity type, the database and the caches.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
/**
* Storage for collection objects.
*/
class CollectionObjectStorage extends SqlContentEntityStorage implements CollectionObjectStorageInterface {
/**
* {@inheritdoc}
*/
public function loadByAccessionNumber(string $accession_number): ?CollectionObjectInterface {
$ids = $this->getQuery()
->accessCheck(TRUE)
->condition('accession_number', strtoupper(trim($accession_number)))
->range(0, 1)
->execute();
return $ids ? $this->load(reset($ids)) : NULL;
}
}
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Entity\Sql\SqlEntityStorageInterface;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
/**
* Storage contract for collection objects.
*/
interface CollectionObjectStorageInterface extends SqlEntityStorageInterface {
/**
* Loads the object that has the given accession number, if any.
*/
public function loadByAccessionNumber(string $accession_number): ?CollectionObjectInterface;
}
Source: API: SqlContentEntityStorage
Every entity query must state whether access is checked: accessCheck(TRUE) removes entities the current user cannot see,
accessCheck(FALSE) does not. A query that omits the call throws an exception. The lookup above checks access, because a
visitor-facing code path will use it; a synchronisation worker that runs without a user would use a separate method with an
explicit accessCheck(FALSE) and a comment saying why. More about queries is in
Database API and entity queries.
List builder
The list builder renders the administrative listing at Content > Collection objects. For a large collection a View is usually better, because site builders can add filters and exposed forms without code (see the "Views data" part below), but a list builder is the right minimum: it works without the Views module and provides the operations links.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\Core\Datetime\DateFormatterInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityListBuilder;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Lists collection objects for curators.
*/
class CollectionObjectListBuilder extends EntityListBuilder {
public function __construct(
EntityTypeInterface $entity_type,
EntityStorageInterface $storage,
protected readonly DateFormatterInterface $dateFormatter,
) {
parent::__construct($entity_type, $storage);
}
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type): static {
return new static(
$entity_type,
$container->get('entity_type.manager')->getStorage($entity_type->id()),
$container->get('date.formatter'),
);
}
/**
* {@inheritdoc}
*/
protected function getEntityIds(): array {
$query = $this->getStorage()->getQuery()
->accessCheck(TRUE)
->sort('changed', 'DESC');
if ($this->limit) {
$query->pager($this->limit);
}
return $query->execute();
}
/**
* {@inheritdoc}
*/
public function buildHeader(): array {
$header['accession_number'] = $this->t('Accession number');
$header['title'] = $this->t('Title');
$header['period'] = $this->t('Period');
$header['on_display'] = $this->t('Location');
$header['status'] = $this->t('Status');
$header['changed'] = $this->t('Updated');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity): array {
assert($entity instanceof CollectionObjectInterface);
$row['accession_number'] = $entity->getAccessionNumber();
$row['title']['data'] = $entity->toLink();
$row['period'] = $entity->get('period')->value ?? '';
$row['on_display'] = $entity->isOnDisplay() ? $this->t('On display') : $this->t('In storage');
$row['status'] = $entity->isPublished() ? $this->t('Published') : $this->t('Unpublished');
$row['changed'] = $this->dateFormatter->format($entity->getChangedTime(), 'short');
return $row + parent::buildRow($entity);
}
}
Source: API: EntityListBuilder
The class does not call \Drupal::service(). Handlers are created by the entity type manager through createInstance(),
so the constructor receives the date formatter. Overriding getEntityIds() is how the sort order and the paging are
changed, and the list builder’s limit property (50 by default) sets the page size. buildOperations() produces the
Edit, Delete and Translate links, filtered by access, and adds the destination so that the editor returns to the list.
Forms
One class serves the add and edit forms. ContentEntityForm already builds the form display widgets, validates the entity
and saves it; the subclass only decides what happens after the save.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Form;
use Drupal\Core\Entity\ContentEntityForm;
use Drupal\Core\Form\FormStateInterface;
/**
* Add and edit form of a collection object.
*/
class CollectionObjectForm extends ContentEntityForm {
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state): int {
$result = parent::save($form, $form_state);
$args = ['%label' => $this->entity->label()];
$this->messenger()->addStatus(
$result === SAVED_NEW
? $this->t('Created the collection object %label.', $args)
: $this->t('Saved the collection object %label.', $args),
);
$form_state->setRedirectUrl($this->entity->toUrl('collection'));
return $result;
}
}
Source: API: ContentEntityForm
The delete operation uses core’s ContentEntityDeleteForm, a confirmation page that also lists the translations being
deleted, so no class is needed. The revision forms are core’s RevisionRevertForm and RevisionDeleteForm, enabled in the
form handler map by the keys revision-revert and revision-delete. The route provider chooses among the form
operations: a bundle-less type uses add for the add route and edit for the edit route when those operations exist, and
default otherwise. See Form API for the form lifecycle, validateForm(), form state and
form alter hooks. Validation rules that belong to the data (the accession number format, uniqueness) are better as entity
constraints than as form validation, because they then apply to imports and API writes as well.
Access control
The access handler is the single place that answers "may this account do this to this entity?". Its checkAccess() runs
for an existing entity, checkCreateAccess() for a new one.
<?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;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
/**
* Access control for collection objects.
*/
class CollectionObjectAccessControlHandler extends EntityAccessControlHandler {
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account): AccessResultInterface {
assert($entity instanceof CollectionObjectInterface);
// The base class grants everything to holders of the admin permission.
$admin = parent::checkAccess($entity, $operation, $account);
if ($admin->isAllowed()) {
return $admin;
}
$result = match ($operation) {
'view' => $entity->isPublished()
? 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'),
'view all revisions', 'view revision' => AccessResult::allowedIfHasPermission($account, 'view collection object revisions'),
'revert revision' => AccessResult::allowedIfHasPermission($account, 'revert collection object revisions'),
'delete revision' => AccessResult::allowedIfHasPermission($account, 'delete collection object revisions'),
default => AccessResult::neutral(),
};
// The answer depends on the published status of this entity.
return $result->addCacheableDependency($entity);
}
/**
* {@inheritdoc}
*/
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL): AccessResultInterface {
return AccessResult::allowedIfHasPermission($account, 'create collection objects')
->orIf(parent::checkCreateAccess($account, $context, $entity_bundle));
}
}
Source: API: EntityAccessControlHandler
A few points make access handlers correct rather than merely working.
-
The result is an
AccessResult, not a boolean.allowed,forbiddenandneutralcombine withorIf()andandIf(): "forbidden" always wins, "allowed" wins over "neutral", and if every voter is neutral the answer is "no". -
Access results are cached with the page, so the result must declare what it depends on.
allowedIfHasPermission()adds theuser.permissionscache context;addCacheableDependency($entity)adds the entity’s cache tags, so that a change of the published status invalidates the cached answer. -
The same handler also powers the
_entity_accessand_entity_create_accessroute requirements that the route provider adds, the Edit and Delete operation links, Views row access, and the$entity->access('update')calls in code. Other modules can add rules throughhook_entity_access()andhook_ENTITY_TYPE_access(), which run before the handler and combine with its result. -
Field access is separate:
hook_entity_field_access()or a field access policy controls whether a user can see or edit one field, such as an internal valuation field. -
Node-style grants (
node_accessrecords) exist only for nodes. A custom entity relies on the access handler for single-entity checks and onaccessCheck(TRUE)in queries, which calls the handler for each result; the performance of that on huge listings is a reason to filter in the query.
Route provider
The route provider generates routes from the link templates, so a module does not write routing.yml entries for the standard
CRUD pages. DefaultHtmlRouteProvider builds the canonical, collection, add, edit and delete routes, and
AdminHtmlRouteProvider, used here, extends it so that the add, edit and delete form routes use the administration theme
(the _admin_route option); the canonical page, which visitors see, keeps the public theme.
RevisionHtmlRouteProvider (generic revision UI, since Drupal 10.1) adds the revision history page, the revision view
page and the revert and delete forms.
Routes named after link templates follow the pattern entity.ENTITY_TYPE_ID.LINK_NAME, with dashes of the link name
replaced by underscores. The ones this entity gets are:
| Route name | Path | Behaviour |
|---|---|---|
|
|
Renders the entity with the |
|
|
The list builder. Requires the admin permission (or |
|
|
The |
|
|
The edit form, with an |
|
|
The confirmation form, with an |
|
|
The list of revisions (from |
Source: API: AdminHtmlRouteProvider
Source: API: RevisionHtmlRouteProvider
Because the paths come from the definition, a change to a links value only takes effect after the routes are rebuilt, with
drush cache:rebuild. The routes from
Routing and controllers that start with /collection/ (the provenance page,
the label and the export) sit beside the canonical route and use the same {collection_object} placeholder, which the
entity parameter converter turns into the loaded entity. Where the access rules of a generated route need an
adjustment, alter the route in a route subscriber, as in
Events and subscribers, rather than replacing the route provider.
The settings route that Field UI hangs its tabs on is the one route the module writes by hand:
# meridian_collection.routing.yml (excerpt)
entity.collection_object.settings:
path: '/admin/structure/collection-object'
defaults:
_form: '\Drupal\meridian_collection\Form\CollectionObjectSettingsForm'
_title: 'Collection object'
requirements:
_permission: 'administer collection objects'
CollectionObjectSettingsForm can be a small FormBase page that explains where the fields are managed (the Field UI
tabs are added next to it), or it can hold real entity-type settings. Its route name is the value of field_ui_base_route
in the attribute; a mismatch is the usual reason why the Manage fields tab does not appear.
Views data
EntityViewsData describes the entity’s tables to the Views module: one Views base table for the entity data, with a
field, filter, sort and argument handler for each base field, and relationships to the user and to referenced entities.
That is why a site builder can create a View of collection objects, such as a public "On display now" page, without
code. A subclass adjusts the generated definitions.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection;
use Drupal\views\EntityViewsData;
/**
* Views data for collection objects.
*/
class CollectionObjectViewsData extends EntityViewsData {
/**
* {@inheritdoc}
*/
public function getViewsData(): array {
$data = parent::getViewsData();
// Give the boolean filter a label that curators recognise.
$data['collection_object_field_data']['on_display']['filter']['label'] = $this->t('On display now');
return $data;
}
}
Source: API: EntityViewsData
When the entity is translatable the Views base table is the data table (collection_object_field_data), not the base
table. The Views module is covered in Views; a module that ships default views should also
declare drupal:views as a dependency.
The tables
The combination of table arguments determines the schema. For the Meridian entity the storage creates four tables:
The base table holds the keys that are the same for every language and revision, the data table holds the values of the
current revision for each language, the revision table has one row for each revision, and the revision data table keeps the
values of each revision for each language. An untranslatable, non-revisionable entity needs only the base table, and
the choices are not fully independent: a revisionable entity needs revision_table and a revision key, and a
translatable and revisionable one needs all four. Base fields that are configured as revisionable or translatable move
to the matching tables; this is why the flags in baseFieldDefinitions() are part of the schema and why changing them
later is a schema migration.
Table and index names are limited by the database (64 characters in MySQL and MariaDB, 63 in PostgreSQL), and the field storage adds suffixes; a long entity type ID with long field names can exceed the limit. Keep IDs short.
Links, tabs, actions and menu entries
Routes need to be reachable from the interface. The three YAML files below add the tabs on an object, the Add button on
the listing and the menu entry. The menu link (Content > Collection objects, route
entity.collection_object.collection) is the one shown in
Admin UI, navigation and admin themes.
# meridian_collection.links.task.yml
entity.collection_object.canonical:
route_name: entity.collection_object.canonical
base_route: entity.collection_object.canonical
title: 'View'
entity.collection_object.edit_form:
route_name: entity.collection_object.edit_form
base_route: entity.collection_object.canonical
title: 'Edit'
weight: 10
entity.collection_object.version_history:
route_name: entity.collection_object.version_history
base_route: entity.collection_object.canonical
title: 'Revisions'
weight: 20
entity.collection_object.delete_form:
route_name: entity.collection_object.delete_form
base_route: entity.collection_object.canonical
title: 'Delete'
weight: 30
entity.collection_object.settings_tab:
route_name: entity.collection_object.settings
base_route: entity.collection_object.settings
title: 'Settings'
# meridian_collection.links.action.yml
entity.collection_object.add_form:
route_name: entity.collection_object.add_form
title: 'Add collection object'
appears_on:
- entity.collection_object.collection
A task with base_route equal to its own route is the first tab of a set; the other tasks join that set by naming the same
base route. The edit and revision routes live in the administration theme because of the route provider, while the canonical
tab keeps the public theme, which is why editors switch theme when they go from Edit to View.
The admin UI for site builders and curators
Once the module is enabled, the entity gives site builders these screens without any more code.
| Screen | What it does |
|---|---|
Content > Collection objects ( |
The list builder table with sortable columns, the Add collection object button and a row of operations for each object. |
Add and Edit forms |
The form display: widgets for every base field, a "Revision information" section with the log message and the "Create new revision"
control (because |
Structure > Collection object ( |
The field settings route. It shows the tabs Manage fields, Manage form display and Manage display, with which a site builder adds configurable fields such as an image, a link to a related exhibition, or a gallery term, without touching PHP. |
People > Permissions |
The nine permissions defined earlier, grouped under the module name. |
Configuration > Regional and language > Content language and translation |
Where translation is enabled for the entity type, with the choice of which fields are translatable. |
Structure > Views |
The entity type is available as a View "Show" option, because of the |
The Field UI separates two kinds of fields. Base fields declared in code (the ones above) are always present, are in the
database tables of the entity and are changed with a code update. Configurable fields added in the UI are stored in their own tables, exported with
configuration management and can differ between sites. A module that ships a custom entity therefore picks its fixed
data model for base fields and leaves the extensible part to the Field UI, which is the pattern of the node entity, with
its title and status as base fields and the rest as configurable fields.
Rendering an object: the theme hook and template
The default view builder renders a content entity through a theme hook with the entity type ID as its name, so a module that wants a usable front end for the canonical page registers one, with a template and a preprocess function. Both are OOP hooks from Drupal 11.2 on:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Render\Element;
/**
* Theme integration of the collection object entity.
*/
class CollectionObjectThemeHooks {
/**
* Declares the theme hook that the view builder uses.
*/
#[Hook('theme')]
public function theme(): array {
return [
'collection_object' => [
'render element' => 'elements',
],
];
}
/**
* Prepares the variables of collection-object.html.twig.
*/
#[Hook('preprocess_collection_object')]
public function preprocess(array &$variables): void {
$variables['view_mode'] = $variables['elements']['#view_mode'];
$variables['collection_object'] = $variables['elements']['#collection_object'];
$variables['content'] = [];
foreach (Element::children($variables['elements']) as $key) {
$variables['content'][$key] = $variables['elements'][$key];
}
}
}
{# templates/collection-object.html.twig #}
<article{{ attributes.addClass('collection-object', 'collection-object--' ~ view_mode|clean_class) }}>
<h1>{{ collection_object.label }}</h1>
{{ content|without('title') }}
</article>
Source: Twig in Drupal
The preprocess function names the entity’s theme hook. Element::children() returns the rendered fields in weight order. The
template is a normal Twig file that a theme can override; the suggestion and override mechanism is in
Template suggestions and preprocess. If an entity type is
only used in the administration area, the theme hook can be left out: the entity then renders the fields in a generic way.
Revisions, publishing and translations
Revisions, the published status and translations are decided by the definition and by the field flags, and the entity API then works the same as for nodes.
<?php
// Save a curator's change as a new revision with a log message.
$object->setNewRevision(TRUE);
$object->setRevisionLogMessage('Corrected the dating after the 2026 survey.');
$object->setRevisionUserId($current_user->id());
$object->setRevisionCreationTime($time->getRequestTime());
$object->isDefaultRevision(TRUE);
$object->save();
// Add the Spanish translation, with its own title and description.
$translation = $object->hasTranslation('es')
? $object->getTranslation('es')
: $object->addTranslation('es');
$translation->set('title', 'Hacha de piedra pulida');
$translation->save();
-
Revisions. The entity form creates a new revision when the "Create new revision" box is ticked. Content Moderation forces a new revision for every change. A new revision is not necessarily the default (the one that visitors see): with Content Moderation a draft revision can be ahead of the published one, so code must use
isLatestRevision(),isDefaultRevision()and the storage’sgetLatestRevisionId()carefully. Revert and delete of revisions use the routes ofRevisionHtmlRouteProvider. The deeper treatment is in Content moderation, workflows and revisions. -
Publishing.
statusis the published flag thatEditorialContentEntityBaseprovides. Queries that must return only public objects should either useaccessCheck(TRUE)with an access handler that hides unpublished ones or conditionstatus = 1explicitly. -
Translations. With
translatable: TRUE, thelangcodekey and the data tables, the entity becomes available to Content Translation: enable it forcollection_objectin the language settings. The original language is the language of the first save; translations store only the translatable fields, and thedefault_langcodeandrevision_translation_affectedfields record which row is which. See Multilingual and Translation in code.
Installing and updating the entity type
When a module that defines a content entity type is installed, Drupal installs the entity type: it creates the tables and
stores the definitions as the last installed schema. When the definitions in code later differ from the stored ones, the Status report shows
"Mismatched entity and/or field definitions". Since Drupal 8.7 core no longer applies such changes automatically. A module has to apply
them in an update function, which is why the commands of older books (drush entity-updates, or entup) no longer exist. The change record is
Support for automatic entity updates has been removed.
Update functions live in meridian_collection.install. They must stay procedural and use the entity definition update
manager, which is the one place where \Drupal:: static calls remain acceptable, because there is no object to inject
them into:
<?php
declare(strict_types=1);
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Installs the collection_object entity type on sites that had the module already.
*/
function meridian_collection_update_11001(): void {
$definition = \Drupal::entityTypeManager()->getDefinition('collection_object');
\Drupal::entityDefinitionUpdateManager()->installEntityType($definition);
}
/**
* Adds the provenance base field to collection objects.
*/
function meridian_collection_update_11002(): void {
// Repeat the definition here: the entity class changes over time, and an
// update function must keep working with the definition of its own day.
$storage_definition = BaseFieldDefinition::create('string')
->setLabel(new TranslatableMarkup('Provenance'))
->setTranslatable(TRUE)
->setRevisionable(TRUE)
->setSetting('max_length', 255);
\Drupal::entityDefinitionUpdateManager()->installFieldStorageDefinition(
'provenance',
'collection_object',
'meridian_collection',
$storage_definition,
);
}
The same class that adds a field has methods for the other changes: updateFieldStorageDefinition() for a compatible
change, uninstallFieldStorageDefinition() to remove a base field (remove it from the class and from entity_keys
first, then run cron so that the purged data is deleted), and updateEntityType() for a definition change that does not
alter the schema. Changing the type of a base field means saving the values, clearing them, uninstalling the field, installing the new
definition and restoring the values, inside a transaction. The numbering follows the convention hook_update_N: the
first digit is the core major version the module targets (11), then three digits (001, 002 …). Run the updates with
drush updatedb, or with drush deploy, which also imports configuration; the hooks themselves are in
Update API and post-updates.
Changes that alter the table layout, such as making an existing entity type revisionable or translatable, use
updateFieldableEntityType(), which migrates the stored data. It fires events, so it belongs in a post-update function.
An abridged shape, based on the core conversions of taxonomy terms and custom blocks, is:
<?php
declare(strict_types=1);
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Converts collection objects to revisionable entities (abridged).
*/
function meridian_collection_post_update_make_revisionable(array &$sandbox): TranslatableMarkup {
$update_manager = \Drupal::entityDefinitionUpdateManager();
$last_installed = \Drupal::service('entity.last_installed_schema.repository');
$entity_type = $update_manager->getEntityType('collection_object');
$field_storage_definitions = $last_installed->getLastInstalledFieldStorageDefinitions('collection_object');
// 1. The new keys and tables.
$keys = $entity_type->getKeys();
$keys['revision'] = 'revision_id';
$entity_type->set('entity_keys', $keys);
$entity_type->set('revision_table', 'collection_object_revision');
$entity_type->set('revision_data_table', 'collection_object_field_revision');
// 2. The existing fields that must now be stored per revision.
foreach (['title', 'accession_number', 'description', 'period', 'on_display'] as $name) {
$field_storage_definitions[$name]->setRevisionable(TRUE);
}
// 3. The fields that a revisionable entity needs (revision_id, revision_default and
// the revision metadata fields) are added to $field_storage_definitions here, with
// BaseFieldDefinition::create(), exactly as the core conversions do.
$update_manager->updateFieldableEntityType($entity_type, $field_storage_definitions, $sandbox);
return new TranslatableMarkup('Collection objects have been converted to be revisionable.');
}
The conversion guide on drupal.org says itself that parts of it are outdated and points to the change record above.
Treat the conversion as a project: take a database backup (see
Backups and disaster recovery), rehearse it on a copy of production data and
apply it through drush deploy in a maintenance window. Making an entity translatable changes its base table, so Views
that read the base table have to be updated as well. It is much cheaper to declare revisions and translations in the first
version of the entity type.
Using the entity in code and in tests
Application code gets the storage from the entity type manager, by constructor injection, and calls the helpers.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Service;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\meridian_collection\CollectionObjectStorageInterface;
use Drupal\meridian_collection\Entity\CollectionObjectInterface;
/**
* Finds and updates collection objects.
*/
final class CollectionObjectLocator {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
public function markOnDisplay(string $accession_number): ?CollectionObjectInterface {
$storage = $this->entityTypeManager->getStorage('collection_object');
assert($storage instanceof CollectionObjectStorageInterface);
$object = $storage->loadByAccessionNumber($accession_number);
$object?->setOnDisplay(TRUE)->save();
return $object;
}
}
Creating, loading, querying and deleting entities, field values and typed data, and validation constraints are described in Entity API. A kernel test covers the entity type without a web server: it installs the schema, saves entities and asserts on revisions and validation.
<?php
declare(strict_types=1);
namespace Drupal\Tests\meridian_collection\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\meridian_collection\Entity\CollectionObject;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* Tests the collection object entity type.
*/
#[Group('meridian_collection')]
#[RunTestsInSeparateProcesses]
final class CollectionObjectTest extends KernelTestBase {
protected static $modules = [
'meridian_collection',
'user',
'system',
'field',
'filter',
'options',
'text',
];
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('user');
$this->installEntitySchema('collection_object');
}
public function testRevisionsAreStored(): void {
$object = CollectionObject::create([
'title' => 'Polished stone axe',
'accession_number' => ' mm.1987.0042 ',
]);
$object->save();
$this->assertSame('MM.1987.0042', $object->getAccessionNumber());
$object->setNewRevision(TRUE);
$object->set('title', 'Polished stone axe head');
$object->save();
$revisions = $this->container->get('entity_type.manager')
->getStorage('collection_object')
->getQuery()
->accessCheck(FALSE)
->allRevisions()
->condition('id', $object->id())
->execute();
$this->assertCount(2, $revisions);
}
public function testAccessionNumberMustBeUnique(): void {
CollectionObject::create(['title' => 'First', 'accession_number' => 'MM.2000.0001'])->save();
$duplicate = CollectionObject::create(['title' => 'Second', 'accession_number' => 'MM.2000.0001']);
$this->assertCount(1, $duplicate->validate());
}
}
Source: Automated testing: Kernel tests
PHPUnit 11 uses attributes instead of annotations (#[Group], #[RunTestsInSeparateProcesses]), since Drupal 11.2.
Test the access handler with a functional or kernel test that logs in users with and without each permission; the whole
approach is in Automated testing. To verify the installation by hand:
ddev drush en meridian_collection -y
ddev drush sql:query "SHOW TABLES LIKE 'collection_object%'"
ddev drush updatedb:status
ddev drush php:eval "echo \Drupal::entityTypeManager()->getStorage('collection_object')->getQuery()->accessCheck(FALSE)->count()->execute();"
ddev drush cache:rebuild
Source: Drush: sql:query
Hooks and events for entities
The entity type triggers the generic entity hooks (hook_entity_presave, hook_entity_insert …) and the type-specific
ones named with the entity type ID (hook_collection_object_presave, hook_collection_object_insert). Since Drupal 11.1
they are written as #[Hook('collection_object_presave')] methods on a class in src/Hook, as shown in
Hooks and OOP hooks; the lifecycle class of that page for
collection_object needs nothing else from the entity definition. hook_entity_type_alter() and
hook_entity_base_field_info_alter() let another module change the definition or add base fields to an existing entity type, and
hook_entity_bundle_field_info() adds bundle fields; the latter is in
Content types and fields. Entity-level events and access hooks are listed in
Entity API. Hooks like hook_install() and hook_update_N() are the
exceptions that stay procedural in the .install file.
Security
-
Access control is the access handler. A route requirement and a handler are two defences; use both. Every operation the entity supports must be mapped to a permission, and the default must be "neutral", which means no access. Give
administer collection objectsonly to trusted roles: theadmin_permissionbypasses the other checks. -
Entity queries must call
accessCheck().accessCheck(FALSE)is correct for system tasks such as a queue worker and a data migration, but a controller or a block that lists objects for visitors must useaccessCheck(TRUE)or the unpublished and restricted objects leak. -
Generic APIs expose content entities. With the JSON:API module enabled, every content entity type becomes an endpoint unless
internal: TRUEis set, and the access handler decides what each user can read or write. The Meridian gallery kiosk (see JSON:API) depends on that, so test the anonymous responses for an unpublished object and for theuidand revision fields before enabling the module on production. -
Escape output. Twig autoescapes the values printed in the template above; an entity label printed through
|rawor#markupwith unfiltered input is an XSS hole. Text fields use text formats; see Text formats and CKEditor 5. -
Deletion is a state change. The delete route is behind a confirmation form with a CSRF token. Do not add a link that deletes with a GET request. See Security hardening.
-
Private fields. Fields such as an insurance value should be protected with field access, or kept out of the entity and out of the view display, because a view mode that prints "all fields" otherwise shows them.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
"Mismatched entity and/or field definitions" on the Status report, or a missing-column error after a deployment |
The base field definitions or the entity type changed without an update function. Write |
"The 'collection_object' entity type does not exist" or a missing table in a kernel test |
The test did not call |
The Manage fields tab is missing |
|
Edit and delete pages return 404 or "Page not found" |
Routes are cached: run |
The revisions tab shows "Access denied", although the user may edit |
The revision routes ask the access handler for the operations |
Reverting a revision does not restore a field |
The field is not |
Translations overwrite the accession number |
An identifier was left translatable. Use |
A fatal error says the entity key |
The revision key must be a new name ( |
A listing shows objects that the user cannot open, or none at all |
The list builder query has no, or the wrong, |
The |
The |
Forms and listings call |
Handlers receive their services through |
Version notes and corrections of older material
-
Entity types as attributes: Drupal 11.1. Plugins such as blocks, field types and queue workers moved to attributes in 10.2 and 10.3, but content and configuration entity types followed in 11.1. A module that must support Drupal 10 or 11.0 keeps the annotation. Annotations are still read by a compatibility layer; core change records place the end of annotation support at Drupal 12 or later, so new code should use attributes. Older books such as Drupal 10 Development Cookbook (2023) show
@ContentEntityTypein a doc comment, which is the same data in the older syntax. -
Hooks. From 11.1 hook implementations are attribute methods in
src/Hook; the same books and the generator output use.modulefunctions. From 11.2 preprocess functions can be OOP hooks as well. -
Entity updates. Automatic entity updates were removed in Drupal 8.7. Instructions with
drush entuporentity:updatesno longer work. -
Route providers and revision UI. The generic revision UI (
RevisionHtmlRouteProvider, theversion-history,revision,revision-revert-formandrevision-delete-formlinks, and theRevisionRevertFormandRevisionDeleteFormclasses) arrived in Drupal 10.1. Before it, a module wrote its own revision pages or used a contributed provider; such code can be removed when the generic UI is adopted, but check the access checks, because the generic routes ask the access handler for the revision operations. -
Dependency injection. Examples in books and in the drupal.org guide call
\Drupal::inside classes; use constructor injection andcreateInstance()as above. -
Drupal 12. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and requires PHP 8.5. The entity type system is unchanged. Query objects are no longer serialisable, so do not store an entity query in a property that is serialised. The
uri_callbackargument is removed in 13.0.0, after its deprecation in 11.4. -
Standard profile. The Standard install profile does not create the Article and Page content types from 11.4, so any tutorial that assumes them must create its content types first; the decision table at the start still applies.