Last lesson we defined what a Contact entity is. But a definition alone doesn't give anyone a button to click. Someone visiting your site needs an actual "Add contact" page, a list of existing contacts, an edit form, and a way to delete one — and every one of those is a route, exactly like the routing you learned back in Module Basics, just with a few entity-specific superpowers layered on top.
What you'll learn in this lesson
- The route-naming convention Drupal's entity system relies on to auto-wire everything together
- The three special route "defaults" that hand a route off to entity-aware rendering instead of a plain controller
- Three different ways to protect a route, and when to use each one
- How a tiny companion YAML file turns a route into a clickable "Add contact" button
The source file
Path: modules/content_entity_example/content_entity_example.routing.yml
# This file brings everything together. Very nifty!
# Route names can be used in several places, for example links, redirects, and
# local actions.
entity.content_entity_example_contact.canonical:
path: '/content_entity_example_contact/{content_entity_example_contact}'
defaults:
# Calls the view controller, defined in the annotation of the contact
# entity. This marks this route as belonging to this entity type.
_entity_view: 'content_entity_example_contact'
_title: 'Contact content'
requirements:
# Calls the access controller of the entity, passing in the suffix ('view')
# as the $operation parameter to checkAccess().
_entity_access: 'content_entity_example_contact.view'
entity.content_entity_example_contact.collection:
path: '/content_entity_example_contact/list'
defaults:
# Calls the list controller, defined in the annotation of the contact entity.
_entity_list: 'content_entity_example_contact'
_title: 'Contact list'
requirements:
# Checks for permission directly.
_permission: 'view contact entity'
content_entity_example.contact_add:
path: '/content_entity_example_contact/add'
defaults:
# Calls the form.add controller, defined in the contact entity.
_entity_form: content_entity_example_contact.default
_title: 'Add contact'
requirements:
# Use the entity's access controller. _entity_create_access tells the router
# to use the access controller's checkCreateAccess() method instead of
# checkAccess().
_entity_create_access: 'content_entity_example_contact'
entity.content_entity_example_contact.edit_form:
path: '/content_entity_example_contact/{content_entity_example_contact}/edit'
defaults:
# Calls the form.edit controller, defined in the contact entity.
_entity_form: content_entity_example_contact.default
_title: 'Edit contact'
requirements:
# Calls the access controller of the entity, passing in the suffix
# ('update') as the $operation parameter to checkAccess().
_entity_access: 'content_entity_example_contact.update'
entity.content_entity_example_contact.delete_form:
path: '/contact/{content_entity_example_contact}/delete'
defaults:
# Calls the form.delete controller, defined in the contact entity.
_entity_form: content_entity_example_contact.delete
_title: 'Delete contact'
requirements:
# Calls the access controller of the entity, passing in the suffix
# ('delete') as the $operation parameter to checkAccess().
_entity_access: 'content_entity_example_contact.delete'
content_entity_example.contact_settings:
path: '/admin/structure/content_entity_example_contact_settings'
defaults:
_form: '\Drupal\content_entity_example\Form\ContactSettingsForm'
_title: 'Contact settings'
requirements:
_permission: 'administer contact entity'
Six routes. Together they cover every operation you'd expect: view one, list all, add, edit, delete, and configure module-wide settings — the classic CRUD set, plus one.
How it works
Route naming conventions
Notice most of these route names start with entity.content_entity_example_contact. — that's not a style choice, it's a contract. Routes named following the pattern entity.{entity_type_id}.{link_template} are automatically recognized by Drupal's entity link-template system, and their names must exactly match the links keys you saw in last lesson's @ContentEntityType annotation (canonical, edit-form, delete-form, collection). This matching is what lets code elsewhere in Drupal call $entity->toUrl('edit-form') and get the right URL back, without that code needing to know anything about this specific entity type.
The one exception is the add route, content_entity_example.contact_add — it deliberately breaks the entity.* pattern, because there's no existing entity instance yet for the route to represent.
Path parameters and "upcasting"
path: '/content_entity_example_contact/{content_entity_example_contact}'
The curly-brace token in the path isn't just a placeholder for display — Drupal's parameter conversion system automatically upcasts it: it takes the raw numeric ID from the URL, loads the matching entity from the database, and hands your controller or form the fully loaded entity object, not the bare integer. You never have to manually call Contact::load($id) in an entity route.
Also notice the delete route uses a shorter path (/contact/{...}/delete) instead of the full /content_entity_example_contact/... prefix the others use. Route names follow a strict pattern; the actual URL path string is entirely up to you.
The defaults section — handing off to entity-aware handling
This is where the real magic happens. Instead of writing a controller method yourself, these routes delegate straight to the entity system using three special keys:
_entity_view: 'content_entity_example_contact'— render this entity using theview_builderhandler from the annotation. Used on the canonical (single-entity view) route._entity_list: 'content_entity_example_contact'— invoke thelist_builderhandler from the annotation. Used on the collection route._entity_form: content_entity_example_contact.default— invoke a specific form handler registered on the entity type, in the format{entity_type_id}.{form_mode}. Both the add and edit routes reuse the exact same.defaultform handler — one form class does double duty for both operations. The delete route uses.delete, which points at a separate confirmation-style form.
Compare that to _form on the settings route, which points directly at a fully-qualified PHP class name — \Drupal\content_entity_example\Form\ContactSettingsForm. That's the plain, non-entity-aware mechanism you already know from earlier Form API lessons, used here because the settings page isn't about any one Contact instance.
The requirements section — three ways to lock a route down
Three distinct access-check strategies appear across these six routes:
_entity_access: 'content_entity_example_contact.view'— delegates to the entity's own access controller (the class you saw registered underhandlers.accesslast lesson). Drupal calls itscheckAccess($entity, $operation, $account)method, passing whatever comes after the dot —view,update, ordelete— as the operation. Use this whenever the route acts on one specific, already-existing entity._entity_create_access: 'content_entity_example_contact'— a special case for the add route: there's no entity yet to check access on, so this callscheckCreateAccess()instead._permission: 'view contact entity'— the simplest option: just check whether the current user holds a named permission, with no per-entity logic at all. Used for the collection listing and the settings form, where access doesn't depend on which specific entity is involved.
_entity_access on the add route. Since no entity instance exists yet, Drupal has nothing to pass to checkAccess() — that's precisely why the add route needs _entity_create_access instead. If your custom entity's "Add" page throws an unexpected access-denied error, this mismatch is one of the first things worth checking.Turning a route into a clickable button: links.action.yml
A route by itself is just a URL — nobody sees a button for it unless something links to it. This module's content_entity_example.links.action.yml file does exactly that:
content_entity_example.contact_add:
route_name: content_entity_example.contact_add
title: 'Add contact'
appears_on:
- entity.content_entity_example_contact.collection
- entity.content_entity_example_contact.canonical
This is what produces the "Add contact" action button you'll see in the screenshot below. route_name says where the button links to, title is its visible label, and appears_on is a list of route names whose pages should display this button — here, both the list page and an individual contact's view page. This keeps the "where does this button show up" decision entirely in configuration, decoupled from the routing and form logic itself.
That's the collection route, entity.content_entity_example_contact.collection, rendered via _entity_list — and the "Add contact" button above the table is the exact button that links.action.yml block just wired up, because entity.content_entity_example_contact.collection is the first route listed under appears_on.
See it for yourself
Visit /content_entity_example_contact/add on your DDEV site and fill in a new contact.
Quick check: which requirement key would you use to protect a route that lets a user delete their own Contact but not someone else's? (Answer:
_entity_access, with operationdelete— that's exactly what the delete route above uses, and it's the access controller class's job to decide "own" vs. "someone else's.")
Key takeaways
- Entity CRUD route names must follow
entity.{entity_type_id}.{link_template}(.canonical,.collection,.edit_form,.delete_form) so Drupal can resolve them automatically via$entity->toUrl()and the entity's ownlinksannotation. - Use
_entity_view,_entity_list, and_entity_formindefaultsto delegate to the handler classes registered on the entity type, instead of writing your own controller. _entity_accessdelegates to the access controller'scheckAccess()for routes acting on an existing entity;_entity_create_accesscallscheckCreateAccess()instead, for routes like "Add" where no entity exists yet.- Curly-brace path tokens like
{content_entity_example_contact}are automatically upcast into a fully loaded entity object — your form or controller never has to load it manually. - A
links.action.ymlfile turns a plain route into a visible action button usingroute_nameandappears_on, keeping UI wiring separate from routing and access logic. - Settings or admin routes that aren't about one specific entity instance use
_formwith a fully-qualified class name and a plain_permissioncheck, rather than any of the entity-specific mechanisms.
Coming up next
We've mentioned the access controller class twice now without actually reading it. Next lesson, we open it up and see exactly how Drupal decides — line by line — whether a given user is allowed to view, edit, or delete a specific Contact.