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
@ContentEntityTypeannotation 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
EntityViewBuilderis 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 (
defaultfor add/edit,deletefor the confirmation form). Drupal picks the right one based on the route's_entity_formsuffix. - access — a class implementing
checkAccess()andcheckCreateAccess()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.
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'sidkey, 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
@ContentEntityTypeannotation 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_keystells 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 followentity.<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.