Drupal Entity Access Control: Building an EntityAccessControlHandlerfor Drupal 11 , and 10

Last updated :  

You've now built a Contact entity type (Lesson 31) and wired up its add/edit/delete routes (Lesson 32). But right now, anyone who can reach those routes can use them — there's no gatekeeper deciding who is actually allowed to view, edit, or delete a given contact. That gatekeeper is what this lesson builds: an access control handler.

What you'll learn in this lesson

  • Why entity access logic lives in its own dedicated class instead of scattered permission checks
  • How checkAccess() maps a requested operation (view, update, delete) to a named permission
  • Why creating an entity needs a completely separate method, checkCreateAccess()
  • What an "admin bypass" is, and why it should always run first
  • The difference between AccessResult::forbidden() and AccessResult::neutral() — and why picking the wrong one can silently break other modules

Why access control gets its own class

You could, in theory, sprinkle if ($account->hasPermission(...)) checks throughout your controllers and forms. Drupal deliberately discourages this. Instead, every content entity type declares one dedicated access control handler class, and every part of Drupal — routing, Views, the entity API itself — asks that one class the same question: "can this user do this operation to this entity?" Centralizing the logic in one place means the answer is always consistent, no matter which part of the system is asking.

The source file

Path: modules/content_entity_example/src/ContactAccessControlHandler.php

<?php

namespace Drupal\content_entity_example;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;

/**
 * Access controller for the contact entity.
 */
class ContactAccessControlHandler extends EntityAccessControlHandler {

  /**
   * {@inheritdoc}
   *
   * Link the activities to the permissions. checkAccess() is called with the
   * $operation as defined in the routing.yml file.
   */
  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
    // Check the admin_permission as defined in your @ContentEntityType
    // annotation.
    $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();
  }

  /**
   * {@inheritdoc}
   *
   * Separate from the checkAccess because the entity does not yet exist. It
   * will be created during the 'add' process.
   */
  protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
    // Check the admin_permission as defined in your @ContentEntityType
    // annotation.
    $admin_permission = $this->entityType->getAdminPermission();
    if ($account->hasPermission($admin_permission)) {
      return AccessResult::allowed();
    }
    return AccessResult::allowedIfHasPermission($account, 'add contact entity');
  }

}

Under 40 lines of real logic, and it governs every access decision for this entire entity type.

How it works

Extending EntityAccessControlHandler

class ContactAccessControlHandler extends EntityAccessControlHandler {

Rather than implementing the raw access-handler interface from scratch, you extend Drupal's base class and override just two methods. Drupal knows to use this specific class because the Contact entity's own annotation says so:

 *   handlers = {
 *     "access" = "Drupal\content_entity_example\ContactAccessControlHandler",
 *     ...
 *   },

Whenever anything calls $entity->access('view'), or Drupal checks access for the entity's canonical/edit/delete routes, this is the class that gets asked.

checkAccess() — deciding on an existing entity

protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account)

This method answers questions about an entity that already exists. Its three parameters:

  • $entity — the specific loaded Contact being checked.
  • $operation — a string like 'view', 'update', or 'delete', coming straight from the route definition or from a direct $entity->access('view') call.
  • $account — whose permissions are being evaluated.

The admin bypass — always check this first

$admin_permission = $this->entityType->getAdminPermission();
if ($account->hasPermission($admin_permission)) {
    return AccessResult::allowed();
}

$this->entityType->getAdminPermission() reads the admin_permission key from the entity's own @ContentEntityType annotation (typically something like 'administer contact entities'). If the current user holds that one permission, they're granted access immediately — no matter which operation they're attempting. This is a standard Drupal convention: one master permission that bypasses every finer-grained check, so administrators never get accidentally locked out of content they're supposed to fully control.

Why this comes first: if you put the admin bypass after the per-operation switch, an administrator without the specific "edit contact entity" permission could get denied before your code ever checks whether they're an admin. Order matters here.

Mapping operations to permissions

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');
}

Each case checks one named permission string. Those exact strings — 'view contact entity', 'edit contact entity', 'delete contact entity' — have to be declared separately in the module's content_entity_example.permissions.yml file so they show up as checkboxes on the real Permissions admin page. This handler doesn't define permissions; it only checks for them.

AccessResult::allowedIfHasPermission() is a convenience factory: it returns allowed() if the account has the permission, neutral() otherwise, and — importantly — it automatically attaches the correct cache metadata (a user.permissions cache context) so Drupal never serves a cached "yes" to a user who shouldn't get one.

The fallback: neutral(), not forbidden()

return AccessResult::neutral();

If $operation doesn't match any of the three cases, the method returns neutral() — "I have no opinion" — rather than forbidden() — "absolutely not." This distinction matters more than it looks: Drupal's access system lets multiple handlers weigh in on the same question, and a forbidden() result from any one of them can veto access outright, even if every other handler would have allowed it. Returning neutral() for operations you don't explicitly recognize keeps your handler from accidentally blocking something a future version of Drupal (or another module) legitimately wants to do.

checkCreateAccess() — a separate question for a reason

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');
}

Notice this method doesn't receive an $entity parameter at all. That's deliberate — when a user visits the "Add contact" form, there is no Contact entity yet; it won't exist until the form is submitted. Drupal routes creation-access questions to this separate method instead of trying to force them through checkAccess(), which always expects a real, already-existing entity object.

The same admin-bypass pattern runs first, then a single permission check against 'add contact entity' — completing the same four-permission set (view, edit, delete, add) as the checks in checkAccess().

AccessResult factory methods at a glance

MethodMeaningCache effect
AccessResult::allowed()Grant access unconditionallyNo permission cache metadata added
AccessResult::forbidden()Deny access unconditionally, vetoes other handlersNo permission cache metadata added
AccessResult::neutral()No opinion; defer to other handlersNo permission cache metadata added
AccessResult::allowedIfHasPermission($account, $perm)Grant if the account has the permissionAdds user.permissions cache context

Using allowedIfHasPermission() instead of writing the if (...) return allowed(); return neutral(); pattern by hand is the recommended approach for exactly this reason — it bundles the correct cache metadata into a single call, so you can't forget it.

See it for yourself

The permissions this handler checks aren't invented out of thin air — they're real checkboxes on the Permissions admin page, declared in content_entity_example.permissions.yml. Visit /admin/people/permissions on your DDEV site and look for the Content Entity Example section.

The Permissions admin page showing the Content Entity Example permissions section with view, edit, delete, and add contact entity checkboxes

Every permission name you see here — view contact entity, edit contact entity, delete contact entity, add contact entity, plus the administer-everything permission — is exactly the string this lesson's ContactAccessControlHandler code checks for. This is the other half of the equation: the code decides when to check a permission, this admin page decides who holds it.

Quick check: a site administrator with the "Administer content entity example" permission tries to delete a contact, but doesn't specifically have "delete contact entity" checked. Are they blocked? If you said no — the admin bypass grants them access to every operation regardless of the per-operation permissions — you've got it.

Key takeaways

  • Entity access logic lives in one dedicated class per entity type — extend EntityAccessControlHandler and override just checkAccess() and checkCreateAccess().
  • Always check the admin bypass permission first, via $this->entityType->getAdminPermission(), so administrators are never accidentally locked out by per-operation logic.
  • checkCreateAccess() exists separately because at creation time there is no entity object yet — Drupal routes "can I create one of these?" questions here instead of through checkAccess().
  • Default to AccessResult::neutral(), not forbidden(), for operations your handler doesn't explicitly recognize — a stray forbidden() can veto access even when every other handler would allow it.
  • Use AccessResult::allowedIfHasPermission() instead of a manual conditional — it automatically attaches the correct user.permissions cache context so cached access results stay correct per-user.
  • The permission strings checked in code ('view contact entity', etc.) must be separately declared in <module>.permissions.yml — the handler consumes permissions, it doesn't define them.

Coming up next

You can now define a Contact entity, let people create/edit/delete them through real routes, and gate every one of those operations behind proper permissions. The last missing piece is showing administrators all their contacts in one place — a sortable, linkable table with edit/delete actions built in. That's exactly what the final lesson in this topic, the Entity List Builder, covers next.