Skip to content

TutorialFor module developersDrupal 11

Build your first module 6: permissions and access checks

So far every page in this module has worked because you are the administrator, who passes permission checks that do not even exist. This part makes access real: you define the module’s three permissions, then build a custom access check so the RSVP form only exists on content types an administrator has enabled.

Part 5 completed the visitor-facing features. This part adds event_rsvp.permissions.yml, defining the permissions the routing file has referenced since part 2, and RsvpAccessCheck, a service tagged as an access check that activates the _event_rsvp_enabled route requirement. At the end, a regular user with the right permission can RSVP to articles, gets a 403 on other content types, and cannot see the admin report at all.

  • Parts 1 through 5 completed.
  • The enabled_types configuration from part 4 still lists only article.

Permissions are declared in <module>.permissions.yml. Create web/modules/custom/event_rsvp/event_rsvp.permissions.yml:

web/modules/custom/event_rsvp/event_rsvp.permissions.yml
rsvp to events:
title: 'RSVP to events'
description: 'Submit an RSVP form on RSVP-enabled content.'
view event rsvp reports:
title: 'View Event RSVP reports'
description: 'See who has responded to an event.'
administer event rsvp:
title: 'Administer Event RSVP'
description: 'Configure which content types accept RSVPs.'
restrict access: true

Each top-level key is the permission’s machine name, the exact string used in route requirements and hasPermission() calls. The values feed the permissions UI: title and description label the checkbox, and restrict access: true on administer event rsvp adds the warning “Give to trusted roles only; this permission has security implications.” next to it. Mark any permission that way when granting it carelessly could compromise the site; a permission controlling which content accepts public form submissions qualifies.

Two of these names should look familiar: rsvp to events and view event rsvp reports are what event_rsvp.routing.yml has required since part 2. A _permission requirement works as a plain string match against the account’s permissions, so routes can reference permissions before they are defined; defining them is what makes it possible to grant them to a role. Note what permissions are not: the definitions are code, but which roles hold a permission is site configuration, stored on each role.

Permission definitions are cached, so rebuild:

Terminal window
ddev drush cache:rebuild

The rsvp to events permission answers “may this person RSVP at all?”. It cannot answer “is this node something one can RSVP to?”, because that depends on the node in the URL and the enabled_types configuration. Per-route logic like that is a custom access check, and core gives you two ways to write one. The lighter is the _custom_access route requirement: point it at a method (often on the controller) that returns an AccessResult, no registration needed, right for logic only one route will ever use. The heavier is what this part builds: an access-check service bound to its own requirement key, which any route in any module can then reuse. “Is RSVP enabled for this type?” is exactly that kind of reusable rule, and its requirement key has been waiting in the routing file since part 2; Access: users, roles, and permissions compares the two forms. Create web/modules/custom/event_rsvp/src/Access/RsvpAccessCheck.php:

web/modules/custom/event_rsvp/src/Access/RsvpAccessCheck.php
<?php
declare(strict_types=1);
namespace Drupal\event_rsvp\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\node\NodeInterface;
/**
* Grants access to RSVP routes only on RSVP-enabled content types.
*
* Registered in event_rsvp.services.yml with the "access_check" tag and
* applies_to: _event_rsvp_enabled, which routes opt into via their
* requirements section.
*/
class RsvpAccessCheck implements AccessInterface {
/**
* Constructs the access check.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The config factory.
*/
public function __construct(
protected readonly ConfigFactoryInterface $configFactory,
) {}
/**
* Checks that the node's content type accepts RSVPs.
*
* @param \Drupal\node\NodeInterface $node
* The node from the route.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result, with cacheability metadata attached.
*/
public function access(NodeInterface $node): AccessResultInterface {
$settings = $this->configFactory->get('event_rsvp.settings');
$enabled_types = $settings->get('enabled_types') ?? [];
return AccessResult::allowedIf(in_array($node->bundle(), $enabled_types, TRUE))
// The result is only valid as long as the settings and the node stay
// the same; declaring that here keeps cached access decisions correct.
->addCacheableDependency($settings)
->addCacheableDependency($node);
}
}

The moving parts:

  • The class implements \Drupal\Core\Routing\Access\AccessInterface, and its access() method is called with the route’s parameters resolved the same way controller arguments are, so $node arrives as the upcast entity, exactly as in part 2’s controller.
  • It returns an \Drupal\Core\Access\AccessResult, never a boolean. allowedIf() produces allowed or neutral, and neutral (not forbidden) is the right “no” here: it means “this check has no reason to allow”, leaving room for other checks. When several requirements apply to a route, Drupal combines them with and-semantics: every check must allow, or the response is a 403.
  • Access results end up inside cached output (page cache responses, rendered links, local tasks), so they carry cacheability like render arrays do. This result depends on two things that can change: the event_rsvp.settings config (an administrator can enable or disable a type) and the node (its type, in theory, and its existence). addCacheableDependency() attaches both, so when you save the settings form from part 4, every cached response or link built from the old decision is invalidated automatically. Forgetting this line is how sites end up showing stale links and cached pages until someone clears caches; part 5’s lesson applies to access exactly as it applied to rendering.

Step 3: Register the check and activate the requirement

Section titled “Step 3: Register the check and activate the requirement”

A class becomes an access check the same way RsvpStorage became a service: through event_rsvp.services.yml, this time with a tag. Add the event_rsvp.access_check entry below the storage service from part 3:

web/modules/custom/event_rsvp/event_rsvp.services.yml
services:
event_rsvp.storage:
class: Drupal\event_rsvp\RsvpStorage
arguments:
- '@database'
- '@datetime.time'
- '@cache_tags.invalidator'
# Alias the class name to the service so autowired classes — like the
# #[Hook] implementations in src/Hook — can receive it by type hint.
Drupal\event_rsvp\RsvpStorage: '@event_rsvp.storage'
event_rsvp.access_check:
class: Drupal\event_rsvp\Access\RsvpAccessCheck
arguments:
- '@config.factory'
tags:
# "applies_to" connects this service to the _event_rsvp_enabled
# requirement used in event_rsvp.routing.yml.
- { name: access_check, applies_to: _event_rsvp_enabled }

The tag is the wiring. name: access_check tells the router this service participates in access decisions, and applies_to: _event_rsvp_enabled binds it to a requirement key. From now on, any route in any module that lists _event_rsvp_enabled in its requirements gets this service’s access() method in its access chain. That key has been sitting in event_rsvp.routing.yml since part 2, ignored because nothing claimed it; this registration is what flips it on. The form route now demands all three of its requirements: _permission: 'rsvp to events', _entity_access: 'node.view' (the visitor can view the event itself, so unpublished drafts stay hidden), and an RSVP-enabled content type.

Terminal window
ddev drush cache:rebuild

Administrators are the wrong account for testing access: user 1 and the Administrator role pass every permission check. (That user-1 behavior is the super user policy, and the comment above security.enable_super_user in sites/default/default.services.yml explains how to turn it off; consider that for production sites.) Build a realistic test setup: a node of a type that does not accept RSVPs, the permission granted to authenticated users, and a regular account.

The access check only allows content types listed in the enabled_types setting from part 4, which is just article. To watch it refuse a request, you need a second type. Add Basic page the same way part 2 added Article, or skip the command if /node/add/page already works:

Terminal window
ddev php web/core/scripts/dr recipe:apply web/core/tests/fixtures/recipes/page_content_type

Create the page node at Administer → Content → Add content → Basic page (/node/add/page), titled About us; note its node ID (assumed 3 below). Then:

Terminal window
ddev drush role:perm:add authenticated 'rsvp to events'
ddev drush user:create rsvp_tester --password=rsvp_tester
ddev drush user:login --name=rsvp_tester

The last command prints a one-time login link; open it in a private browser window so your admin session stays untouched. Then, as rsvp_tester:

  • /node/1/rsvp (article): the RSVP form loads. Permission granted, and article is in enabled_types.
  • /node/3/rsvp (page): 403. The permission check passes, but RsvpAccessCheck returns neutral because page is not in enabled_types, and neutral ends as forbidden.
  • /admin/reports/event-rsvp/1: 403. The report route requires view event rsvp reports, which authenticated users do not hold.
  • Unpublish node 1 as the administrator and reload /node/1/rsvp: 403. The tester cannot view an unpublished node, so _entity_access denies the form too. Publish it again before you continue.

For the last word on flexibility, go to the settings form as the administrator and enable RSVPs for Basic pages: /node/3/rsvp starts working for the tester on the next load, no cache clear needed. Declaring the settings config as a cacheable dependency is what keeps every cached response and rendered link that used the old decision correct when the settings change.

You are testing with the administrator account, which passes all permission checks. Use the rsvp_tester window from step 4.

The tester gets 403 on the article RSVP form

Section titled “The tester gets 403 on the article RSVP form”

The permission grant did not land. Check /admin/people/permissions for a checked “RSVP to events” box in the Authenticated user column, or rerun ddev drush role:perm:add authenticated 'rsvp to events'.

The site throws “class not found” errors after editing services.yml

Section titled “The site throws “class not found” errors after editing services.yml”

The services file references Drupal\event_rsvp\Access\RsvpAccessCheck, so a typo in the class name or file path breaks any route using the requirement. Confirm the file is src/Access/RsvpAccessCheck.php and run ddev drush cache:rebuild.

The router re-checks route access on every request, so the RSVP form itself always reflects the current settings. If a cached page or link still reflects the old settings after a config save, your access() method is missing the addCacheableDependency($settings) line: saving the config through the API, the form, or ddev drush config:set invalidates the config’s cache tag, and only results that declared it get dropped.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.