Define a custom entity type
When your module stores its own kind of data, define an entity type instead of a bare database table: you inherit storage, CRUD hooks, forms, routes, access control, Views support, and an admin UI from declarations rather than code. This page defines a complete content entity type with the #[ContentEntityType] attribute.
A new content entity type with its own table, add/edit/delete forms, and admin listing, all generated from one attribute and one field method. Two checks before you commit to this: if editors should compose the data out of configurable fields per bundle, a node type or other existing entity type is less work; and if the data is settings that deploy with configuration (one per environment, not thousands of rows), you want a config entity instead (see Variations).
Prerequisites
Section titled “Prerequisites”- Entities, bundles, and fields for the model: entity types, handlers, base fields.
- Create, load, and query entities for what your new type’s API will look like to its consumers.
- A custom module installed on a Drupal 11 site.
-
Define the entity class in
src/Entity/, extending\Drupal\Core\Entity\ContentEntityBase, with the\Drupal\Core\Entity\Attribute\ContentEntityTypeattribute as the definition:web/modules/custom/docs_snippets/modules/module_howtos_a/src/Entity/GuestbookEntry.php /*** A message a visitor left in the guestbook.** The attribute is the entire entity type definition: Drupal reads it at* discovery time and creates the database table, routes, forms, and admin* listing from what is declared here.*/#[ContentEntityType(id: 'guestbook_entry',label: new TranslatableMarkup('Guestbook entry'),label_collection: new TranslatableMarkup('Guestbook entries'),label_singular: new TranslatableMarkup('guestbook entry'),label_plural: new TranslatableMarkup('guestbook entries'),entity_keys: ['id' => 'id','uuid' => 'uuid','label' => 'name',],handlers: ['view_builder' => EntityViewBuilder::class,'list_builder' => EntityListBuilder::class,'form' => ['default' => ContentEntityForm::class,'delete' => ContentEntityDeleteForm::class,],'route_provider' => ['html' => AdminHtmlRouteProvider::class,],],links: ['canonical' => '/admin/content/guestbook/{guestbook_entry}','add-form' => '/admin/content/guestbook/add','edit-form' => '/admin/content/guestbook/{guestbook_entry}/edit','delete-form' => '/admin/content/guestbook/{guestbook_entry}/delete','collection' => '/admin/content/guestbook',],admin_permission: 'administer guestbook entries',base_table: 'guestbook_entry',)]class GuestbookEntry extends ContentEntityBase {The attribute’s sections each delegate one concern:
entity_keystells the entity system which of your fields play the standard roles:id,uuid, and which field is the human-readablelabel.handlersare the pluggable objects that operate on entities of this type. The core defaults used here give you a working admin listing (\Drupal\Core\Entity\EntityListBuilder), add/edit and delete forms (\Drupal\Core\Entity\ContentEntityForm,\Drupal\Core\Entity\ContentEntityDeleteForm), rendering (\Drupal\Core\Entity\EntityViewBuilder), and generated routes (\Drupal\Core\Entity\Routing\AdminHtmlRouteProvider). Replace any of them with a subclass when the default stops being enough; that is the extension model.linksdeclare the URL for each generated route. The route provider turns each link template into a real route (namedentity.guestbook_entry.canonicaland so on), with the{guestbook_entry}parameter upcast to a loaded entity.admin_permissionplus the default access handler means: this permission grants everything. Fine for admin-managed data; for per-operation rules, add anaccesshandler subclassing\Drupal\Core\Entity\EntityAccessControlHandler.base_tablenames the table Drupal creates and manages for you.
-
Declare the fields every entity of this type has by overriding
baseFieldDefinitions():web/modules/custom/docs_snippets/modules/module_howtos_a/src/Entity/GuestbookEntry.php /*** {@inheritdoc}*/public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {// The parent defines the fields behind entity_keys: id and uuid here.$fields = parent::baseFieldDefinitions($entity_type);$fields['name'] = BaseFieldDefinition::create('string')->setLabel(new TranslatableMarkup('Name'))->setRequired(TRUE)->setSetting('max_length', 100)->setDisplayOptions('form', ['type' => 'string_textfield','weight' => 0,])->setDisplayOptions('view', ['label' => 'inline','weight' => 0,]);$fields['message'] = BaseFieldDefinition::create('string_long')->setLabel(new TranslatableMarkup('Message'))->setRequired(TRUE)->setDisplayOptions('form', ['type' => 'string_textarea','weight' => 1,])->setDisplayOptions('view', ['label' => 'hidden','weight' => 1,]);// The 'created' field type fills itself in at creation time.$fields['created'] = BaseFieldDefinition::create('created')->setLabel(new TranslatableMarkup('Submitted on'));return $fields;}Each
\Drupal\Core\Field\BaseFieldDefinitionis a field type plus settings, exactly like fields configured in the UI, only defined in code. ThesetDisplayOptions()calls are what make the field appear on the generated form ('form') and rendered output ('view'); a field without form display options is invisible on the add form. Field types bring behavior with them:createdstamps itself at creation time, andstringenforces itsmax_lengthat the storage and validation layers. -
Define the permission the attribute references, in
module_howtos_a.permissions.yml:web/modules/custom/docs_snippets/modules/module_howtos_a/module_howtos_a.permissions.yml administer guestbook entries:title: 'Administer guestbook entries'restrict access: true -
Install the module:
ddev drush pm:install module_howtos_a. Installing a module that provides an entity type also installs the entity type: Drupal creates theguestbook_entrytable from the field definitions. There is no schema file to write.Changing base fields after install is a deployed schema change: existing sites need a
hook_update_N()function (the numbering rules are in Custom database tables and update paths) that applies it through theentity definition update manager, never through Schema API calls on the entity’s tables.installFieldStorageDefinition()adds a new base field, andupdateFieldStorageDefinition()applies a changed one:// Fragment: in module_howtos_a_update_11101(), add a new base field.$definition = BaseFieldDefinition::create('email')->setLabel(t('Email'));\Drupal::entityDefinitionUpdateManager()->installFieldStorageDefinition('email', 'guestbook_entry', 'module_howtos_a', $definition);Drupal.org’s Updating entities and fields guide covers the other cases.
Verify
Section titled “Verify”- Visit
/admin/content/guestbookas an administrator: the collection route renders an empty listing (“There are no guestbook entries yet.”). - Visit
/admin/content/guestbook/addand select Save after entering a name and message. Back on/admin/content/guestbook, the listing now has a row with Edit and Delete operations. (The stock list builder renders only the operations column; subclassEntityListBuilderand overridebuildHeader()andbuildRow()to add real columns.) drush php:eval "print \Drupal::entityTypeManager()->getDefinition('guestbook_entry')->getLabel();"prints “Guestbook entry”.- Your new type works with everything from Create, load, and query entities:
\Drupal::entityQuery('guestbook_entry')queries it like any core type.
Variations
Section titled “Variations”A config entity type instead
Section titled “A config entity type instead”Use \Drupal\Core\Entity\Attribute\ConfigEntityType on a class extending \Drupal\Core\Config\Entity\ConfigEntityBase when the “entities” are administrator-defined configuration: image styles, node types, and roles are config entities. The differences follow from where the data lives:
- Storage is the config system, not a database table: no
base_table, and entries deploy through config export/import like any configuration. - Instead of
baseFieldDefinitions(), the class has plain PHP properties, and the attribute’sconfig_exportarray lists which of them persist. config_prefixnamespaces the stored objects (for examplemodule_howtos_a.support_channel.*).- IDs are administrator-chosen machine names, not serial integers.
If you would have thousands of rows, or users (rather than administrators) create them, it is content, not config.
Make it revisionable or translatable
Section titled “Make it revisionable or translatable”Declare revision_table plus a revision entity key to version your data, and translatable: TRUE with a data_table and langcode key for multilingual values; mark individual fields with setRevisionable(TRUE) and setTranslatable(TRUE). Core’s path_alias type is a compact revisionable example (core/modules/path_alias/src/Entity/PathAlias.php), and block_content shows revisionable and translatable together (core/modules/block_content/src/Entity/BlockContent.php).
Common field traits
Section titled “Common field traits”Recurring field bundles ship as traits with matching helper methods: \Drupal\Core\Entity\EntityChangedTrait (a changed timestamp), \Drupal\Core\Entity\EntityPublishedTrait (a published flag and entity key), and the user module’s EntityOwnerTrait (an owner/uid key). Each adds its fields inside your baseFieldDefinitions().
Let site builders add fields
Section titled “Let site builders add fields”Set field_ui_base_route in the attribute (pointing at a route, usually your settings form’s) and install the Field UI module to let site builders attach configurable fields to your type, exactly as they do on content types.
Related
Section titled “Related”- Entities, bundles, and fields for handlers, keys, and the entity type system itself.
- Create, load, and query entities for using the type you defined.
- Custom database tables and update paths for evolving the stored schema later.
the Entity API topicon api.drupal.org.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.