Content Entity Definition: The @ContentEntityType Annotation Explainedfor Drupal 11 , and 10

Last updated :  

Every lesson so far has used pieces Drupal already understood — a page, a block, a form. In this lesson we build something bigger: a brand new kind of content that Drupal has never seen before, with its own database table, its own admin pages, and its own permissions — all generated automatically from one PHP class. This is one of the most powerful capabilities in Drupal, and it's what content entities are for.

What's a "content entity," really?

You already know one content entity intimately: the Node (what Drupal calls a "page" or "article" under the hood). Nodes aren't special-cased into Drupal's core in some magical way — they're built using the exact same content entity system you're about to learn. So is the User entity, the Taxonomy Term entity, and the Comment entity. Once you understand how one content entity type is defined, you understand how all of them work — including ones you invent yourself.

In this lesson we'll study a real one: the Contact entity from the content_entity_example module. It's deliberately simple — just a name, a first name, an owning user, and a role — so the entity mechanism stands out clearly, without getting lost in a complicated real-world data model.

What you'll learn in this lesson

  • What a PHP annotation is, and how Drupal uses one (@ContentEntityType) to register an entirely new entity type
  • How that single annotation drives routing, forms, permissions, and the database schema — all at once
  • How baseFieldDefinitions() defines both storage and the admin form/display in one method
  • Where to see your new entity type actually working in the Drupal admin UI
Heads up: this is a longer, denser lesson than most so far — content entities touch almost every system you've learned about in this course (routing, forms, access control, Field API) at once. Don't worry about memorizing every annotation key on a first read; treat this lesson as a reference you'll come back to.

The source file

Path: modules/content_entity_example/src/Entity/Contact.php

<?php

namespace Drupal\content_entity_example\Entity;

use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\content_entity_example\ContactInterface;
use Drupal\user\UserInterface;
use Drupal\Core\Entity\EntityChangedTrait;

/**
 * Defines the ContentEntityExample entity.
 *
 * @ingroup content_entity_example
 *
 * This is the main definition of the entity type. From it, an EntityType object
 * is derived. The most important properties in this example are listed below.
 *
 * id: The unique identifier of this entity type. It follows the pattern
 * 'moduleName_xyz' to avoid naming conflicts.
 *
 * label: Human readable name of the entity type.
 *
 * handlers: Handler classes are used for different tasks. You can use
 * standard handlers provided by Drupal or build your own, most probably derived
 * from the ones provided by Drupal. In detail:
 *
 * - view_builder: we use the standard controller to view an instance. It is
 *   called when a route lists an '_entity_view' default for the entity type.
 *   You can see this in the entity.content_entity_example_contact.canonical
 *   route in the content_entity_example.routing.yml file. The view can be
 *   manipulated by using the standard Drupal tools in the settings.
 *
 * - list_builder: We derive our own list builder class from EntityListBuilder
 *   to control the presentation. If there is a view available for this entity
 *   from the views module, it overrides the list builder if the "collection"
 *   key in the links array in the Entity annotation below is changed to the
 *   path of the View. In this case the entity collection route will give the
 *   view path.
 *
 * - form: We derive our own forms to add functionality like additional fields,
 *   redirects etc. These forms are used when the route specifies an
 *   '_entity_form' or '_entity_create_access' for the entity type. Depending on
 *   the suffix (.add/.default/.delete) of the '_entity_form' default in the
 *   route, the form specified in the annotation is used. The suffix then also
 *   becomes the $operation parameter to the access handler. We use the
 *   '.default' suffix for all operations that are not 'delete'.
 *
 * - access: Our own access controller, where we determine access rights based
 *   on permissions.
 *
 * More properties:
 *
 *  - base_table: Define the name of the table used to store the data. Make sure
 *    it is unique. The schema is automatically determined from the
 *    BaseFieldDefinitions below. The table is automatically created during
 *    installation.
 *
 *  - entity_keys: How to access the fields. Specify fields from
 *    baseFieldDefinitions() which can be used as keys.
 *
 *  - links: Provide links to do standard tasks. The 'edit-form' and
 *    'delete-form' links are added to the list built by the
 *    entityListController. They will show up as action buttons in an additional
 *    column.
 *
 *  - field_ui_base_route: The route name used by Field UI to attach its
 *    management pages. Field UI module will attach its Manage Fields,
 *    Manage Display, and Manage Form Display tabs to this route.
 *
 * There are many more properties to be used in an entity type definition. For
 * a complete overview, please refer to the '\Drupal\Core\Entity\EntityType'
 * class definition.
 *
 * The following construct is the actual definition of the entity type which
 * is read and cached. Don't forget to clear cache after changes.
 *
 * @ContentEntityType(
 *   id = "content_entity_example_contact",
 *   label = @Translation("Contact entity"),
 *   handlers = {
 *     "view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
 *     "list_builder" = "Drupal\content_entity_example\Entity\Controller\ContactListBuilder",
 *     "form" = {
 *       "default" = "Drupal\content_entity_example\Form\ContactForm",
 *       "delete" = "Drupal\content_entity_example\Form\ContactDeleteForm",
 *     },
 *     "access" = "Drupal\content_entity_example\ContactAccessControlHandler",
 *   },
 *   list_cache_contexts = { "user" },
 *   base_table = "contact",
 *   admin_permission = "administer contact entity",
 *   entity_keys = {
 *     "id" = "id",
 *     "label" = "name",
 *     "uuid" = "uuid"
 *   },
 *   links = {
 *     "canonical" = "/content_entity_example_contact/{content_entity_example_contact}",
 *     "edit-form" = "/content_entity_example_contact/{content_entity_example_contact}/edit",
 *     "delete-form" = "/contact/{content_entity_example_contact}/delete",
 *     "collection" = "/content_entity_example_contact/list"
 *   },
 *   field_ui_base_route = "content_entity_example.contact_settings",
 * )
 *
 * The 'links' above are defined by their path. For core to find the
 * corresponding route, the route name must follow the correct pattern:
 *
 * entity.<entity_type>.<link_name>
 *
 * Example: 'entity.content_entity_example_contact.canonical'.
 *
 * See the routing file at content_entity_example.routing.yml for the
 * corresponding implementation.
 *
 * The Contact class defines methods and fields for the contact entity.
 *
 * Being derived from the ContentEntityBase class, we can override the methods
 * we want. In our case we want to provide access to the standard fields about
 * creation and changed time stamps.
 *
 * Our interface (see ContactInterface) also exposes the EntityOwnerInterface.
 * This allows us to provide methods for setting and providing ownership
 * information.
 *
 * The most important part is the definitions of the field properties for this
 * entity type. These are of the same type as fields added through the GUI, but
 * they can by changed in code. In the definition we can define if the user with
 * the rights privileges can influence the presentation (view, edit) of each
 * field.
 *
 * The class also uses the EntityChangedTrait trait which allows it to record
 * timestamps of save operations.
 */
class Contact extends ContentEntityBase implements ContactInterface {

  use EntityChangedTrait;

  /**
   * {@inheritdoc}
   *
   * When a new entity instance is added, set the user_id entity reference to
   * the current user as the creator of the instance.
   */
  public static function preCreate(EntityStorageInterface $storage_controller, array &$values) {
    parent::preCreate($storage_controller, $values);
    $values += [
      'user_id' => \Drupal::currentUser()->id(),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getOwner() {
    return $this->get('user_id')->entity;
  }

  /**
   * {@inheritdoc}
   */
  public function getOwnerId() {
    return $this->get('user_id')->target_id;
  }

  /**
   * {@inheritdoc}
   */
  public function setOwnerId($uid) {
    $this->set('user_id', $uid);
    return $this;
  }

  /**
   * {@inheritdoc}
   */
  public function setOwner(UserInterface $account) {
    $this->set('user_id', $account->id());
    return $this;
  }

  /**
   * {@inheritdoc}
   *
   * Define the field properties here.
   *
   * Field name, type and size determine the table structure.
   *
   * In addition, we can define how the field and its content can be manipulated
   * in the GUI. The behavior of the used widgets can be determined here.
   */
  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {

    // Standard field, used as unique if primary index.
    $fields['id'] = BaseFieldDefinition::create('integer')
      ->setLabel(t('ID'))
      ->setDescription(t('The ID of the Contact entity.'))
      ->setReadOnly(TRUE);

    // Standard field, unique outside of the scope of the current project.
    $fields['uuid'] = BaseFieldDefinition::create('uuid')
      ->setLabel(t('UUID'))
      ->setDescription(t('The UUID of the Contact entity.'))
      ->setReadOnly(TRUE);

    // Name field for the contact.
    // We set display options for the view as well as the form.
    // Users with correct privileges can change the view and edit configuration.
    $fields['name'] = BaseFieldDefinition::create('string')
      ->setLabel(t('Name'))
      ->setDescription(t('The name of the Contact entity.'))
      ->setSettings([
        'max_length' => 255,
        'text_processing' => 0,
      ])
      // Set no default value.
      ->setDefaultValue(NULL)
      ->setDisplayOptions('view', [
        'label' => 'above',
        'type' => 'string',
        'weight' => -6,
      ])
      ->setDisplayOptions('form', [
        'type' => 'string_textfield',
        'weight' => -6,
      ])
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE);

    $fields['first_name'] = BaseFieldDefinition::create('string')
      ->setLabel(t('First Name'))
      ->setDescription(t('The first name of the Contact entity.'))
      ->setSettings([
        'max_length' => 255,
        'text_processing' => 0,
      ])
      // Set no default value.
      ->setDefaultValue(NULL)
      ->setDisplayOptions('view', [
        'label' => 'above',
        'type' => 'string',
        'weight' => -5,
      ])
      ->setDisplayOptions('form', [
        'type' => 'string_textfield',
        'weight' => -5,
      ])
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE);

    // Owner field of the contact.
    // Entity reference field, holds the reference to the user object.
    // The view shows the user name field of the user.
    // The form presents a auto complete field for the user name.
    $fields['user_id'] = BaseFieldDefinition::create('entity_reference')
      ->setLabel(t('User Name'))
      ->setDescription(t('The Name of the associated user.'))
      ->setSetting('target_type', 'user')
      ->setSetting('handler', 'default')
      ->setDisplayOptions('view', [
        'label' => 'above',
        'type' => 'author',
        'weight' => -3,
      ])
      ->setDisplayOptions('form', [
        'type' => 'entity_reference_autocomplete',
        'settings' => [
          'match_operator' => 'CONTAINS',
          'match_limit' => 10,
          'size' => 60,
          'placeholder' => '',
        ],
        'weight' => -3,
      ])
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE);

    // Role field for the contact.
    // The values shown in options are 'administrator' and 'user'.
    $fields['role'] = BaseFieldDefinition::create('list_string')
      ->setLabel(t('Role'))
      ->setDescription(t('The role of the Contact entity.'))
      ->setSettings([
        'allowed_values' => [
          'administrator' => 'administrator',
          'user' => 'user',
        ],
      ])
      // Set the default value of this field to 'user'.
      ->setDefaultValue('user')
      ->setDisplayOptions('view', [
        'label' => 'above',
        'type' => 'string',
        'weight' => -2,
      ])
      ->setDisplayOptions('form', [
        'type' => 'options_select',
        'weight' => -2,
      ])
      ->setDisplayConfigurable('form', TRUE)
      ->setDisplayConfigurable('view', TRUE);

    $fields['langcode'] = BaseFieldDefinition::create('language')
      ->setLabel(t('Language code'))
      ->setDescription(t('The language code of ContentEntityExample entity.'));
    $fields['created'] = BaseFieldDefinition::create('created')
      ->setLabel(t('Created'))
      ->setDescription(t('The time that the entity was created.'));

    $fields['changed'] = BaseFieldDefinition::create('changed')
      ->setLabel(t('Changed'))
      ->setDescription(t('The time that the entity was last edited.'));

    return $fields;
  }

}

How it works

The @ContentEntityType annotation

That big PHP docblock starting with @ContentEntityType( isn't just a comment — Drupal's plugin discovery system actually parses it at bootstrap time and caches the result. It's the single declaration that transforms an ordinary PHP class into a fully managed Drupal entity type, complete with its own storage, routing, and admin UI. Drupal finds it by scanning every module's src/Entity/ directory. Just like the hooks you met earlier in this course, the discovered result is cached — run drush cr after changing anything inside this annotation, or your changes won't take effect.

id — the entity type's machine name

id = "content_entity_example_contact",

The unique machine name for this entity type across your entire Drupal site. Convention: moduleName_descriptiveName, to avoid two modules accidentally choosing the same name. You'll use this exact string constantly — for example to load the entity storage: \Drupal::entityTypeManager()->getStorage('content_entity_example_contact').

label — the human-readable name

label = @Translation("Contact entity"),

Shown in admin UIs like the Views module's entity type picker. Wrapping it in @Translation() tells Drupal's string-extraction tooling this text should be translatable.

handlers — swappable behavior classes

This is where the annotation gets genuinely powerful. Instead of one giant class doing everything, responsibilities are split across small, focused handler classes:

  • view_builder — controls how a single entity renders as HTML. The default core EntityViewBuilder is used here.
  • list_builder — renders the table of all Contact entities (you'll meet this in a later lesson). Here it's a custom ContactListBuilder.
  • form — a small map from operation to form class: default handles both adding and editing, delete handles removal with a confirmation step.
  • access — a custom class Drupal consults before letting anyone view, edit, or delete a specific Contact.

This is the same "swap in your own class for one piece of behavior" pattern you saw with plugins — it lets you override exactly the part you need without touching anything else.

list_cache_contexts — who sees which cached list

list_cache_contexts = { "user" },

Tells Drupal's render cache that the entity list can differ per user (for example, an admin might see extra columns or rows a regular user can't). Without this, one user's cached list could incorrectly be served to everyone else.

base_table — where the data actually lives

base_table = "contact",

The database table name. You never write the CREATE TABLE statement yourself — Drupal derives the full schema from baseFieldDefinitions() (below) and creates the table automatically the moment the module is installed.

admin_permission — the master-key permission

admin_permission = "administer contact entity",

A permission string (declared in the module's .permissions.yml file) that grants blanket access to this entity type, bypassing the finer-grained access checks in the access handler.

entity_keys — naming the fields Drupal needs to know about

entity_keys = {
  "id" = "id",
  "label" = "name",
  "uuid" = "uuid"
},

Drupal's entity API needs to know which of your fields (defined below in baseFieldDefinitions()) play certain special roles. Here, label is mapped to the name field — meaning whenever you call $contact->label() anywhere in code, you get back the value of the name field. This is exactly the kind of indirection that lets generic Drupal code (like the admin toolbar or search indexing) work with any entity type without knowing its specific field names in advance.

links — the URLs Drupal will generate for you

links = {
  "canonical" = "/content_entity_example_contact/{content_entity_example_contact}",
  "edit-form" = "/content_entity_example_contact/{content_entity_example_contact}/edit",
  "delete-form" = "/contact/{content_entity_example_contact}/delete",
  "collection" = "/content_entity_example_contact/list"
},

Each entry pairs a link "type" with a URL pattern. The {content_entity_example_contact} placeholder is automatically converted (Drupal calls this "upcasting") from a raw ID in the URL into a fully loaded Contact object your controller can use directly. For this to work, a matching route must exist in content_entity_example.routing.yml named exactly entity.content_entity_example_contact.canonical (following the pattern entity.<entity_type>.<link_name>) — you'll see this route in the next lesson.

field_ui_base_route — plugging into the Field UI module

field_ui_base_route = "content_entity_example.contact_settings",

If the core Field UI module is enabled, this tells it which route to attach the familiar "Manage Fields" / "Manage Display" / "Manage Form Display" tabs to — the same tabs you'd see when adding a field to an Article. Without this key, those tabs simply never appear for your entity type.

The class itself: ContentEntityBase and ownership

class Contact extends ContentEntityBase implements ContactInterface {

All the heavy lifting — loading, saving, field storage, revisions — comes free from extending ContentEntityBase. The ContactInterface this class implements also extends Drupal's built-in EntityOwnerInterface, which is a contract requiring four specific methods:

  • getOwner() — returns the full loaded User object.
  • getOwnerId() — returns just the numeric user ID.
  • setOwner(UserInterface $account) / setOwnerId($uid) — set the owner from a user object or a raw ID.

Implementing this well-known interface is what lets Contact entities plug into any Drupal code that already knows how to work with "ownable" content — for instance, "content authored by me" listings work automatically.

preCreate() — sensible defaults on new entities

public static function preCreate(EntityStorageInterface $storage_controller, array &$values) {
  parent::preCreate($storage_controller, $values);
  $values += [
    'user_id' => \Drupal::currentUser()->id(),
  ];
}

A lifecycle hook Drupal calls right before a brand-new entity is first saved. The += array operator only fills in user_id if nothing already set it, which is a neat trick for providing a default without accidentally overwriting a value someone deliberately passed in. The result: every new Contact is automatically owned by whoever created it, with zero extra code required at the call site.

baseFieldDefinitions() — one method, three jobs at once

This is the method that does the most work in the whole file. For every field it defines, it simultaneously controls:

  1. The database column's type and constraints (used to generate the SQL schema on install)
  2. Which widget appears on the add/edit form
  3. How the value is formatted when the entity is viewed

A few fields worth calling out specifically:

  • name / first_name — plain string fields, capped at 255 characters, with text_processing => 0 meaning plain text only (no HTML filtering). setDisplayConfigurable(TRUE) on both form and view is what lets a site administrator later rearrange or restyle these fields through the UI, exactly as if they were fields added by hand.
  • user_id — an entity_reference field pointing at the core user entity type. The entity_reference_autocomplete widget is the same type-ahead search box you've used elsewhere in Drupal admin forms.
  • role — a list_string field with a fixed, hardcoded set of allowed values, rendered with the options_select widget — a plain HTML dropdown.
  • created / changed — Drupal manages these two fields' values entirely automatically. Combined with the EntityChangedTrait used near the top of the class, you get full "created at / last edited at" tracking with no extra code.

See it for yourself

Visit /content_entity_example_contact/list on your DDEV site to see Contact entities that already exist, managed entirely by the class you just read.

The Contact entity list page showing Contact entities managed by the entity type defined in this lesson

Quick check: if you wanted the Contact entity's label to show the first name instead of the name field, which single annotation key would you change? (Answer: entity_keys — specifically its label value.)

Key takeaways

  • The @ContentEntityType annotation is the single declaration that registers your entity type with Drupal — every key maps to a concrete behavior (routing, storage, access control, UI integration), and a cache rebuild is required after any change.
  • The handlers map lets you swap or extend individual responsibilities (viewing, listing, forms, access) independently, instead of one monolithic class doing everything.
  • baseFieldDefinitions() controls the database schema and the default form/display configuration in one place; setDisplayConfigurable(TRUE) lets administrators override those defaults later without touching code.
  • The entity_keys array creates semantic aliases (id, label, uuid) that generic Drupal code — Views, search, the admin toolbar — relies on to work with any entity type without hardcoding field names.
  • The links array combined with correctly named routes (following entity.<entity_type>.<link_name>) is what lets Drupal auto-generate URLs and action buttons for your entity.
  • Implementing EntityOwnerInterface and using EntityChangedTrait gives you ownership and timestamp tracking for free — both are well-known contracts the rest of Drupal already knows how to work with.

Coming up next

We've defined the entity type — but a definition alone doesn't give visitors a way to add, edit, or delete Contacts through the browser. In the next lesson we'll look at the routing and form classes that turn this annotation into real, clickable "Add contact" and "Edit contact" pages.