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 node entity type)

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 @ContentEntityType annotation in a doc comment.

  • The entity class (and its interface) is the object that represents one record. It extends ContentEntityBase or 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_data and 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.

flowchart LR D["Entity type definition: ContentEntityType attribute"] --> C["Entity class and interface: base fields, getters"] D --> H["Handlers named in the definition"] H --> S["storage: SqlContentEntityStorage or a subclass"] H --> VB["view_builder: EntityViewBuilder"] H --> LB["list_builder: CollectionObjectListBuilder"] H --> F["form: default, delete, revision-revert, revision-delete"] H --> A["access: CollectionObjectAccessControlHandler"] H --> RP["route_provider: AdminHtmlRouteProvider, RevisionHtmlRouteProvider"] H --> VD["views_data: EntityViewsData or a subclass"] RP --> R["Routes: entity.collection_object.canonical, collection, add_form, edit_form, delete_form"] D --> L["links: URL templates for each route"] L --> RP S --> T["Tables: collection_object, collection_object_field_data, revision tables"] Y["YAML: permissions, links.task, links.action, links.menu"] --> R

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 field_ui_base_route, and Field UI is installed; configurable fields then attach to its bundle. Base fields always exist regardless.

Does it have bundles?

No. A bundle-less entity type has one implicit bundle named after the entity type ID (collection_object). To get several bundles (for example painting and tool) add a configuration entity type for the bundle and set bundle_entity_type on the content entity and bundle_of on the bundle; that is how node_type works for node. Starting without bundles is fine, because moving later is a large refactor, so choose deliberately.

Is it revisionable?

Yes. Curators need to see and revert earlier descriptions. This adds a revision table and revision_id key.

Is it translatable?

Yes (English and Spanish). This adds a data table and a langcode key; see Multilingual.

Is it publishable?

Yes. A status field lets curators prepare an object before visitors can see it, and Content Moderation can drive it; see Content moderation, workflows and revisions.

Does it have an owner?

Yes. The uid field records the curator who created the record, which permits "edit own" style rules.

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

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 generate again 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"

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;

}

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;
  }

}

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

id

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 (collection_object_presave). It is limited to 32 characters and cannot be changed after installation without a migration.

label, label_collection, label_singular, label_plural, label_count

Human-readable names used by the UI, by Views, by messages and by the permission screens. label_count supplies plural-aware phrases for counts.

entity_keys

Maps the roles that core understands to field names. id is mandatory; uuid should be present; label makes $entity->label() and listings work; langcode is needed for translations; owner enables EntityOwnerTrait; published enables EntityPublishedTrait; revision names the revision ID field of a revisionable type. Core also fills in the keys default_langcode and revision_translation_affected for a translatable, revisionable type; do not map them by hand. The revision key cannot be the same as an existing field name.

handlers

The classes that implement each concern; see Handlers.

links

URL templates, keyed by link template name (canonical, collection, add-form, edit-form, delete-form, version-history, revision, revision-revert-form, revision-delete-form). The route provider turns each of them into a route, and $entity->toUrl('edit-form') builds the URL. The placeholder is the entity type ID.

base_table, data_table, revision_table, revision_data_table

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.

translatable

Turns on the translation system for the entity type. The module content_translation still has to be enabled and the bundle enabled for translation in Configuration > Regional and language > Content language and translation.

show_revision_ui

Shows the revision information and the "Create new revision" control on the entity edit form, for users with the revision permissions.

revision_metadata_keys

Maps the three revision metadata roles (user, created time and log message) to the fields that RevisionLogEntityTrait defines.

admin_permission

The permission that grants every operation. Core’s default access handler and route provider read it. A collection_permission can be given separately for the listing.

field_ui_base_route

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.

bundle_entity_type, bundle_of

Link a content entity to a configuration entity that defines its bundles.

internal

When TRUE, JSON:API and other generic APIs do not expose the entity type. See Security.

list_cache_tags, list_cache_contexts

The cache tag and contexts attached to every listing of the entity type. By default the tag is the entity type ID with a _list suffix, so saving any collection object invalidates collection_object_list, which the blocks and listings of the other Meridian pages use.

static_cache, render_cache, persistent_cache

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 call setTranslatable(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() and addConstraint() become validation rules. The UniqueField constraint 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. Without setDisplayConfigurable(), 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 gallery taxonomy term, for instance) is an entity_reference base field with target_type and 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

storage

SqlContentEntityStorage

Loads, saves and deletes entities and creates the tables. Replace it with a subclass to add query helpers.

storage_schema

SqlContentEntityStorageSchema

Builds the table schema from the field definitions. Subclass it to add database indexes.

view_builder

EntityViewBuilder

Builds the render array of an entity for a view mode and applies the view display.

list_builder

none

Builds the administrative listing table with its operation links.

form

default, add, edit, delete …​

Maps form operations to classes. The route provider picks add, edit and delete, and falls back to default.

access

EntityAccessControlHandler

Decides whether an account may view, update or delete an entity, or create a new one.

route_provider

none

Generates the routes from the links.

views_data

none

Describes the tables to Views, so that the entity can be listed, filtered and related in a view.

translation

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;

}

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);
  }

}

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;
  }

}

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));
  }

}

A few points make access handlers correct rather than merely working.

  • The result is an AccessResult, not a boolean. allowed, forbidden and neutral combine with orIf() and andIf(): "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 the user.permissions cache 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_access and _entity_create_access route 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 through hook_entity_access() and hook_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_access records) exist only for nodes. A custom entity relies on the access handler for single-entity checks and on accessCheck(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

entity.collection_object.canonical

/collection/{collection_object}

Renders the entity with the full view mode through the view builder. Requires view access.

entity.collection_object.collection

/admin/content/collection

The list builder. Requires the admin permission (or collection_permission when it is set).

entity.collection_object.add_form

/admin/content/collection/add

The add or default form, with an _entity_create_access requirement.

entity.collection_object.edit_form

/admin/content/collection/{collection_object}/edit

The edit form, with an _entity_access requirement for update.

entity.collection_object.delete_form

/admin/content/collection/{collection_object}/delete

The confirmation form, with an _entity_access requirement for delete.

entity.collection_object.version_history

/admin/content/collection/{collection_object}/revisions

The list of revisions (from 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;
  }

}

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:

flowchart TD E["Entity: one collection object"] --> B["collection_object: id, revision_id, uuid, langcode"] E --> DT["collection_object_field_data: one row for each translation, current values"] E --> RV["collection_object_revision: one row for each revision, revision metadata"] E --> RD["collection_object_field_revision: one row for each revision and translation"] B -->|"id"| DT B -->|"revision_id"| RV RV -->|"revision_id"| RD

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.

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 (/admin/content/collection)

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 show_revision_ui is on), the published checkbox and the translation tabs when Content Translation is enabled.

Structure > Collection object (/admin/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 views_data handler.

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’s getLatestRevisionId() carefully. Revert and delete of revisions use the routes of RevisionHtmlRouteProvider. The deeper treatment is in Content moderation, workflows and revisions.

  • Publishing. status is the published flag that EditorialContentEntityBase provides. Queries that must return only public objects should either use accessCheck(TRUE) with an access handler that hides unpublished ones or condition status = 1 explicitly.

  • Translations. With translatable: TRUE, the langcode key and the data tables, the entity becomes available to Content Translation: enable it for collection_object in the language settings. The original language is the language of the first save; translations store only the translatable fields, and the default_langcode and revision_translation_affected fields 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());
  }

}

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

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 objects only to trusted roles: the admin_permission bypasses 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 use accessCheck(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: TRUE is 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 the uid and revision fields before enabling the module on production.

  • Escape output. Twig autoescapes the values printed in the template above; an entity label printed through |raw or #markup with 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 hook_update_N() with the entity definition update manager. Never edit the stored definitions by hand.

"The 'collection_object' entity type does not exist" or a missing table in a kernel test

The test did not call installEntitySchema('collection_object'), or the entity type is not installed on the site yet.

The Manage fields tab is missing

field_ui_base_route is not set, names a route that does not exist, or the field_ui module is not enabled.

Edit and delete pages return 404 or "Page not found"

Routes are cached: run drush cache:rebuild. Also check that the link template contains the entity type ID as the placeholder, for example {collection_object}.

The revisions tab shows "Access denied", although the user may edit

The revision routes ask the access handler for the operations view all revisions, view revision, revert revision and delete revision. A handler that only knows view, update and delete denies them.

Reverting a revision does not restore a field

The field is not setRevisionable(TRUE), so it has one value shared by all revisions.

Translations overwrite the accession number

An identifier was left translatable. Use setTranslatable(FALSE) for fields that must be the same in every language.

A fatal error says the entity key revision conflicts with a field

The revision key must be a new name (revision_id) that is not an existing field name.

A listing shows objects that the user cannot open, or none at all

The list builder query has no, or the wrong, accessCheck(), or the access handler returns neutral for unpublished objects.

The status field is missing from the form

The published entity key is not set, or the class does not extend EditorialContentEntityBase.

Forms and listings call \Drupal::service() in the constructor

Handlers receive their services through createInstance() or create(); fetching the container statically makes them untestable.

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 @ContentEntityType in 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 .module functions. 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 entup or entity:updates no longer work.

  • Route providers and revision UI. The generic revision UI (RevisionHtmlRouteProvider, the version-history, revision, revision-revert-form and revision-delete-form links, and the RevisionRevertForm and RevisionDeleteForm classes) 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 and createInstance() 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_callback argument 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.