Creating a Custom Access Handler to Restrict Access in Drupal
Access Policy API
Create a custom access handler to restrict access to a controller based on some specific condition (e.g. user must have the letter e in it's username).
In Drupal, a custom Access Handler is the standard way to centralize access logic for a specific Entity Type (like a Node or a custom entity). While you can use hook_entity_access(), creating a dedicated Access Control Handler class is cleaner and more performant for complex modules.
1. Define the Handler in the Annotation
First, you must tell Drupal which class should handle access for your entity. This is done in the @ContentEntityType annotation of your entity class.
PHP
/**
* @ContentEntityType(
* id = "my_custom_entity",
* handlers = {
* "access" = "Drupal\my_module\MyEntityAccessControlHandler",
* },
* ...
* )
*/
2. Create the Access Control Handler
Your class should extend EntityAccessControlHandler. You primarily override two methods: checkAccess (for view/update/delete) and checkCreateAccess.
src/MyEntityAccessControlHandler.php
PHP
namespace Drupal\my_module;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;
class MyEntityAccessControlHandler extends EntityAccessControlHandler {
/**
* Controls access to viewing, editing, or deleting an existing entity.
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
// 1. Bypass check: If the user has a "master" permission.
if ($account->hasPermission('administer my custom entity')) {
return AccessResult::allowed()->cachePerPermissions();
}
switch ($operation) {
case 'view':
// Allow view if the entity is published or the user is the owner.
$status = $entity->get('status')->value;
if ($status || ($account->id() == $entity->getOwnerId())) {
return AccessResult::allowed()->cachePerPermissions()->cachePerUser();
}
return AccessResult::neutral();
case 'update':
// Only allow owners to edit.
return AccessResult::allowedIf($account->id() == $entity->getOwnerId())
->cachePerUser();
case 'delete':
// Strict restriction: prevent deletion if the entity is "locked".
if ($entity->get('is_locked')->value) {
return AccessResult::forbidden('This entity is locked and cannot be deleted.');
}
return AccessResult::allowedIfHasPermission($account, 'delete my custom entity');
}
return AccessResult::neutral();
}
/**
* Controls access to creating a brand new entity.
*/
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
return AccessResult::allowedIfHasPermission($account, 'create my custom entity');
}
}
3. Understanding AccessResult
Drupal doesn't just return TRUE or FALSE. It uses the AccessResult object to handle caching.
Result | Description |
| Access is granted. |
| This handler has no opinion. If all handlers are neutral, access is denied. |
| Access is strictly denied, even if another handler says "allowed". Use this for critical locks. |
4. Why Caching Metadata Matters
If you grant access based on a user's ID ($account->id()), you must tell Drupal to vary the cache by user. If you forget this, User A might see an "Edit" button that was cached for User B.
cachePerUser(): Use when logic depends on$account->id().cachePerPermissions(): Use when logic depends on$account->hasPermission().addCacheableDependency($entity): Use if access depends on a field value (likeis_locked).
5. How to Invoke the Handler
Once this is set up, you check access anywhere in your code like this:
PHP
$entity = \Drupal::entityTypeManager()->getStorage('my_custom_entity')->load(1);
if ($entity->access('update')) {
// The user can edit this!
}
This method automatically calls your checkAccess() method and combines the results with any other modules using hook_entity_access().
Next we will see how to implement hook_node_access to apply these same rules to standard Drupal Nodes instead of a custom entity...
When you want to restrict access to core Drupal nodes (which already have their own access handlers), you use hook_node_access. This hook allows you to "sit on top" of Drupal’s default logic to grant or deny access based on custom business rules.
1. Implementing hook_node_access
Add this to your .module file. This hook is called every time a node is viewed, edited, or deleted.
PHP
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Node\NodeInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Implements hook_node_access().
*/
function my_module_node_access(NodeInterface $node, $op, AccountInterface $account) {
$type = $node->bundle();
// 1. Only apply logic to 'article' content type.
if ($type !== 'article') {
return AccessResult::neutral();
}
// 2. Custom Business Logic: Restrict 'update' based on a field.
if ($op === 'update') {
// Example: If an article is marked as "Archived", nobody can edit it.
if ($node->hasField('field_archived') && $node->get('field_archived')->value == 1) {
return AccessResult::forbidden('Archived articles cannot be edited.')
->addCacheableDependency($node);
}
}
// 3. Custom Business Logic: Restrict 'view' based on User Role + Field.
if ($op === 'view') {
// Example: "Premium" articles require the 'premium_subscriber' role.
if ($node->hasField('field_is_premium') && $node->get('field_is_premium')->value == 1) {
if (!$account->hasPermission('access premium content')) {
return AccessResult::forbidden()->cachePerPermissions()->addCacheableDependency($node);
}
}
}
// Fallback to neutral so other modules (or core) can decide.
return AccessResult::neutral();
}
2. Access Result Triage
In Drupal, access is determined by combining the results of all modules implementing this hook.
Forbidden wins: If any module returns
AccessResult::forbidden(), access is denied, period.Allowed wins: If no module forbids access and at least one module returns
AccessResult::allowed(), access is granted.Neutral loses: If every single module (including Core) returns
AccessResult::neutral(), access is denied.
3. Important: The "Node Grants" Distinction
hook_node_access is perfect for individual node checks (like "is this node archived?"). However, it has a major limitation: it does not affect listings (Views).
If you use hook_node_access to hide nodes, they will still show up in Search results or Views lists, but users will get a "403 Access Denied" when they click them.
To hide nodes from listings and queries, you must use the Node Access Records system (also known as Node Grants):
System | Best For | Affects Views/Search? |
| Fast, logic-based checks on the "Full View" page. | No |
Node Grants | Complex permissions (e.g., "Private Groups") that must hide nodes from lists. | Yes |
4. Cacheable Dependencies
In the example above, notice addCacheableDependency($node). Since the access decision depends on the value of field_archived, we must tell Drupal: "If this node is updated, re-calculate the access permission."
Future discussion: how to implement hook_node_grants and hook_node_access_records to hide content from Views and Search results.
Recent content
-
1 hour 5 minutes ago
-
1 week ago
-
1 week 1 day ago
-
1 week 1 day ago
-
1 week 1 day ago
-
1 week 6 days ago
-
2 weeks ago
-
2 weeks 1 day ago
-
2 weeks 1 day ago
-
2 weeks 2 days ago