Problem/Motivation
Drupal's OOP #[Hook] system explicitly supports implementing the same hook multiple times within one module, in separate methods.
hook_entity_field_access() is an exception. EntityAccessControlHandler::fieldAccess() collects grants keyed by module name, then flattens with array_merge():
// core/lib/Drupal/Core/Entity/EntityAccessControlHandler.php:351-358
$grants = [];
$this->moduleHandler()->invokeAllWith(
'entity_field_access',
function (callable $hook, string $module) use ($operation, $field_definition, $account, $items, &$grants) {
$grants[] = [$module => $hook($operation, $field_definition, $account, $items)];
}
);
$grants = array_merge([':default' => $default], ...$grants);
Each invocation is wrapped in its own single-key [$module => $result] array, and array_merge() on string keys means later entries silently overwrite earlier ones sharing the same key. If a module implements entity_field_access more than once — the pattern the OOP hook system is meant to encourage, splitting one hook into focused, single-responsibility methods — only the last-invoked method's result for that module survives. There is no orIf() combination applied across a module's own multiple grants the way there is across different modules'.
#3558998 — Allow multiple hook_theme implementations per module (https://www.drupal.org/project/drupal/issues/3558998) documents the identical root-cause pattern (invokeAllWith-collected hook, same-module results clobbering) for hook_theme. A maintainer there states most hooks support multiple per-module implementations safely and names four known-restricted hooks (hook_library_info_build, hook_mail, hook_help, hook_node_update_index) — entity_field_access isn't on that list, suggesting this instance is currently untracked.
Steps to reproduce
Create a minimal module (mymodule) with a single src/Hook/Hooks.php, two methods, each restricting a different, real field.
class Hooks {
#[Hook('entity_field_access')]
public function forbidMyField(string $operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
if ($field_definition->getName() === 'field_my_field') {
return AccessResult::forbidden();
}
return AccessResult::neutral();
}
#[Hook('entity_field_access')]
public function forbidMyOtherField(string $operation, FieldDefinitionInterface $field_definition, AccountInterface $account, ?FieldItemListInterface $items = NULL) {
if ($field_definition->getName() === 'field_my_other_field') {
return AccessResult::forbidden();
}
return AccessResult::neutral();
}
}
Both methods are correct, and both fields are meant to be restricted — only the field in the last declared hook implementation actually is. Swapping the two methods' declaration order and clearing the caches changes which field is restricted.
Proposed resolution
hook_entity_field_access_alter()'s own documented example relies on the module-keyed shape ($grants['node']->isForbidden()), so the fix needs to preserve that contract rather than drop keying entirely (e.g. switching to plain invokeAll() like the sibling access() method uses would break that documented use case). Instead, accumulate directly into one keyed array, combining same-module results with orIf() as they arrive, rather than building throwaway single-key arrays to array_merge() afterward:
$grants = [':default' => $default];
$this->moduleHandler()->invokeAllWith(
'entity_field_access',
function (callable $hook, string $module) use ($operation, $field_definition, $account, $items, &$grants) {
$result = $hook($operation, $field_definition, $account, $items);
$grants[$module] = isset($grants[$module]) ? $grants[$module]->orIf($result) : $result;
}
);
No array_merge() needed. $grants['node'] still means what the _alter docs say it means — now correctly combined across all of a module's implementations instead of only the last one. alter() and processAccessHookResults() downstream are untouched: same array shape, same keys, only the accumulation logic changes.
Remaining tasks
- A pull request with the above code change
- Tests
User interface changes
None
Introduced terminology
None
API changes
None
Data model changes
None
Release notes snippet
Issue fork drupal-3614667
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
miwayha commentedComment #4
nicxvan commentedYeah this looks like a side effect of how it was looping through.
The fix looks right to me on it's face, but since it's access we should get a quick look by the subsystem maintainers.
I'm not sure if we have to worry about changing where :defaults is set since now the hooks can clobber that where they couldn't before.
For process here now that I've added the tag we wait a week or so to give the subsystem maintainers a chance to review, if they don't reply in a week you can ping them in slack in the #core-development channel: the maintainers can be found here: https://git.drupalcode.org/project/drupal/-/blob/main/core/MAINTAINERS.t...
Comment #5
larowlanLeft some comments on the MR - thanks for filing this.
Comment #6
miwayha commentedThe comments in #5 were really good -- I think addressing them made the tests stronger. Thank you.
Comment #7
miwayha commented@larowlan is would it be possible for you to take another look at this one at your leisure? I believe I resolved your feedback on the PR, but I'd be happy to keep working on it if you have more feedback