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
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 coreEntityViewBuilderis used here.list_builder— renders the table of all Contact entities (you'll meet this in a later lesson). Here it's a customContactListBuilder.form— a small map from operation to form class:defaulthandles both adding and editing,deletehandles 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 loadedUserobject.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:
- The database column's type and constraints (used to generate the SQL schema on install)
- Which widget appears on the add/edit form
- How the value is formatted when the entity is viewed
A few fields worth calling out specifically:
name/first_name— plainstringfields, capped at 255 characters, withtext_processing => 0meaning plain text only (no HTML filtering).setDisplayConfigurable(TRUE)on bothformandviewis 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— anentity_referencefield pointing at the coreuserentity type. Theentity_reference_autocompletewidget is the same type-ahead search box you've used elsewhere in Drupal admin forms.role— alist_stringfield with a fixed, hardcoded set of allowed values, rendered with theoptions_selectwidget — a plain HTML dropdown.created/changed— Drupal manages these two fields' values entirely automatically. Combined with theEntityChangedTraitused 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.
Quick check: if you wanted the Contact entity's label to show the first name instead of the
namefield, which single annotation key would you change? (Answer:entity_keys— specifically itslabelvalue.)
Key takeaways
- The
@ContentEntityTypeannotation 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
handlersmap 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_keysarray 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
linksarray combined with correctly named routes (followingentity.<entity_type>.<link_name>) is what lets Drupal auto-generate URLs and action buttons for your entity. - Implementing
EntityOwnerInterfaceand usingEntityChangedTraitgives 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.