Skip to content

How-to guideFor module developersDrupal 11

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).

  1. Define the entity class in src/Entity/, extending \Drupal\Core\Entity\ContentEntityBase, with the \Drupal\Core\Entity\Attribute\ContentEntityType attribute 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:

  2. 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\BaseFieldDefinition is a field type plus settings, exactly like fields configured in the UI, only defined in code. The setDisplayOptions() 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: created stamps itself at creation time, and string enforces its max_length at the storage and validation layers.

  3. 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
  4. 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 the guestbook_entry table 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 the entity definition update manager, never through Schema API calls on the entity’s tables. installFieldStorageDefinition() adds a new base field, and updateFieldStorageDefinition() 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.

  • Visit /admin/content/guestbook as an administrator: the collection route renders an empty listing (“There are no guestbook entries yet.”).
  • Visit /admin/content/guestbook/add and 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; subclass EntityListBuilder and override buildHeader() and buildRow() 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.

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’s config_export array lists which of them persist.
  • config_prefix namespaces the stored objects (for example module_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.

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).

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().

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.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.