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.
What you’ll build
Section titled “What you’ll build”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.
Prerequisites
Section titled “Prerequisites”Step 1: Define the permissions
Section titled “Step 1: Define the permissions”Permissions are declared in <module>.permissions.yml. Create 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: trueEach 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:
ddev drush cache:rebuildStep 2: Build the access check
Section titled “Step 2: Build the access check”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:
<?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 itsaccess()method is called with the route’s parameters resolved the same way controller arguments are, so$nodearrives 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.settingsconfig (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:
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 }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'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.
ddev drush cache:rebuildStep 4: Verify as a regular user
Section titled “Step 4: Verify as a regular user”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:
ddev php web/core/scripts/dr recipe:apply web/core/tests/fixtures/recipes/page_content_typeCreate the page node at Administer → Content → Add content → Basic page (/node/add/page), titled About us; note its node ID (assumed 3 below). Then:
ddev drush role:perm:add authenticated 'rsvp to events'ddev drush user:create rsvp_tester --password=rsvp_testerddev drush user:login --name=rsvp_testerThe 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, andarticleis inenabled_types./node/3/rsvp(page): 403. The permission check passes, butRsvpAccessCheckreturns neutral becausepageis not inenabled_types, and neutral ends as forbidden./admin/reports/event-rsvp/1: 403. The report route requiresview 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_accessdenies 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.
Troubleshooting
Section titled “Troubleshooting”Everything is still accessible
Section titled “Everything is still accessible”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.
Changing enabled types has no effect
Section titled “Changing enabled types has no effect”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.
Next steps
Section titled “Next steps”- Part 7: tests and shipping proves all of this works, permanently, with PHPUnit.
- Access: users, roles, and permissions is the canonical page on the access system, including entity access and nodes.
- Services and events covers tagged services beyond access checks.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.