Drupalwoo ☉

Build a custom Drupal access checker

Node URLs look simple: /node/42 renders one content object, and Drupal’s core entity access already decides whether the viewer may see it. A custom Drupal access checker is useful when a project adds a project-specific rule—publication workflow, content type, audience or moderation state—to that standard decision. The checker belongs in the route access layer, so direct requests, links and API calls encounter the same rule.

Access decisions are easy to get wrong when they exist only in a JavaScript interface or in one controller. Drupal’s route requirements, AccessResult objects and cache metadata provide a server-side mechanism that can be reused and tested. The implementation below keeps core node access in place, then adds a narrow rule for briefing nodes without replacing Drupal’s grant system.

Route requirement What it decides Possible outcome Cache data that matters
_entity_access: 'node' Core node grants, status rules and module permissions Allowed or forbidden Node access records, entity data and permissions
_custom_access: 'node_publication_access:access' A project-specific rule for the route’s node Allowed, neutral or forbidden Node, permissions and referenced workflow data
_permission: 'access briefings' Access granted by a named Drupal permission Allowed or forbidden Effective permissions and roles

Understand the access layers

Drupal applies route requirements before the controller runs. _entity_access: 'node' invokes Drupal’s entity access machinery, including node grants, published-state rules and permissions defined by modules. A custom requirement such as _custom_access: 'node_publication_access:access' calls a named service method. Both can sit on the same route, so the custom policy complements core access instead of silently bypassing it.

The checker receives the route and current account, not the rendered node. It reads the node route parameter, loads the node when necessary and turns the policy into an AccessResult. This matters for aliases and entity routes: a reliable decision is based on the actual node and its current state, not on a URL pattern or a link that happened to be hidden.

AccessResult::allowed() grants access, forbidden() denies it and neutral() says the checker has no opinion. In a combined route check, a neutral result can leave the decision to other applicable checks; forbidden wins over allowed. Treat those outcomes as distinct states, because returning a plain boolean discards cacheability information and makes stale anonymous or authenticated decisions more likely.

Build the checker service

Place the class in a custom module and register it as an access-check service. The tag lets Drupal discover it, while the route still names the exact service and method it should call.

services:
  node_publication_access:
    class: Drupal\custom_node_access\Access\NodePublicationAccessChecker
    arguments:
      - '@entity_type.manager'
    tags:
      - { name: access_check }

Give the checker an entity type manager so it can resolve the raw node parameter. The example uses current Drupal account APIs and assumes a briefing node type with a view unpublished briefings permission.

<?php

namespace Drupal\custom_node_access\Access;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Authentication\AccountInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\Route;
use Drupal\node\NodeInterface;

final class NodePublicationAccessChecker implements AccessInterface {

  public function __construct(
    protected EntityTypeManagerInterface $entityTypeManager,
  ) {}

  public function access(
    Route $route,
    ?AccountInterface $account = NULL,
    $return_as_object = FALSE
  ) {
    if ($route->getRequirement('_custom_access') !== 'node_publication_access:access') {
      $result = AccessResult::neutral();
      return $return_as_object ? $result : $result->isAllowed();
    }

    $parameter = $route->getParameter('node');
    $node = $parameter instanceof NodeInterface
      ? $parameter
      : (is_scalar($parameter)
        ? $this->entityTypeManager->getStorage('node')->load((string) $parameter)
        : NULL);

    if (!$node instanceof NodeInterface || !$account) {
      $result = AccessResult::forbidden();
    }
    elseif ($node->bundle() !== 'briefing') {
      $result = AccessResult::forbidden('This route is for briefing nodes.');
    }
    elseif ($account->hasPermission('administer nodes')) {
      $result = AccessResult::allowed('Administrators may view briefings.');
    }
    elseif (
      !$node->isPublished()
      && !$account->hasPermission('view unpublished briefings')
    ) {
      $result = AccessResult::forbidden('This briefing is not published.');
    }
    else {
      $result = AccessResult::allowed('This briefing is available.');
    }

    if ($node instanceof NodeInterface) {
      $result
        ->addCacheableDependency($node)
        ->addCacheTags(['node_access:' . $node->id()]);
    }

    if ($account) {
      $result->addCacheContexts(['user.permissions']);
    }

    return $return_as_object ? $result : $result->isAllowed();
  }

}

After access is calculated, add the node as a cacheable dependency and tag the result with the node’s access record. The permission context varies the cached decision by the viewer’s effective permissions. Define view unpublished briefings in the module’s permissions.yml; reuse administer nodes for the administrator case. Do not call hasPermission() and return an uncacheable-looking result, because a role change could otherwise leave an old response available. On an older Drupal branch, use the AccountInterface namespace declared by that installed core version.

Connect the checker to node routes

A dedicated route is usually safer than changing Drupal’s canonical node route. Declare both core entity access and the custom check:

briefing.view:
  path: '/briefings/{node}'
  defaults:
    _entity_view: 'node'
    _title: '{{ node.title }}'
  requirements:
    _entity_access: 'node'
    _custom_access: 'node_publication_access:access'

The entity requirement still applies published-state and grant rules from modules. The custom checker adds the project rule that an unpublished briefing needs the extra permission, while administrators retain their usual route access. If a non-briefing node reaches this endpoint, the checker forbids it rather than exposing an unrelated content type through a briefing URL.

If an existing route must use the rule, add the requirement in a route-collection event subscriber and guard against overwriting another _custom_access value. Scope the subscriber carefully: changing the canonical /node/{node} route affects previews, edit links and every node bundle. A separate route is easier to reason about, especially for councils, campaign sites and membership organisations.

Navigation should follow the same decision. The existing guide to custom breadcrumb logic is useful for route-aware trails, but a breadcrumb must not expose a restricted node title; resolve it only after access succeeds. Cache breadcrumb fragments by the same permission and node contexts, or an anonymous page may retain a label that a later viewer was not allowed to see.

Cache and test decisions safely

Cache metadata is part of the access result, not optional polish. The example adds user.permissions, the node entity dependency and a node_access:<nid> tag. Include other dependencies when the decision reads workflow entities, configuration or a custom audience record. When a node is published, moderation state changes or a grant is rebuilt, the relevant tag must be invalidated; otherwise Drupal may serve a decision that is technically cached but operationally wrong.

Test at least the anonymous user, an ordinary authenticated user, a user with the unpublished-content permission and an administrator. Cover published and unpublished nodes, the wrong content type, a missing node, direct URLs and aliases. In functional tests, request the route as each account and assert both the response and its cache headers. A denied request should normally produce a 403, while a missing node keeps Drupal’s normal 404 behaviour.

Keep _entity_access: 'node' on the route even when the custom checker appears stricter. The custom code addresses one business rule; core still handles grants for roles, content types and modules that extend node access. Never infer permission from the presence of a button, and avoid loading a node as published and then assuming the viewer may see it. If existence itself is sensitive, decide deliberately whether a 404 is preferable to a 403 rather than leaking details accidentally.

Run it on Australian Drupal sites

Australian implementations often sit with small in-house teams or specialist Drupal agencies in Sydney, Melbourne, Brisbane and Canberra, rather than a single large engineering organisation. That makes configuration discipline important: a checker should be understandable to an editor, documented in Australian English and safe to deploy without a full development team on every site.

Regional clients can have a very different operating context from metropolitan offices. Teams working across regional Victoria, the Pilbara in Western Australia or south-west WA may manage sites over satellite links and intermittent connections. A clear server response is more useful there than a decorative loading spinner. The practical lesson in solar lantern investment is similar: equipment works in context when its operating assumptions match the household, just as route access works when its assumptions match the site’s content workflow.

Local language also matters in handover notes. An editor saying “no worries, that briefing can wait until the arvo” may simply mean the item is not ready for publication, not that the access rule should be relaxed. Scheduled changes should name AEST/AEDT or the local Australian/Adelaide and Australia/Perth zones, especially during daylight-saving transitions. For organisations subject to the Australian Privacy Act 1988, the access trail should also support the Australian Privacy Principles by exposing only what the viewer is entitled to see.