Defining a custom entity with annotationsfor Drupal 8 , 9 , 10 , and 11

Last updated :  

In the last lesson you loaded entities that already existed. Now you're going to build the whole factory: a brand-new content entity type, with its own database table, its own PHP class, and its own admin routes — all generated automatically from a single annotated class. Think of a content type built through the UI (like Article) as a pre-fab house: fast, flexible, but built on Drupal's shared node foundation. A custom content entity is building your own foundation from scratch, when what you're modeling genuinely isn't "a piece of content" at all — a log entry, a contact record, an inventory item.

What you'll learn in this lesson

  • The four handler classes every content entity type declares, and what each one is responsible for
  • How the @ContentEntityType annotation registers an entire entity type from one docblock
  • Why the storage table and route names both derive automatically from what you write in the annotation

The annotation: one docblock, one entity type

/**
 * @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",
 *   },
 *   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"
 *   },
 * )
 */
class Contact extends ContentEntityBase implements ContactInterface {
  // ...
}

Drupal's entity type manager scans every enabled module for classes carrying this annotation and caches what it finds — which is why a new or changed annotation needs a cache rebuild before it takes effect. Nothing in .routing.yml or hook_entity_type_build() is required just to register the type; the annotation alone is the source of truth.

id, base_table, and entity_keys

id is the machine name for the entire entity type — it's what you'll pass to ->getStorage('content_entity_example_contact') everywhere else in your code. base_table names the database table Drupal creates automatically (from your field definitions, covered in the next lesson) the moment the module installs. entity_keys tells Drupal which of your fields serve as the ID, the human-readable label, and the UUID — get this wrong and things like the admin listing's title column silently break.

The four handlers

  • view_builder — turns one entity into a render array when a page displays it. The generic EntityViewBuilder is usually enough; write your own only for unusual display logic.
  • list_builder — builds the admin table you see at the entity's collection route. Almost always a custom class extending EntityListBuilder, since the default columns are rarely what you want.
  • form — a keyed array of form classes, one per operation (default for add/edit, delete for the confirmation form). Drupal picks the right one based on the route's _entity_form suffix.
  • access — a class implementing checkAccess() and checkCreateAccess() that decides who can view, edit, delete, or create instances.

links and the entity-upcasting URL pattern

"canonical" = "/content_entity_example_contact/{content_entity_example_contact}",

Each link's path uses a placeholder matching the entity type ID, which triggers Drupal's entity upcasting: the router automatically loads the specific entity whose ID matches that URL segment and hands it straight to your controller or form, no manual lookup code required. The route names themselves must follow entity.<entity_type_id>.<link_key> exactly (e.g. entity.content_entity_example_contact.canonical) — that's the contract that lets $entity->toUrl('edit-form') resolve correctly anywhere in your code without hardcoding a path.

Content entity vs. config entity — pick correctly up front. If instances are things end users will create constantly and in volume (contacts, orders, log entries), it's a content entity like this one. If instances are things a site builder defines once and rarely touches (a "Robot" type used purely for admin configuration), it's a config entity instead — a different base class and a very different storage mechanism (YAML config, not a database table).

Quick check: you rename the PHP class implementing this annotation but leave id = "content_entity_example_contact" untouched. Does anything break? No — the entity type's identity lives entirely in the annotation's id key, not the class name. You can rename the class freely as long as the namespace path still matches PSR-4 autoloading.

Key takeaways

  • A single @ContentEntityType annotation registers an entire entity type — no routing, no hooks, just the docblock plus a cache rebuild.
  • Every content entity needs four handlers: view_builder, list_builder, form (an array keyed by operation), and access.
  • entity_keys tells Drupal which fields are the ID, label, and UUID — required for admin listings and generic APIs to work correctly.
  • Link paths using {entity_type_id} as a placeholder get automatic entity upcasting; route names must follow entity.<type>.<link> exactly.
  • Choose content entities for high-volume, end-user-created data; choose config entities for site-builder-defined, rarely-changed records.

Coming up next

The annotation above references four handler classes without explaining any of them in depth. Next, in Defining Entity Handlers, we open each one up and see exactly what it's responsible for.