Disabling a Drupal content type for every user, including uid 1
In Drupal, hook_node_access() returning forbidden is not enough to lock a content type away from administrators. Core's node access handler checks the bypass node access permission first and returns early, so users with that permission never reach your hook. To disable a content type for everyone, a team working on a group of Drupal sites combined hooks with a custom access control handler on the node and node type entities, plus a route check and a few UI clean-ups.
Why does the obvious hook not work?
The natural first attempt is to implement the node access hooks and return AccessResult::forbidden() for the bundles that should disappear:
function mymodule_node_access(EntityInterface $node, $operation, AccountInterface $account) {
if ($node->bundle() === 'legacy_event') {
return AccessResult::forbidden('Disabled.');
}
return AccessResult::neutral();
}
For ordinary editors this works. Forbidden beats allowed when access results are combined, so any other module's grant loses. But administrators carry bypass node access, and NodeAccessControlHandler::access() and createAccess() test for it up front and return allowed without invoking the hooks. The requirement here was stricter than "hide it from editors": nobody, including the super user, should be able to create or edit these bundles while a setting was switched on.
What did the team change?
They replaced the handler. Drupal lets a module swap the access handler for an entity type from hook_entity_type_alter(), so a small subclass can check its own rule before deferring to core:
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\node\NodeAccessControlHandler;
class DisabledBundleNodeAccessHandler extends NodeAccessControlHandler {
private const DISABLED = ['legacy_event'];
public function access(EntityInterface $entity, $operation, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
if (in_array($entity->bundle(), self::DISABLED, TRUE)) {
$result = AccessResult::forbidden('Disabled.');
return $return_as_object ? $result : FALSE;
}
return parent::access($entity, $operation, $account, $return_as_object);
}
public function createAccess($entity_bundle = NULL, ?AccountInterface $account = NULL, array $context = [], $return_as_object = FALSE) {
if (in_array($entity_bundle, self::DISABLED, TRUE)) {
$result = AccessResult::forbidden('Disabled.');
return $return_as_object ? $result : FALSE;
}
return parent::createAccess($entity_bundle, $account, $context, $return_as_object);
}
}
function mymodule_entity_type_alter(array &$entity_types) {
$entity_types['node']->setHandlerClass('access', DisabledBundleNodeAccessHandler::class);
}
The same pattern applies to the node_type entity, so the bundle's configuration pages are closed too.
What else still leaks the content type?
Access handlers do not remove a bundle from every screen. The team's fix touched several more places:
- Route access. Some admin routes for a bundle's fields and display settings are guarded by a route requirement. A custom access check on those routes closes them.
- Entity list builders. The content types overview lists every bundle it loads. Overriding the list builder to drop the hidden bundles stops them being listed.
- Exposed Views filters. The admin content view offers a "Content type" dropdown. A
hook_form_views_exposed_form_alter()that unsets the hidden options keeps them out of the filter. - Reference fields. Fields that point at the disabled bundle's entities need
hook_entity_field_access()or a form#accesschange, otherwise editors still see empty widgets for content they cannot open.
Treat these as a checklist. Missing any one produces a screen that looks half-removed.
Why put the switch in configuration?
Each check reads a single boolean from a config object and attaches it with addCacheableDependency(). Two reasons. First, an administrator can switch the content type back on without a deploy. Second, access results are cached: without the cache dependency, flipping the setting would not take effect until caches were cleared. Cacheability metadata is the part of custom access code that is easiest to forget.
The same idea for taxonomy vocabularies
The team applied the pattern to hiding taxonomy vocabularies as well, with a smaller footprint. For terms, plain hooks were enough: hook_entity_create_access() to block creating terms in a hidden vocabulary and hook_entity_access() to forbid updating or deleting them. Check core's handler for each entity type you hide instead of assuming it behaves like nodes.
What I'd do differently
I like that the behaviour is one setting you can read about in the admin UI. I would put the list of disabled bundles in config as well, rather than hard-coding it in four places. When a further bundle was added to the list in this work, the same edit had to be repeated across the module, the access handlers, the route check and the list builder. A single helper returning the list, used everywhere, would turn that into a one-line change.
Key takeaways
- Core's node access handler returns allowed early for users with
bypass node access, so access hooks alone cannot lock them out. - To disable a bundle for everyone, subclass the access handler for the entity type and register it in
hook_entity_type_alter(). - Hiding a bundle also needs route checks, a list builder override, exposed Views filter clean-up and field-level access.
- Attach cacheability metadata to every result driven by a config switch, or the change will not apply until caches clear.
- Keep the list of hidden bundles in one place so adding another is a single edit.