Build a Custom Drupal Access Check to Gate Your Module Routes
When you build a custom Drupal module, deciding who can hit a given route is rarely a one-line affair. Stock permissions often fall short when the rule depends on entity ownership, a third-party flag, or a value pulled from an external system that should not be visible to every authenticated user. That is exactly where a custom access check earns its keep, turning the access subsystem into something that fits the business rule rather than bending the rule to fit a checkbox.
Drupal already gives you a well-defined hook_node_access, the AccessInterface service, and a routing layer that respects _access_check requirements. Layering your own logic on top of those primitives lets you express conditions such as "editors in the same office as the content author may publish," which is the kind of rule you see across government portals, membership sites, and Australian e-commerce builds where a single role no longer covers the workflow.
How Drupal Resolves Access Decisions
Every request to a route is filtered through a chain of access checks. Each check returns an AccessResult, and Drupal combines them using AND/OR logic expressed in the routing definition. The three most common sources of these results are static _permission strings, services that implement Drupal\Core\Access\AccessInterface, and the legacy hook_node_access() callback that still ships in core for backwards compatibility.
The AccessResult object carries three states: allowed, forbidden, and neutral. Neutral is the polite middle ground — it lets other checks in the chain have their say. Once you see a forbidden result, the request short-circuits with a 403 response, so specificity matters. A custom check should return neutral when the condition is irrelevant for the given context, forbidden when the rule is clearly violated, and allowed only when the rule is satisfied and no further checks are needed.
For Australian projects, this chain also feeds into compliance reporting. Agencies bound by the Privacy Act 1988 and the Australian Privacy Principles often need to demonstrate that a restricted page was blocked for a stated reason. Returning a meaningful forbidden reason through AccessResult::forbidden('Your team has not approved this draft.') produces audit-friendly responses both in the UI and in watchdog logs.
Scaffolding the Module Before Writing the Check
A custom access check is just a service, so the module starts in the same shape as any other. Create mymodule.info.yml, mymodule.services.yml, and a src/Access/ directory. Declaring the namespace in mymodule.info.yml ensures Drupal's autoloader can find your class wherever the module lives — useful when you vendor the module through Composer for an Acquia or Pantheon-style PaaS deployment, or when you deploy to on-shore Australian infrastructure such as a VPS from VentraIP or an AARNet-connected data centre.
Once the skeleton exists, decide whether the access check should be parameterless (returning the same verdict for any caller) or whether it should accept a route parameter such as a node ID or a taxonomy term. Parameterised checks are common when the rule depends on the entity being viewed, so plan for a __construct() that injects services such as the entity type manager, the current user, or your own domain repositories. Constructor injection keeps the class testable through unit-style harnesses and avoids the static service container calls that tend to bite teams later on.
Writing the AccessInterface Implementation
A common example is an Australian e-commerce check that gates a "bulk discount" tool behind both the access bulk discount permission and an extra rule that only staff in the head office can trigger it:
namespace Drupal\mymodule\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\Routing\Route;
class BulkDiscountAccessCheck implements AccessInterface {
protected $account;
public function __construct(AccountInterface $account) {
$this->account = $account;
}
public function access(Route $route, RouteMatchInterface $route_match, AccountInterface $account) {
$role = $account->getRole();
if ($role !== 'head_office') {
return AccessResult::forbidden('Bulk discount tools are reserved for head office staff.');
}
return AccessResult::allowed()->addCacheContexts(['user.roles']);
}
}
The addCacheContexts() call is critical. Without it, a page rendered for the marketing intern would be served from the page cache to the warehouse supervisor. Adding the user.roles context forces Drupal to vary the cache by the viewer's role, which is exactly how Varnish or a CDN such as a Cloudflare Australian edge node should treat role-sensitive responses.
Register the class as a service in mymodule.services.yml:
services:
mymodule.bulk_discount_access:
class: Drupal\mymodule\Access\BulkDiscountAccessCheck
arguments: ['@current_user']
tags:
- { name: access_check, applies_to: _bulk_discount_access }
The access_check tag with an applies_to value is what tells the route layer which requirement string this service satisfies. Pick a name that is unique and unlikely to collide with anything you find on drupal.org.
Hooking the Check Into a Route
Once the service is sorted, attach it to a route in mymodule.routing.yml:
mymodule.bulk_discount:
path: '/admin/finance/bulk-discount'
defaults:
_form: '\Drupal\mymodule\Form\BulkDiscountForm'
_title: 'Bulk discount tools'
requirements:
_permission: 'access bulk discount'
_bulk_discount_access: 'TRUE'
options:
_admin_route: TRUE
The _bulk_discount_access: 'TRUE' line reads the boolean from the YAML parser and hands it to the access() method as a string. If you need a richer value, parse the string in your service. Drupal combines the two requirements with AND logic, so both must pass for the user to reach the form. This pairing is popular on intranets at government departments such as the Australian Taxation Office, where a permission guards the entry point and a contextual check shields sensitive subsections.
For controller-driven routes built with the #[Route] attribute introduced in Drupal 10, the same logic applies. You can declare _bulk_discount_access: 'TRUE' inside an #[Route] attribute or in a separate routing file. The attribute route loader merges them transparently, so the choice between YAML and PHP is a matter of taste rather than capability.
Comparing the Main Access Patterns
| Pattern | Where it applies | Inspects entity? | Easy to test | Caching awareness |
|---|---|---|---|---|
_permission in routing.yml |
Any route | No | Limited | Automatic |
| Service implementing AccessInterface | Any route | Yes, via injected services | High, with mocks | Manual via cache contexts |
hook_node_access() |
Node view/update/delete | Yes, the node itself | Medium | Manual via access result metadata |
_entity_access requirement |
CRUD routes for entities | Yes, via entity type plugin | High | Automatic when using entity access plugins |
In-controller $this->guard()->condition() |
Forms or service-injected endpoints | Yes, current request only | Low, requires full stack | Manual |
The right pick depends on what you need to introspect. A simple permission gate is fine for tools that everyone with a role should see. Anything that branches on the node author, the workflow state, or a flag stored in a custom table is much cleaner through an AccessInterface service, which is why modules shared through the Australian Drupal community meetups in Melbourne and Sydney tend to follow that pattern.
Caching, Logging, and Local Testing
After wiring everything up, clear caches with drush cache:rebuild and tail the log with drush watchdog:tail while exercising the route as different roles. The Australian Cyber Security Centre recommends that restricted routes expose enough context to operators without leaking the rule itself, so phrase forbidden messages carefully — a verbose message helps the on-call developer but may also help an attacker probe the system.
In local development, run Behat or PHPUnit suites that hit the route as at least three accounts: anonymous, an authenticated user without the permission, and a head-office role. A test such as
$this->drupalGet('/admin/finance/bulk-discount', ['account' => $intern]);
$this->assertSession()->statusCodeEquals(403);
gives you confidence the chain behaves as expected. Configuration sync matters too: export the routing file and the services file with drush config:export, then import the result on staging and production so the access check service is registered everywhere the module ships.
Finally, add a CHANGELOG note when you tighten or relax an access rule. Auditors reviewing activities under the Notifiable Data Breaches scheme look at version control as a first stop, so a tidy commit history pays off the day something goes sideways.