The last lesson listed four handler classes inside the @ContentEntityType annotation without opening any of them up. Think of handlers as the entity type's specialist staff: the view builder is the person who dresses a mannequin for the shop window, the list builder runs the inventory spreadsheet, the form handler staffs the checkout counter, and the access handler is security checking IDs at the door. Each has exactly one job, and Drupal calls the right one automatically depending on what the current request needs.
What you'll learn in this lesson
- What each of the four standard handler types is responsible for
- When to accept Drupal's generic handler and when you need your own
- How to write a custom list builder and a custom access handler, with real code
view_builder — turning one entity into a render array
"view_builder" = "Drupal\Core\Entity\EntityViewBuilder",
Fires whenever a route renders a single entity (the canonical page, a Views row, a block). The generic EntityViewBuilder loops over your field definitions and builds a standard render array automatically — you only need a custom view builder for genuinely unusual display logic that field formatters can't express.
list_builder — the admin table
class ContactListBuilder extends EntityListBuilder {
public function buildHeader() {
$header['id'] = $this->t('ContactID');
$header['name'] = $this->t('Name');
$header['first_name'] = $this->t('First Name');
$header['role'] = $this->t('Role');
return $header + parent::buildHeader();
}
public function buildRow(EntityInterface $entity) {
$row['id'] = $entity->id();
$row['name'] = $entity->toLink()->toString();
$row['first_name'] = $entity->first_name->value;
$row['role'] = $entity->role->value;
return $row + parent::buildRow($entity);
}
}
This is almost always a custom class, since the default columns are rarely what you want. buildHeader() and buildRow() return associative arrays of column-key to content; calling parent::buildHeader()/parent::buildRow() and merging the result in with + adds Drupal's own "Edit"/"Delete" operations column automatically, generated straight from the links your entity annotation declared.
form — one class per operation
"form" = {
"default" = "Drupal\content_entity_example\Form\ContactForm",
"delete" = "Drupal\content_entity_example\Form\ContactDeleteForm",
},
The form handler is itself a keyed array, not a single class — Drupal picks the right entry based on the route's _entity_form default (e.g. content_entity_example_contact.default for add/edit, content_entity_example_contact.delete for the confirmation step). The suffix after the dot also becomes the $operation string your access handler receives, which is exactly how "edit" and "delete" end up being checked separately.
access — who's allowed to do what
class ContactAccessControlHandler extends EntityAccessControlHandler {
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
$admin_permission = $this->entityType->getAdminPermission();
if ($account->hasPermission($admin_permission)) {
return AccessResult::allowed();
}
switch ($operation) {
case 'view':
return AccessResult::allowedIfHasPermission($account, 'view contact entity');
case 'update':
return AccessResult::allowedIfHasPermission($account, 'edit contact entity');
case 'delete':
return AccessResult::allowedIfHasPermission($account, 'delete contact entity');
}
return AccessResult::neutral();
}
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
$admin_permission = $this->entityType->getAdminPermission();
if ($account->hasPermission($admin_permission)) {
return AccessResult::allowed();
}
return AccessResult::allowedIfHasPermission($account, 'add contact entity');
}
}
checkAccess() handles view/update/delete on an entity that already exists; checkCreateAccess() is deliberately separate because at that point there's no entity instance yet to check permissions against — only a bundle and a user. Both check the annotation's admin_permission as a universal bypass first, then fall through to granular, operation-specific permissions.
AccessResult::neutral() matters. Returning neutral() rather than forbidden() for an unrecognized operation lets other modules' access hooks still have a say — forbidden() is final and can't be overridden by anything else in the access-check chain, while neutral() simply means "I have no opinion," deferring the decision.Quick check: a site administrator holds the
administer contact entitypermission but notdelete contact entity. Can they delete a contact? Yes — the admin permission check at the top ofcheckAccess()short-circuits toallowed()before the switch statement ever looks at the specificdelete contact entitypermission.
Key takeaways
view_builderrenders one entity; the generic class is usually sufficient.list_builderbuilds the admin table — almost always custom, viabuildHeader()/buildRow()merged with the parent's operations column.formis a keyed array of form classes, one per_entity_formroute suffix, and that suffix becomes the operation your access handler sees.accessimplementscheckAccess()(existing entities) andcheckCreateAccess()(new ones) separately, since a not-yet-created entity has no instance to check against.- Return
AccessResult::neutral(), notforbidden(), for anything your handler doesn't have a strong opinion about — it keeps the door open for other modules to grant access.
Coming up next
The list builder above referenced fields like first_name and role as if they already existed. Next, in Creating Entity Fields, we define exactly where those come from: baseFieldDefinitions().