Implementing Access Checks with AccessResult in Drupalfor Drupal 8 , 9 , 10 , and 11

Last updated :  

Imagine a bouncer at a club door who never just says "yes" or "no" — instead, for every single person, they weigh multiple rules (guest list, dress code, age, capacity) and can also shrug and say "not my call, ask someone else." That third option is what makes Drupal's access system genuinely powerful: AccessResult doesn't just return a boolean. It has three real states, and understanding the third one is the key to writing access logic that plays nicely with everyone else's access logic on the same site.

What you'll learn in this lesson

  • The three states an access check can return, and why "neutral" is not the same as "forbidden"
  • The most common AccessResult factory methods and when to reach for each one
  • How a real entity access controller uses AccessResult to gate view/update/delete/create operations
  • Why access results carry cache metadata, and what breaks if you forget to attach it

The three states: allowed, forbidden, neutral

use Drupal\Core\Access\AccessResult;

AccessResult::allowed();    // "Yes, let them through."
AccessResult::forbidden();  // "No — and stop asking other checks too."
AccessResult::neutral();    // "Not my decision — defer to other access checks."

This matters because Drupal often runs several access checks for the same request — a route requirement, an entity access handler, and any number of modules implementing hook_entity_access() — and combines their answers. forbidden() is absolute and immediately wins over everything else. neutral() means "I have no opinion," letting some other check make the actual call; if nothing ever returns allowed(), the final answer defaults to denied. Returning forbidden() when you actually meant "I don't manage this case" is a common bug that can lock out access another module was trying to correctly grant.

The permission-checking shortcuts

// Equivalent to: $account->hasPermission('edit contact entity')
//   ? AccessResult::allowed() : AccessResult::neutral()
AccessResult::allowedIfHasPermission($account, 'edit contact entity');

// Equivalent to: $condition ? AccessResult::allowed() : AccessResult::forbidden()
AccessResult::allowedIf($condition);

Notice the difference in what happens on failure: allowedIfHasPermission() falls back to neutral() (letting other checks decide), while allowedIf() falls straight to forbidden(). Reach for allowedIfHasPermission() when your check is just one of several legitimate ways to gain access; reach for allowedIf() when your condition really is the final word.

A real entity access controller, method by method

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;

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

}

This is the real access controller from the Examples module's Content Entity example. Two things are worth noticing: checkAccess() and checkCreateAccess() are separate methods, because a create check has no entity to inspect yet (nothing's been saved), so it needs its own logic. And the admin-permission shortcut is checked first in both — a single "administer contacts" permission bypasses every operation-specific check, which is a common and sensible pattern: one escape hatch for site builders instead of forcing them to grant four separate permissions.

Don't forget cache metadata. AccessResult objects carry cacheability information — ->cachePerPermissions(), ->addCacheableDependency($entity), and similar methods exist because an access decision is itself cacheable data. If your custom access logic depends on something Drupal doesn't already know to vary the cache by (say, the time of day, or a custom session value), and you don't attach that as cache metadata, one user's "allowed" result can get cached and incorrectly served to a different user who should have been denied.

Quick check: your custom access check returns AccessResult::forbidden() whenever a user lacks a specific permission, instead of AccessResult::allowedIfHasPermission()'s default fallback. What's the practical difference if another module's hook also grants access to the same route for a different reason? (With forbidden(), your check overrides and blocks the other module's allowed() entirely, since forbidden always wins. With the neutral fallback, your check simply steps aside and lets the other module's grant stand. Using forbidden() for "I don't handle this case" is a real bug, not a stricter safety choice.)

Key takeaways

  • AccessResult has three states — allowed(), forbidden(), and neutral() — and forbidden() always wins over every other check, so use it only when you mean an absolute, final denial.
  • allowedIfHasPermission() falls back to neutral() on failure (defer to others); allowedIf() falls back to forbidden() (final word) — pick based on whether your check is one of several valid paths to access or the sole gatekeeper.
  • Entity access controllers separate checkAccess() (existing entities) from checkCreateAccess() (no entity exists yet) because create operations have nothing to inspect.
  • Attach cache metadata to any access result whose logic depends on something Drupal doesn't already vary its cache by, or you risk serving one user's access decision to another.

Coming up next

You've now covered the core security trio every module developer needs — sanitizing input, escaping output, and gating access. Next, the Queue API topic shifts focus entirely: "Creating a Queue" looks at how to defer slow or unreliable work to run safely in the background instead of blocking a page request.