summaryrefslogtreecommitdiffstatshomepage
path: root/core
diff options
context:
space:
mode:
authorLee Rowlands <lee.rowlands@previousnext.com.au>2025-04-29 10:09:45 +1000
committerLee Rowlands <lee.rowlands@previousnext.com.au>2025-04-29 10:09:45 +1000
commit48c71f0598fe8775f08aab8a6862daed7271d850 (patch)
tree34d940e79d5cfbfc64f85c587ea9f31efc31aaa0 /core
parent7034691e25541cebce2255135ee9ef725634a153 (diff)
downloaddrupal-48c71f0598fe8775f08aab8a6862daed7271d850.tar.gz
drupal-48c71f0598fe8775f08aab8a6862daed7271d850.zip
Issue #3485896 by nicxvan, donquixote, ghost of drupal past, amateescu, godotislate, quietone, berdir, larowlan, catch: Hook ordering across OOP, procedural and with extra types i.e replace hook_module_implements_alter
Diffstat (limited to 'core')
-rw-r--r--core/core.api.php93
-rw-r--r--core/lib/Drupal/Core/Extension/ModuleHandler.php275
-rw-r--r--core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php2
-rw-r--r--core/lib/Drupal/Core/Extension/module.api.php6
-rw-r--r--core/lib/Drupal/Core/Hook/Attribute/Hook.php29
-rw-r--r--core/lib/Drupal/Core/Hook/Attribute/HookAttributeInterface.php15
-rw-r--r--core/lib/Drupal/Core/Hook/Attribute/LegacyModuleImplementsAlter.php22
-rw-r--r--core/lib/Drupal/Core/Hook/Attribute/RemoveHook.php33
-rw-r--r--core/lib/Drupal/Core/Hook/Attribute/ReorderHook.php40
-rw-r--r--core/lib/Drupal/Core/Hook/HookCollectorPass.php481
-rw-r--r--core/lib/Drupal/Core/Hook/Order/Order.php28
-rw-r--r--core/lib/Drupal/Core/Hook/Order/OrderAfter.php19
-rw-r--r--core/lib/Drupal/Core/Hook/Order/OrderBefore.php19
-rw-r--r--core/lib/Drupal/Core/Hook/Order/OrderInterface.php42
-rw-r--r--core/lib/Drupal/Core/Hook/Order/RelativeOrderBase.php56
-rw-r--r--core/lib/Drupal/Core/Hook/OrderOperation/BeforeOrAfter.php81
-rw-r--r--core/lib/Drupal/Core/Hook/OrderOperation/FirstOrLast.php48
-rw-r--r--core/lib/Drupal/Core/Hook/OrderOperation/OrderOperation.php55
-rw-r--r--core/modules/ckeditor5/ckeditor5.module25
-rw-r--r--core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php14
-rw-r--r--core/modules/content_translation/content_translation.module22
-rw-r--r--core/modules/content_translation/src/Hook/ContentTranslationHooks.php5
-rw-r--r--core/modules/layout_builder/layout_builder.module14
-rw-r--r--core/modules/layout_builder/src/Hook/LayoutBuilderHooks.php3
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module14
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php8
-rw-r--r--core/modules/navigation/navigation.module15
-rw-r--r--core/modules/navigation/src/Hook/NavigationHooks.php6
-rw-r--r--core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/aaa_hook_collector_test.info.yml7
-rw-r--r--core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookAfter.php27
-rw-r--r--core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookAfterClassMethod.php32
-rw-r--r--core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookBefore.php27
-rw-r--r--core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookFirst.php27
-rw-r--r--core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookLast.php28
-rw-r--r--core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookOrderExtraTypes.php32
-rw-r--r--core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookReorderHookFirst.php40
-rw-r--r--core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/bbb_hook_collector_test.info.yml7
-rw-r--r--core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookAfter.php27
-rw-r--r--core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookAfterClassMethod.php27
-rw-r--r--core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookBefore.php27
-rw-r--r--core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookFirst.php27
-rw-r--r--core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookLast.php26
-rw-r--r--core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookOrderExtraTypes.php26
-rw-r--r--core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookReorderHookLast.php31
-rw-r--r--core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/aaa_hook_order_test.info.yml6
-rw-r--r--core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/aaa_hook_order_test.module45
-rw-r--r--core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/AAlterHooks.php29
-rw-r--r--core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/AHooks.php52
-rw-r--r--core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/ModuleImplementsAlter.php47
-rw-r--r--core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/bbb_hook_order_test.info.yml6
-rw-r--r--core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/bbb_hook_order_test.module29
-rw-r--r--core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/src/Hook/BAlterHooks.php23
-rw-r--r--core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/src/Hook/BHooks.php33
-rw-r--r--core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/ccc_hook_order_test.info.yml6
-rw-r--r--core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/ccc_hook_order_test.module36
-rw-r--r--core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/src/Hook/CAlterHooks.php28
-rw-r--r--core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/src/Hook/CHooks.php54
-rw-r--r--core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/ddd_hook_order_test.info.yml6
-rw-r--r--core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/ddd_hook_order_test.module15
-rw-r--r--core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/src/Hook/DAlterHooks.php27
-rw-r--r--core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/src/Hook/DHooks.php33
-rw-r--r--core/modules/system/tests/modules/common_test/common_test.module18
-rw-r--r--core/modules/system/tests/modules/hook_test_remove/hook_test_remove.info.yml7
-rw-r--r--core/modules/system/tests/modules/hook_test_remove/src/Hook/TestHookRemove.php38
-rw-r--r--core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.implementations.inc (renamed from core/modules/system/tests/modules/module_test/module_test.implementations.inc)4
-rw-r--r--core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.info.yml6
-rw-r--r--core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.module41
-rw-r--r--core/modules/system/tests/modules/module_test/module_test.module20
-rw-r--r--core/modules/system/tests/src/Kernel/Common/AlterTest.php3
-rw-r--r--core/modules/workspaces/src/Hook/EntityOperations.php13
-rw-r--r--core/modules/workspaces/workspaces.module35
-rw-r--r--core/tests/Drupal/KernelTests/Core/Extension/ModuleImplementsAlterTest.php42
-rw-r--r--core/tests/Drupal/KernelTests/Core/Hook/HookAlterOrderTest.php232
-rw-r--r--core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php166
-rw-r--r--core/tests/Drupal/KernelTests/Core/Hook/HookOrderTest.php94
-rw-r--r--core/tests/Drupal/KernelTests/Core/Hook/HookOrderTestTrait.php56
-rw-r--r--core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php6
-rw-r--r--core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.info.yml6
-rw-r--r--core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.module20
-rw-r--r--core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php32
80 files changed, 2773 insertions, 429 deletions
diff --git a/core/core.api.php b/core/core.api.php
index 0500a3c58ae..e93fa0bb5b5 100644
--- a/core/core.api.php
+++ b/core/core.api.php
@@ -1606,6 +1606,14 @@
* modules that they interact with. Your modules can also define their own
* hooks, in order to let other modules interact with them.
*
+ * Hook implementations will execute in the following order.
+ * order.
+ * - Module weight.
+ * - Alphabetical by module name.
+ * - This order can be modified by using the order parameter on the #[Hook]
+ * attribute, using the #[ReorderHook] attribute, or implementing the legacy
+ * hook_module_implements_alter.
+ *
* @section implementing Implementing a hook
*
* There are two ways to implement a hook:
@@ -1657,6 +1665,7 @@
* Legacy meta hooks:
* - hook_hook_info()
* - hook_module_implements_alter()
+ * @see \Drupal\Core\Hook\Attribute\LegacyModuleImplementsAlter
*
* Install hooks:
* - hook_install()
@@ -1709,6 +1718,90 @@
* @see \Drupal\Core\Hook\Attribute\Hook
* @see \Drupal::moduleHandler()
*
+ * @section ordering_hooks Ordering hook implementations
+ *
+ * The order in which hook implementations are executed can be modified. A hook
+ * can be placed first or last in the order of execution. It can also be placed
+ * before or after the execution of another module's implementation of the same
+ * hook. When changing the order of execution in relation to a specific module
+ * either the module name or the class and method can be used.
+ *
+ * Use the order argument of the Hook attribute to order the execution of
+ * hooks.
+ *
+ * Example of executing 'entity_type_alter' of my_module first:
+ * @code
+ * #[Hook('entity_type_alter', order: Order::First)]
+ * @endcode
+ *
+ * Example of executing 'entity_type_alter' of my_module last:
+ * @code
+ * #[Hook('entity_type_alter', order: Order::Last)]
+ * @endcode
+ *
+ * Example of executing 'entity_type_alter' before the execution of the
+ * implementation in the foo module:
+ * @code
+ * #[Hook('entity_type_alter', order: new OrderBefore(['foo']))]
+ * @endcode
+ *
+ * Example of executing 'entity_type_alter' after the execution of the
+ * implementation in the foo module:
+ * @code
+ * #[Hook('entity_type_alter', order: new OrderAfter(['foo']))]
+ * @endcode
+ *
+ * Example of executing 'entity_type_alter' before two methods. One in the Foo
+ * class and one in the Bar class.
+ * @code
+ * #[Hook('entity_type_alter',
+ * order: new OrderBefore(
+ * classesAndMethods: [
+ * [Foo::class, 'someMethod'],
+ * [Bar::class, 'someOtherMethod'],
+ * ]
+ * )
+ * )]
+ * @endcode
+ *
+ * @see \Drupal\Core\Hook\Attribute\Hook
+ * @see \Drupal\Core\Hook\Order\Order
+ * @see \Drupal\Core\Hook\Order\OrderBefore
+ * @see \Drupal\Core\Hook\Order\OrderAfter
+ *
+ * @section ordering_other_module_hooks Ordering other module hook implementations
+ *
+ * The order in which hooks implemented in other modules are executed can be
+ * reordered. The reordering of the targeted hook is done relative to other
+ * implementations. The reordering process executes after the ordering defined
+ * in the Hook attribute.
+ *
+ * Example of reordering the execution of the 'entity_presave' hook so that
+ * Content Moderation module hook executes before the Workspaces module hook.
+ * @code
+ * #[ReorderHook('entity_presave',
+ * class: ContentModerationHooks::class,
+ * method: 'entityPresave',
+ * order: new OrderBefore(['workspaces'])
+ * )]
+ * @endcode
+ *
+ * @see \Drupal\Core\Hook\Attribute\ReorderHook
+ *
+ * @section removing_hooks Removing hook implementations
+ *
+ * The execution of a hooks implemented by other modules can be skipped. This
+ * is done by removing the targeted hook, use the RemoveHook attribute.
+ *
+ * Example of removing the 'help' hook of the Layout Builder module.
+ * @code
+ * #[RemoveHook('help',
+ * class: LayoutBuilderHooks::class,
+ * method: 'help'
+ * )]
+ * @endcode
+ *
+ * @see \Drupal\Core\Hook\Attribute\RemoveHook
* @}
*/
diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php
index 1f9ff1cb0f6..53cf3c95aa5 100644
--- a/core/lib/Drupal/Core/Extension/ModuleHandler.php
+++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php
@@ -3,10 +3,12 @@
namespace Drupal\Core\Extension;
use Drupal\Component\Graph\Graph;
+use Drupal\Component\Render\FormattableMarkup;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Extension\Exception\UnknownExtensionException;
use Drupal\Core\Hook\Attribute\LegacyHook;
use Drupal\Core\Hook\HookCollectorPass;
+use Drupal\Core\Hook\OrderOperation\OrderOperation;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
@@ -59,32 +61,65 @@ class ModuleHandler implements ModuleHandlerInterface {
protected $includeFileKeys = [];
/**
+ * Lists of implementation callables by hook.
+ *
+ * @var array<string, list<callable>>
+ */
+ protected array $listenersByHook = [];
+
+ /**
+ * Lists of module names by hook.
+ *
+ * The indices are exactly the same as in $listenersByHook.
+ *
+ * @var array<string, list<string>>
+ */
+ protected array $modulesByHook = [];
+
+ /**
* Hook and module keyed list of listeners.
*
- * @var array
+ * @var array<string, array<string, list<callable>>>
*/
protected array $invokeMap = [];
/**
+ * Ordering rules by hook name.
+ *
+ * @var array<string, list<\Drupal\Core\Hook\OrderOperation\OrderOperation>>
+ */
+ protected array $orderingRules = [];
+
+ /**
* Constructs a ModuleHandler object.
*
* @param string $root
* The app root.
- * @param array $module_list
+ * @param array<string, array{type: string, pathname: string, filename: string}> $module_list
* An associative array whose keys are the names of installed modules and
* whose values are Extension class parameters. This is normally the
* %container.modules% parameter being set up by DrupalKernel.
* @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher
* The event dispatcher.
- * @param array $hookImplementationsMap
+ * @param array<string, array<class-string, array<string, string>>> $hookImplementationsMap
* An array keyed by hook, classname, method and the value is the module.
- * @param array $groupIncludes
- * An array of .inc files to get helpers from.
+ * @param array<string, list<string>> $groupIncludes
+ * Lists of *.inc file paths that contain procedural implementations, keyed
+ * by hook name.
+ * @param array<string, list<string>> $packedOrderOperations
+ * Ordering rules by hook name, serialized.
*
* @see \Drupal\Core\DrupalKernel
* @see \Drupal\Core\CoreServiceProvider
*/
- public function __construct($root, array $module_list, protected EventDispatcherInterface $eventDispatcher, protected array $hookImplementationsMap, protected array $groupIncludes = []) {
+ public function __construct(
+ $root,
+ array $module_list,
+ protected EventDispatcherInterface $eventDispatcher,
+ protected array $hookImplementationsMap,
+ protected array $groupIncludes = [],
+ protected array $packedOrderOperations = [],
+ ) {
$this->root = $root;
$this->moduleList = [];
foreach ($module_list as $name => $module) {
@@ -211,6 +246,8 @@ class ModuleHandler implements ModuleHandlerInterface {
foreach ($hook_collector->getImplementations() as $hook => $moduleImplements) {
foreach ($moduleImplements as $module => $classImplements) {
foreach ($classImplements[ProceduralCall::class] ?? [] as $method) {
+ $this->listenersByHook[$hook][] = $method;
+ $this->modulesByHook[$hook][] = $module;
$this->invokeMap[$hook][$module][] = $method;
}
}
@@ -310,10 +347,9 @@ class ModuleHandler implements ModuleHandlerInterface {
* {@inheritdoc}
*/
public function invokeAllWith(string $hook, callable $callback): void {
- foreach ($this->getHookListeners($hook) as $module => $listeners) {
- foreach ($listeners as $listener) {
- $callback($listener, $module);
- }
+ foreach ($this->getFlatHookListeners($hook) as $index => $listener) {
+ $module = $this->modulesByHook[$hook][$index];
+ $callback($listener, $module);
}
}
@@ -415,14 +451,6 @@ class ModuleHandler implements ModuleHandlerInterface {
// specific variants of it, as in the case of ['form', 'form_FORM_ID'].
if (is_array($type)) {
$cid = implode(',', $type);
- $extra_types = $type;
- $type = array_shift($extra_types);
- // Allow if statements in this function to use the faster isset() rather
- // than !empty() both when $type is passed as a string, or as an array
- // with one item.
- if (empty($extra_types)) {
- unset($extra_types);
- }
}
else {
$cid = $type;
@@ -432,40 +460,160 @@ class ModuleHandler implements ModuleHandlerInterface {
// list of functions to call, and on subsequent calls, iterate through them
// quickly.
if (!isset($this->alterEventListeners[$cid])) {
- $this->alterEventListeners[$cid] = [];
- $hook = $type . '_alter';
- $hook_listeners = $this->getHookListeners($hook);
- if (isset($extra_types)) {
- // For multiple hooks, we need $modules to contain every module that
- // implements at least one of them in the correct order.
- foreach ($extra_types as $extra_type) {
- foreach ($this->getHookListeners($extra_type . '_alter') as $module => $listeners) {
- if (isset($hook_listeners[$module])) {
- $hook_listeners[$module] = array_merge($hook_listeners[$module], $listeners);
- }
- else {
- $hook_listeners[$module] = $listeners;
- $extra_modules = TRUE;
- }
- }
+ $hooks = is_array($type)
+ ? array_map(static fn (string $type) => $type . '_alter', $type)
+ : [$type . '_alter'];
+ $this->alterEventListeners[$cid] = $this->getCombinedListeners($hooks);
+ }
+ foreach ($this->alterEventListeners[$cid] as $listener) {
+ $listener($data, $context1, $context2);
+ }
+ }
+
+ /**
+ * Builds a list of listeners for an alter hook.
+ *
+ * @param list<string> $hooks
+ * The hooks passed to the ->alter() call.
+ *
+ * @return list<callable>
+ * List of implementation callables.
+ */
+ protected function getCombinedListeners(array $hooks): array {
+ // Get implementation lists for each hook.
+ $listener_lists = array_map($this->getFlatHookListeners(...), $hooks);
+ // Remove empty lists.
+ $listener_lists = array_filter($listener_lists);
+ if (!$listener_lists) {
+ // No implementations exist.
+ return [];
+ }
+ if (array_keys($listener_lists) === [0]) {
+ // Only the first hook has implementations.
+ return $listener_lists[0];
+ }
+ // Collect the lists from each hook and group the listeners by module.
+ $listeners_by_identifier = [];
+ $modules_by_identifier = [];
+ $identifiers_by_module = [];
+ foreach ($listener_lists as $i_hook => $listeners) {
+ $hook = $hooks[$i_hook];
+ foreach ($listeners as $i_listener => $listener) {
+ $module = $this->modulesByHook[$hook][$i_listener];
+ $identifier = is_array($listener)
+ ? get_class($listener[0]) . '::' . $listener[1]
+ : ProceduralCall::class . '::' . $listener;
+ $other_module = $modules_by_identifier[$identifier] ?? NULL;
+ if ($other_module !== NULL) {
+ $this->triggerErrorForDuplicateAlterHookListener(
+ $hooks,
+ $module,
+ $other_module,
+ $listener,
+ $identifier,
+ );
+ // Don't add the same listener more than once.
+ continue;
}
+ $listeners_by_identifier[$identifier] = $listener;
+ $modules_by_identifier[$identifier] = $module;
+ $identifiers_by_module[$module][] = $identifier;
}
- // If any modules implement one of the extra hooks that do not implement
- // the primary hook, we need to add them to the $modules array in their
- // appropriate order.
- $modules = array_keys($hook_listeners);
- if (isset($extra_modules)) {
- $modules = $this->reOrderModulesForAlter($modules, $hook);
- }
- foreach ($modules as $module) {
- foreach ($hook_listeners[$module] ?? [] as $listener) {
- $this->alterEventListeners[$cid][] = $listener;
- }
+ }
+ // First we get the the modules in moduleList order, this order is module
+ // weight then alphabetical. Then we apply legacy ordering using
+ // hook_module_implements_alter(). Finally we order using order attributes.
+ $modules = array_keys($identifiers_by_module);
+ $modules = $this->reOrderModulesForAlter($modules, $hooks[0]);
+ // Create a flat list of identifiers, using the new module order.
+ $identifiers = array_merge(...array_map(
+ fn (string $module) => $identifiers_by_module[$module],
+ $modules,
+ ));
+ foreach ($hooks as $hook) {
+ foreach ($this->getHookOrderingRules($hook) as $rule) {
+ $rule->apply($identifiers, $modules_by_identifier);
+ // Order operations must not:
+ // - Insert duplicate keys.
+ // - Change the array to be not a list.
+ // - Add or remove values.
+ assert($identifiers === array_unique($identifiers));
+ assert(array_is_list($identifiers));
+ assert(!array_diff($identifiers, array_keys($modules_by_identifier)));
+ assert(!array_diff(array_keys($modules_by_identifier), $identifiers));
}
}
- foreach ($this->alterEventListeners[$cid] as $listener) {
- $listener($data, $context1, $context2);
+ return array_map(
+ static fn (string $identifier) => $listeners_by_identifier[$identifier],
+ $identifiers,
+ );
+ }
+
+ /**
+ * Triggers an error on duplicate alter listeners.
+ *
+ * This is called when the same method is registered for multiple hooks, which
+ * are now part of the same alter call.
+ *
+ * @param list<string> $hooks
+ * Hook names from the ->alter() call.
+ * @param string $module
+ * The module name for one of the hook implementations.
+ * @param string $other_module
+ * The module name for another hook implementation.
+ * @param callable $listener
+ * The hook listener.
+ * @param string $identifier
+ * String identifier of the hook listener.
+ */
+ protected function triggerErrorForDuplicateAlterHookListener(array $hooks, string $module, string $other_module, callable $listener, string $identifier): void {
+ $log_message_replacements = [
+ '@implementation' => is_array($listener)
+ ? ('method ' . $identifier . '()')
+ : ('function ' . $listener[1] . '()'),
+ '@hooks' => "['" . implode("', '", $hooks) . "']",
+ ];
+ if ($other_module !== $module) {
+ // There is conflicting information about which module this
+ // implementation is registered for. At this point we cannot even
+ // be sure if the module is the one from the main hook or the extra
+ // hook. This means that ordering may not work as expected and it is
+ // unclear if the intention is to execute the code multiple times. This
+ // can be resolved by using a separate method for alter hooks that
+ // implement on behalf of other modules.
+ trigger_error((string) new FormattableMarkup(
+ 'The @implementation is registered for more than one of the alter hooks @hooks from the current ->alter() call, on behalf of different modules @module and @other_module. Only one instance will be part of the implementation list for this hook combination. For the purpose of ordering, the module @module will be used.',
+ [
+ ...$log_message_replacements,
+ '@module' => "'$module'",
+ '@other_module' => "'$other_module'",
+ ],
+ ), E_USER_WARNING);
}
+ else {
+ // There is no conflict, but probably one or more redundant #[Hook]
+ // attributes should be removed.
+ trigger_error((string) new FormattableMarkup(
+ 'The @implementation is registered for more than one of the alter hooks @hooks from the current ->alter() call. Only one instance will be part of the implementation list for this hook combination.',
+ $log_message_replacements,
+ ), E_USER_NOTICE);
+ }
+ }
+
+ /**
+ * Gets ordering rules for a hook.
+ *
+ * @param string $hook
+ * Hook name.
+ *
+ * @return list<\Drupal\Core\Hook\OrderOperation\OrderOperation>
+ * List of order operations for the hook.
+ */
+ protected function getHookOrderingRules(string $hook): array {
+ return $this->orderingRules[$hook] ??= array_map(
+ OrderOperation::unpack(...),
+ $this->packedOrderOperations[$hook] ?? [],
+ );
}
/**
@@ -549,14 +697,39 @@ class ModuleHandler implements ModuleHandlerInterface {
}
/**
+ * Gets hook listeners by module.
+ *
* @param string $hook
* The name of the hook.
*
- * @return array
+ * @return array<string, list<callable>>
* A list of event listeners implementing this hook.
*/
protected function getHookListeners(string $hook): array {
if (!isset($this->invokeMap[$hook])) {
+ $this->invokeMap[$hook] = [];
+ foreach ($this->getFlatHookListeners($hook) as $index => $listener) {
+ $module = $this->modulesByHook[$hook][$index];
+ $this->invokeMap[$hook][$module][] = $listener;
+ }
+ }
+
+ return $this->invokeMap[$hook] ?? [];
+ }
+
+ /**
+ * Gets a list of hook listener callbacks.
+ *
+ * @param string $hook
+ * The hook name.
+ *
+ * @return list<callable>
+ * A list of hook implementation callables.
+ *
+ * @internal
+ */
+ protected function getFlatHookListeners(string $hook): array {
+ if (!isset($this->listenersByHook[$hook])) {
foreach ($this->eventDispatcher->getListeners("drupal_hook.$hook") as $listener) {
if (is_array($listener) && is_object($listener[0])) {
$module = $this->hookImplementationsMap[$hook][get_class($listener[0])][$listener[1]];
@@ -569,7 +742,8 @@ class ModuleHandler implements ModuleHandlerInterface {
$callable = $listener;
}
if (isset($this->moduleList[$module])) {
- $this->invokeMap[$hook][$module][] = $callable;
+ $this->listenersByHook[$hook][] = $callable;
+ $this->modulesByHook[$hook][] = $module;
}
}
}
@@ -580,7 +754,8 @@ class ModuleHandler implements ModuleHandlerInterface {
}
}
}
- return $this->invokeMap[$hook] ?? [];
+
+ return $this->listenersByHook[$hook] ?? [];
}
}
diff --git a/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php b/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php
index 597da739a14..529fd7275a8 100644
--- a/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php
+++ b/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php
@@ -225,7 +225,7 @@ interface ModuleHandlerInterface {
*
* @param string $hook
* The name of the hook to invoke.
- * @param callable $callback
+ * @param callable(callable, string): mixed $callback
* A callable that invokes a hook implementation. Such that
* $callback is callable(callable, string): mixed.
* Arguments:
diff --git a/core/lib/Drupal/Core/Extension/module.api.php b/core/lib/Drupal/Core/Extension/module.api.php
index 325335615ab..40cd1824106 100644
--- a/core/lib/Drupal/Core/Extension/module.api.php
+++ b/core/lib/Drupal/Core/Extension/module.api.php
@@ -94,6 +94,10 @@ function hook_hook_info(): array {
/**
* Alter the registry of modules implementing a hook.
*
+ * This hook will be removed in 12.0.0. It is not deprecated in order to
+ * support the "#[LegacyModuleImplementsAlter]" attribute, used for
+ * compatibility with versions prior to Drupal 11.2.0.
+ *
* Only procedural implementations are supported for this hook.
*
* This hook is invoked in \Drupal::moduleHandler()->getImplementationInfo().
@@ -115,6 +119,8 @@ function hook_hook_info(): array {
* file named $module.$group.inc.
* @param string $hook
* The name of the module hook being implemented.
+ *
+ * @see \Drupal\Core\Hook\Attribute\LegacyModuleImplementsAlter
*/
function hook_module_implements_alter(&$implementations, $hook) {
if ($hook == 'form_alter') {
diff --git a/core/lib/Drupal/Core/Hook/Attribute/Hook.php b/core/lib/Drupal/Core/Hook/Attribute/Hook.php
index 1b220577a13..33da9558b51 100644
--- a/core/lib/Drupal/Core/Hook/Attribute/Hook.php
+++ b/core/lib/Drupal/Core/Hook/Attribute/Hook.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Drupal\Core\Hook\Attribute;
+use Drupal\Core\Hook\Order\OrderInterface;
+
/**
* Attribute for defining a class method as a hook implementation.
*
@@ -30,8 +32,14 @@ namespace Drupal\Core\Hook\Attribute;
* }
* @endcode
*
- * Ordering hook implementations can be done by implementing
- * hook_module_implements_alter.
+ * Ordering hook implementations can be done by using the order parameter.
+ * See Drupal\Core\Hook\Order\OrderInterface for more information.
+ *
+ * Removing hook implementations can be done by using the attribute
+ * \Drupal\Core\Hook\Attribute\RemoveHook.
+ *
+ * Ordering hook implementations in other modules can be done by using the
+ * attribute \Drupal\Core\Hook\Attribute\ReorderHook.
*
* Classes that use this annotation on the class or on their methods are
* automatically registered as autowired services with the class name as the
@@ -88,7 +96,7 @@ namespace Drupal\Core\Hook\Attribute;
* See \Drupal\Core\Hook\Attribute\LegacyHook for additional information.
*/
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
-class Hook {
+class Hook implements HookAttributeInterface {
/**
* Constructs a Hook attribute object.
@@ -104,23 +112,14 @@ class Hook {
* (optional) The module this implementation is for. This allows one module
* to implement a hook on behalf of another module. Defaults to the module
* the implementation is in.
+ * @param \Drupal\Core\Hook\Order\OrderInterface|null $order
+ * (optional) Set the order of the implementation.
*/
public function __construct(
public string $hook,
public string $method = '',
public ?string $module = NULL,
+ public OrderInterface|null $order = NULL,
) {}
- /**
- * Set the method the hook should apply to.
- *
- * @param string $method
- * The method that the hook attribute applies to.
- * This only needs to be set when the attribute is on the class.
- */
- public function setMethod(string $method): static {
- $this->method = $method;
- return $this;
- }
-
}
diff --git a/core/lib/Drupal/Core/Hook/Attribute/HookAttributeInterface.php b/core/lib/Drupal/Core/Hook/Attribute/HookAttributeInterface.php
new file mode 100644
index 00000000000..8a2f2413b20
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Attribute/HookAttributeInterface.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook\Attribute;
+
+/**
+ * Common interface for attributes used for hook discovery.
+ *
+ * This does not imply any shared behavior, it is only used to collect all
+ * hook-related attributes in the same call.
+ *
+ * @internal
+ */
+interface HookAttributeInterface {}
diff --git a/core/lib/Drupal/Core/Hook/Attribute/LegacyModuleImplementsAlter.php b/core/lib/Drupal/Core/Hook/Attribute/LegacyModuleImplementsAlter.php
new file mode 100644
index 00000000000..d74f3cbd506
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Attribute/LegacyModuleImplementsAlter.php
@@ -0,0 +1,22 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook\Attribute;
+
+/**
+ * Prevents procedural hook_module_implements_alter from executing.
+ *
+ * This allows the use of the legacy hook_module_implements_alter alongside
+ * attribute-based ordering. Providing support for versions of Drupal older
+ * than 11.2.0.
+ *
+ * Marking hook_module_implements_alter as #LegacyModuleImplementsAlter will
+ * prevent hook_module_implements_alter from running when attribute-based
+ * ordering is available.
+ *
+ * On older versions of Drupal which are not aware of attribute-based ordering,
+ * only the legacy hook implementation is executed.
+ */
+#[\Attribute(\Attribute::TARGET_FUNCTION)]
+class LegacyModuleImplementsAlter {}
diff --git a/core/lib/Drupal/Core/Hook/Attribute/RemoveHook.php b/core/lib/Drupal/Core/Hook/Attribute/RemoveHook.php
new file mode 100644
index 00000000000..28d4c9456ca
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Attribute/RemoveHook.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook\Attribute;
+
+/**
+ * Removes an already existing implementation.
+ *
+ * The effect of this attribute is independent from the specific class or method
+ * on which it is placed.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class RemoveHook implements HookAttributeInterface {
+
+ /**
+ * Constructs a RemoveHook object.
+ *
+ * @param string $hook
+ * The hook name from which to remove the target implementation.
+ * @param class-string $class
+ * The class name of the target hook implementation.
+ * @param string $method
+ * The method name of the target hook implementation.
+ * If the class instance itself is the listener, this should be '__invoke'.
+ */
+ public function __construct(
+ public readonly string $hook,
+ public readonly string $class,
+ public readonly string $method,
+ ) {}
+
+}
diff --git a/core/lib/Drupal/Core/Hook/Attribute/ReorderHook.php b/core/lib/Drupal/Core/Hook/Attribute/ReorderHook.php
new file mode 100644
index 00000000000..920552ec448
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Attribute/ReorderHook.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook\Attribute;
+
+use Drupal\Core\Hook\Order\OrderInterface;
+
+/**
+ * Sets the order of an already existing implementation.
+ *
+ * The effect of this attribute is independent from the specific class or method
+ * on which it is placed.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class ReorderHook implements HookAttributeInterface {
+
+ /**
+ * Constructs a ReorderHook object.
+ *
+ * @param string $hook
+ * The hook for which to reorder an implementation.
+ * @param class-string $class
+ * The class of the targeted hook implementation.
+ * @param string $method
+ * The method name of the targeted hook implementation.
+ * If the #[Hook] attribute is on the class itself, this should be
+ * '__invoke'.
+ * @param \Drupal\Core\Hook\Order\OrderInterface $order
+ * Specifies a new position for the targeted hook implementation relative to
+ * other implementations.
+ */
+ public function __construct(
+ public string $hook,
+ public string $class,
+ public string $method,
+ public OrderInterface $order,
+ ) {}
+
+}
diff --git a/core/lib/Drupal/Core/Hook/HookCollectorPass.php b/core/lib/Drupal/Core/Hook/HookCollectorPass.php
index 3809e24af21..3fe2a6d2830 100644
--- a/core/lib/Drupal/Core/Hook/HookCollectorPass.php
+++ b/core/lib/Drupal/Core/Hook/HookCollectorPass.php
@@ -9,8 +9,13 @@ use Drupal\Component\Annotation\Reflection\MockFileFinder;
use Drupal\Component\FileCache\FileCacheFactory;
use Drupal\Core\Extension\ProceduralCall;
use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Attribute\HookAttributeInterface;
use Drupal\Core\Hook\Attribute\LegacyHook;
+use Drupal\Core\Hook\Attribute\LegacyModuleImplementsAlter;
+use Drupal\Core\Hook\Attribute\RemoveHook;
+use Drupal\Core\Hook\Attribute\ReorderHook;
use Drupal\Core\Hook\Attribute\StopProceduralHookScan;
+use Drupal\Core\Hook\OrderOperation\OrderOperation;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -26,29 +31,50 @@ use Symfony\Component\DependencyInjection\ContainerBuilder;
*
* Finally, a hook_implementations_map container parameter is added. This
* contains a mapping from [hook,class,method] to the module name.
+ *
+ * @internal
*/
class HookCollectorPass implements CompilerPassInterface {
/**
- * An associative array of hook implementations.
+ * OOP implementation module names keyed by hook name and "$class::$method".
+ *
+ * @var array<string, array<string, string>>
+ */
+ protected array $oopImplementations = [];
+
+ /**
+ * Procedural implementation module names by hook name.
*
- * Keys are hook, module, class. Values are a list of methods.
+ * @var array<string, list<string>>
*/
- protected array $implementations = [];
+ protected array $proceduralImplementations = [];
/**
- * An associative array of hook implementations.
+ * Order operations grouped by hook name and weight.
*
- * Keys are hook, module and an empty string value.
+ * Operations with higher weight are applied last, which means they can
+ * override the changes from previous operations.
*
- * @see hook_module_implements_alter()
+ * @var array<string, array<int, list<\Drupal\Core\Hook\OrderOperation\OrderOperation>>>
+ *
+ * @todo Review how to combine operations from different hooks.
*/
- protected array $moduleImplements = [];
+ protected array $orderOperations = [];
/**
- * A list of include files.
+ * Identifiers to remove, as "$class::$method", keyed by hook name.
+ *
+ * @var array<string, list<string>>
+ */
+ protected array $removeHookIdentifiers = [];
+
+ /**
+ * A map of include files by function name.
*
* (This is required only for BC.)
+ *
+ * @var array<string, string>
*/
protected array $includes = [];
@@ -56,6 +82,8 @@ class HookCollectorPass implements CompilerPassInterface {
* A list of functions implementing hook_module_implements_alter().
*
* (This is required only for BC.)
+ *
+ * @var list<callable-string>
*/
protected array $moduleImplementsAlters = [];
@@ -63,69 +91,250 @@ class HookCollectorPass implements CompilerPassInterface {
* A list of functions implementing hook_hook_info().
*
* (This is required only for BC.)
+ *
+ * @var list<callable-string>
*/
private array $hookInfo = [];
/**
- * A list of .inc files.
+ * Include files, keyed by the $group part of "/$module.$group.inc".
+ *
+ * @var array<string, list<string>>
*/
private array $groupIncludes = [];
/**
+ * Constructor.
+ *
+ * @param list<string> $modules
+ * Names of installed modules.
+ * When used as a compiler pass, this parameter should be omitted.
+ */
+ public function __construct(
+ protected readonly array $modules = [],
+ ) {}
+
+ /**
* {@inheritdoc}
*/
public function process(ContainerBuilder $container): void {
- $collector = static::collectAllHookImplementations($container->getParameter('container.modules'), $container);
- $map = [];
+ $module_list = $container->getParameter('container.modules');
+ $parameters = $container->getParameterBag()->all();
+ $skip_procedural_modules = array_filter(
+ array_keys($module_list),
+ static fn (string $module) => !empty($parameters["$module.hooks_converted"]),
+ );
+ $collector = static::collectAllHookImplementations($module_list, $skip_procedural_modules);
+
+ $collector->writeToContainer($container);
+ }
+
+ /**
+ * Writes collected definitions to the container builder.
+ *
+ * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
+ * Container builder.
+ */
+ protected function writeToContainer(ContainerBuilder $container): void {
$container->register(ProceduralCall::class, ProceduralCall::class)
- ->addArgument($collector->includes);
+ ->addArgument($this->includes);
+
+ // Gather includes for each hook_hook_info group. Store this in
+ // $groupIncludes so the module handler includes the files at runtime when
+ // the hooks are invoked.
$groupIncludes = [];
- foreach ($collector->hookInfo as $function) {
+ foreach ($this->hookInfo as $function) {
foreach ($function() as $hook => $info) {
- if (isset($collector->groupIncludes[$info['group']])) {
- $groupIncludes[$hook] = $collector->groupIncludes[$info['group']];
+ if (isset($this->groupIncludes[$info['group']])) {
+ $groupIncludes[$hook] = $this->groupIncludes[$info['group']];
}
}
}
+
+ $implementationsByHook = $this->calculateImplementations();
+
+ static::writeImplementationsToContainer($container, $implementationsByHook);
+
+ // Update the module handler definition.
$definition = $container->getDefinition('module_handler');
$definition->setArgument('$groupIncludes', $groupIncludes);
- foreach ($collector->moduleImplements as $hook => $moduleImplements) {
- foreach ($collector->moduleImplementsAlters as $alter) {
+
+ $packed_order_operations = [];
+ $order_operations = $this->getOrderOperations();
+ foreach (preg_grep('@_alter$@', array_keys($order_operations)) as $alter_hook) {
+ $packed_order_operations[$alter_hook] = array_map(
+ fn (OrderOperation $operation) => $operation->pack(),
+ $order_operations[$alter_hook],
+ );
+ }
+ $definition->setArgument('$packedOrderOperations', $packed_order_operations);
+ }
+
+ /**
+ * Gets implementation lists with removals already applied.
+ *
+ * @return array<string, list<string>>
+ * Implementations, as module names keyed by hook name and
+ * "$class::$method".
+ */
+ protected function getFilteredImplementations(): array {
+ $implementationsByHook = [];
+ foreach ($this->proceduralImplementations as $hook => $procedural_modules) {
+ foreach ($procedural_modules as $module) {
+ $implementationsByHook[$hook][ProceduralCall::class . '::' . $module . '_' . $hook] = $module;
+ }
+ }
+ foreach ($this->oopImplementations as $hook => $oopImplementations) {
+ if (!isset($implementationsByHook[$hook])) {
+ $implementationsByHook[$hook] = $oopImplementations;
+ }
+ else {
+ $implementationsByHook[$hook] += $oopImplementations;
+ }
+ }
+ foreach ($this->removeHookIdentifiers as $hook => $identifiers_to_remove) {
+ foreach ($identifiers_to_remove as $identifier_to_remove) {
+ unset($implementationsByHook[$hook][$identifier_to_remove]);
+ }
+ if (empty($implementationsByHook[$hook])) {
+ unset($implementationsByHook[$hook]);
+ }
+ }
+ return $implementationsByHook;
+ }
+
+ /**
+ * Calculates the ordered implementations.
+ *
+ * @return array<string, array<string, string>>
+ * Implementations, as module names keyed by hook name and "$class::$method"
+ * identifier.
+ */
+ protected function calculateImplementations(): array {
+ $implementationsByHookOrig = $this->getFilteredImplementations();
+
+ // List of hooks and modules formatted for hook_module_implements_alter().
+ $moduleImplementsMap = [];
+ foreach ($implementationsByHookOrig as $hook => $hookImplementations) {
+ foreach (array_intersect($this->modules, $hookImplementations) as $module) {
+ $moduleImplementsMap[$hook][$module] = '';
+ }
+ }
+
+ $implementationsByHook = [];
+ foreach ($moduleImplementsMap as $hook => $moduleImplements) {
+ // Process all hook_module_implements_alter() for build time ordering.
+ foreach ($this->moduleImplementsAlters as $alter) {
$alter($moduleImplements, $hook);
}
- $priority = 0;
foreach ($moduleImplements as $module => $v) {
- foreach ($collector->implementations[$hook][$module] as $class => $method_hooks) {
- if ($container->has($class)) {
- $definition = $container->findDefinition($class);
- }
- else {
- $definition = $container
- ->register($class, $class)
- ->setAutowired(TRUE);
- }
- foreach ($method_hooks as $method) {
- $map[$hook][$class][$method] = $module;
- $definition->addTag('kernel.event_listener', [
- 'event' => "drupal_hook.$hook",
- 'method' => $method,
- 'priority' => $priority--,
- ]);
- }
+ foreach (array_keys($implementationsByHookOrig[$hook], $module, TRUE) as $identifier) {
+ $implementationsByHook[$hook][$identifier] = $module;
}
}
}
+
+ foreach ($this->getOrderOperations() as $hook => $order_operations) {
+ self::applyOrderOperations($implementationsByHook[$hook], $order_operations);
+ }
+
+ return $implementationsByHook;
+ }
+
+ /**
+ * Gets order operations by hook.
+ *
+ * @return array<string, list<\Drupal\Core\Hook\OrderOperation\OrderOperation>>
+ * Order operations by hook name.
+ */
+ protected function getOrderOperations(): array {
+ $operations_by_hook = [];
+ foreach ($this->orderOperations as $hook => $order_operations_by_weight) {
+ ksort($order_operations_by_weight);
+ $operations_by_hook[$hook] = array_merge(...$order_operations_by_weight);
+ }
+ return $operations_by_hook;
+ }
+
+ /**
+ * Applies order operations to a hook implementation list.
+ *
+ * @param array<string, string> $implementation_list
+ * Implementation list for one hook, as module names keyed by
+ * "$class::$method" identifiers.
+ * @param list<\Drupal\Core\Hook\OrderOperation\OrderOperation> $order_operations
+ * A list of order operations for one hook.
+ */
+ protected static function applyOrderOperations(array &$implementation_list, array $order_operations): void {
+ $module_finder = $implementation_list;
+ $identifiers = array_keys($module_finder);
+ foreach ($order_operations as $order_operation) {
+ $order_operation->apply($identifiers, $module_finder);
+ assert($identifiers === array_unique($identifiers));
+ assert(array_is_list($identifiers));
+ assert(!array_diff($identifiers, array_keys($module_finder)));
+ assert(!array_diff(array_keys($module_finder), $identifiers));
+ }
+ // Rebuild the identifier -> module array with the new order.
+ $identifiers = array_combine($identifiers, $identifiers);
+ $identifiers = array_intersect_key($identifiers, $module_finder);
+ $implementation_list = array_replace($identifiers, $module_finder);
+ }
+
+ /**
+ * Writes all implementations to the container.
+ *
+ * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container
+ * The container builder.
+ * @param array<string, array<string, string>> $implementationsByHook
+ * Implementations, as module names keyed by hook name and "$class::$method"
+ * identifier.
+ */
+ protected static function writeImplementationsToContainer(
+ ContainerBuilder $container,
+ array $implementationsByHook,
+ ): void {
+ $map = [];
+ $tagsInfoByClass = [];
+ foreach ($implementationsByHook as $hook => $hookImplementations) {
+ $priority = 0;
+ foreach ($hookImplementations as $class_and_method => $module) {
+ [$class, $method] = explode('::', $class_and_method);
+ $tagsInfoByClass[$class][] = [
+ 'event' => "drupal_hook.$hook",
+ 'method' => $method,
+ 'priority' => $priority,
+ ];
+ --$priority;
+ $map[$hook][$class][$method] = $module;
+ }
+ }
+
+ foreach ($tagsInfoByClass as $class => $tagsInfo) {
+ if ($container->hasDefinition($class)) {
+ $definition = $container->findDefinition($class);
+ }
+ else {
+ $definition = $container
+ ->register($class, $class)
+ ->setAutowired(TRUE);
+ }
+ foreach ($tagsInfo as $tag_info) {
+ $definition->addTag('kernel.event_listener', $tag_info);
+ }
+ }
+
$container->setParameter('hook_implementations_map', $map);
}
/**
* Collects all hook implementations.
*
- * @param array $module_filenames
+ * @param array<string, array{pathname: string}> $module_list
* An associative array. Keys are the module names, values are relevant
* info yml file path.
- * @param Symfony\Component\DependencyInjection\ContainerBuilder|null $container
- * The container.
+ * @param list<string> $skipProceduralModules
+ * Module names that are known to not have procedural hook implementations.
*
* @return static
* A HookCollectorPass instance holding all hook implementations and
@@ -134,20 +343,21 @@ class HookCollectorPass implements CompilerPassInterface {
* @internal
* This method is only used by ModuleHandler.
*
- * @todo Pass only $container when ModuleHandler->add is removed
- * https://www.drupal.org/project/drupal/issues/3481778
+ * @todo Pass only $container and make protected when ModuleHandler::add() is
+ * removed in Drupal 12.0.0.
*/
- public static function collectAllHookImplementations(array $module_filenames, ?ContainerBuilder $container = NULL): static {
- $modules = array_map(fn ($x) => preg_quote($x, '/'), array_keys($module_filenames));
- // Longer modules first.
- usort($modules, fn($a, $b) => strlen($b) - strlen($a));
- $module_preg = '/^(?<function>(?<module>' . implode('|', $modules) . ')_(?!preprocess_)(?!update_\d)(?<hook>[a-zA-Z0-9_\x80-\xff]+$))/';
- $collector = new static();
- foreach ($module_filenames as $module => $info) {
- $skip_procedural = FALSE;
- if ($container?->hasParameter("$module.hooks_converted")) {
- $skip_procedural = $container->getParameter("$module.hooks_converted");
- }
+ public static function collectAllHookImplementations(array $module_list, array $skipProceduralModules = []): static {
+ $modules = array_keys($module_list);
+ $modules_by_length = $modules;
+ usort($modules_by_length, static fn ($a, $b) => strlen($b) - strlen($a));
+ $known_modules_pattern = implode('|', array_map(
+ static fn ($x) => preg_quote($x, '/'),
+ $modules_by_length,
+ ));
+ $module_preg = '/^(?<function>(?<module>' . $known_modules_pattern . ')_(?!preprocess_)(?!update_\d)(?<hook>[a-zA-Z0-9_\x80-\xff]+$))/';
+ $collector = new static($modules);
+ foreach ($module_list as $module => $info) {
+ $skip_procedural = in_array($module, $skipProceduralModules);
$collector->collectModuleHookImplementations(dirname($info['pathname']), $module, $module_preg, $skip_procedural);
}
return $collector;
@@ -195,16 +405,33 @@ class HookCollectorPass implements CompilerPassInterface {
$namespace = preg_replace('#^src/#', "Drupal/$module/", $iterator->getSubPath());
$class = $namespace . '/' . $fileinfo->getBasename('.php');
$class = str_replace('/', '\\', $class);
+ $attributes = [];
if (class_exists($class)) {
- $attributes = static::getHookAttributesInClass($class);
+ $reflectionClass = new \ReflectionClass($class);
+ $attributes = self::getAttributeInstances($reflectionClass);
$hook_file_cache->set($filename, ['class' => $class, 'attributes' => $attributes]);
}
- else {
- $attributes = [];
- }
}
- foreach ($attributes as $attribute) {
- $this->addFromAttribute($attribute, $class, $module);
+ foreach ($attributes as $method => $methodAttributes) {
+ foreach ($methodAttributes as $attribute) {
+ if ($attribute instanceof Hook) {
+ self::checkForProceduralOnlyHooks($attribute, $class);
+ $this->oopImplementations[$attribute->hook][$class . '::' . ($attribute->method ?: $method)] = $attribute->module ?? $module;
+ if ($attribute->order !== NULL) {
+ // Use a lower weight for order operations that are declared
+ // together with the hook listener they apply to.
+ $this->orderOperations[$attribute->hook][0][] = $attribute->order->getOperation("$class::$method");
+ }
+ }
+ elseif ($attribute instanceof ReorderHook) {
+ // Use a higher weight for order operations that target other hook
+ // listeners.
+ $this->orderOperations[$attribute->hook][1][] = $attribute->order->getOperation($attribute->class . '::' . $attribute->method);
+ }
+ elseif ($attribute instanceof RemoveHook) {
+ $this->removeHookIdentifiers[$attribute->hook][] = $attribute->class . '::' . $attribute->method;
+ }
+ }
}
}
elseif (!$skip_procedural) {
@@ -217,14 +444,15 @@ class HookCollectorPass implements CompilerPassInterface {
if (StaticReflectionParser::hasAttribute($attributes, StopProceduralHookScan::class)) {
break;
}
- if (!StaticReflectionParser::hasAttribute($attributes, LegacyHook::class) && preg_match($module_preg, $function, $matches)) {
- $implementations[] = ['function' => $function, 'module' => $matches['module'], 'hook' => $matches['hook']];
+ if (!StaticReflectionParser::hasAttribute($attributes, LegacyHook::class) && preg_match($module_preg, $function, $matches) && !StaticReflectionParser::hasAttribute($attributes, LegacyModuleImplementsAlter::class)) {
+ assert($function === $matches['module'] . '_' . $matches['hook']);
+ $implementations[] = ['module' => $matches['module'], 'hook' => $matches['hook']];
}
}
$procedural_hook_file_cache->set($filename, $implementations);
}
foreach ($implementations as $implementation) {
- $this->addProceduralImplementation($fileinfo, $implementation['hook'], $implementation['module'], $implementation['function']);
+ $this->addProceduralImplementation($fileinfo, $implementation['hook'], $implementation['module']);
}
}
if ($extension === 'inc') {
@@ -250,89 +478,33 @@ class HookCollectorPass implements CompilerPassInterface {
return TRUE;
}
// glob() doesn't support streams but scandir() does.
- return !in_array($fileInfo->getFilename(), ['tests', 'js', 'css']) && !array_filter(scandir($key), fn ($filename) => str_ends_with($filename, '.info.yml'));
+ return !in_array($fileInfo->getFilename(), ['tests', 'js', 'css']) && !array_filter(scandir($key), static fn ($filename) => str_ends_with($filename, '.info.yml'));
}
return in_array($extension, ['inc', 'module', 'profile', 'install']);
}
/**
- * An array of Hook attributes on this class with $method set.
- *
- * @param string $class
- * The class.
- *
- * @return \Drupal\Core\Hook\Attribute\Hook[]
- * An array of Hook attributes on this class. The $method property is
- * guaranteed to be set.
- */
- protected static function getHookAttributesInClass(string $class): array {
- $reflection_class = new \ReflectionClass($class);
- $class_implementations = [];
- // Check for #[Hook] on the class itself.
- foreach ($reflection_class->getAttributes(Hook::class, \ReflectionAttribute::IS_INSTANCEOF) as $reflection_attribute) {
- $hook = $reflection_attribute->newInstance();
- assert($hook instanceof Hook);
- self::checkForProceduralOnlyHooks($hook, $class);
- if (!$hook->method) {
- if (method_exists($class, '__invoke')) {
- $hook->setMethod('__invoke');
- }
- else {
- throw new \LogicException("The Hook attribute for hook $hook->hook on class $class must specify a method.");
- }
- }
- $class_implementations[] = $hook;
- }
- // Check for #[Hook] on methods.
- foreach ($reflection_class->getMethods(\ReflectionMethod::IS_PUBLIC) as $method_reflection) {
- foreach ($method_reflection->getAttributes(Hook::class, \ReflectionAttribute::IS_INSTANCEOF) as $attribute_reflection) {
- $hook = $attribute_reflection->newInstance();
- assert($hook instanceof Hook);
- self::checkForProceduralOnlyHooks($hook, $class);
- $class_implementations[] = $hook->setMethod($method_reflection->getName());
- }
- }
- return $class_implementations;
- }
-
- /**
- * Adds a Hook attribute implementation.
- *
- * @param \Drupal\Core\Hook\Attribute\Hook $hook
- * A hook attribute.
- * @param string $class
- * The class in which said attribute resides in.
- * @param string $module
- * The module in which the class resides in.
- */
- protected function addFromAttribute(Hook $hook, $class, $module): void {
- if ($hook->module) {
- $module = $hook->module;
- }
- $this->moduleImplements[$hook->hook][$module] = '';
- $this->implementations[$hook->hook][$module][$class][] = $hook->method;
- }
-
- /**
* Adds a procedural hook implementation.
*
* @param \SplFileInfo $fileinfo
- * The file this procedural implementation is in. (You don't say)
+ * The file this procedural implementation is in.
* @param string $hook
- * The name of the hook. (Huh, right?)
+ * The name of the hook.
* @param string $module
- * The name of the module. (Truly shocking!)
- * @param string $function
- * The name of function implementing the hook. (Wow!)
+ * The module implementing the hook, or on behalf of which the hook is
+ * implemented.
*/
- protected function addProceduralImplementation(\SplFileInfo $fileinfo, string $hook, string $module, string $function): void {
- $this->addFromAttribute(new Hook($hook, $module . '_' . $hook), ProceduralCall::class, $module);
+ protected function addProceduralImplementation(\SplFileInfo $fileinfo, string $hook, string $module): void {
+ $function = $module . '_' . $hook;
if ($hook === 'hook_info') {
$this->hookInfo[] = $function;
}
- if ($hook === 'module_implements_alter') {
+ elseif ($hook === 'module_implements_alter') {
+ $message = "$function without a #[LegacyModuleImplementsAlter] attribute is deprecated in drupal:11.2.0 and removed in drupal:12.0.0. See https://www.drupal.org/node/3496788";
+ @trigger_error($message, E_USER_DEPRECATED);
$this->moduleImplementsAlters[] = $function;
}
+ $this->proceduralImplementations[$hook][] = $module;
if ($fileinfo->getExtension() !== 'module') {
$this->includes[$function] = $fileinfo->getPathname();
}
@@ -341,6 +513,8 @@ class HookCollectorPass implements CompilerPassInterface {
/**
* This method is only to be used by ModuleHandler.
*
+ * @todo Remove when ModuleHandler::add() is removed in Drupal 12.0.0.
+ *
* @internal
*/
public function loadAllIncludes(): void {
@@ -352,21 +526,40 @@ class HookCollectorPass implements CompilerPassInterface {
/**
* This method is only to be used by ModuleHandler.
*
+ * @return array<string, array<string, array<class-string, array<string, string>>>>
+ * Hook implementation method names keyed by hook, module, class and method.
+ *
+ * @todo Remove when ModuleHandler::add() is removed in Drupal 12.0.0.
+ *
* @internal
*/
public function getImplementations(): array {
- return $this->implementations;
+ $implementationsByHook = $this->getFilteredImplementations();
+
+ // List of modules implementing hooks with the implementation details.
+ $implementations = [];
+
+ foreach ($implementationsByHook as $hook => $hookImplementations) {
+ foreach ($this->modules as $module) {
+ foreach (array_keys($hookImplementations, $module, TRUE) as $identifier) {
+ [$class, $method] = explode('::', $identifier);
+ $implementations[$hook][$module][$class][$method] = $method;
+ }
+ }
+ }
+
+ return $implementations;
}
/**
* Checks for hooks which can't be supported in classes.
*
- * @param \Drupal\Core\Hook\Attribute\Hook $hook
+ * @param \Drupal\Core\Hook\Attribute\Hook $hookAttribute
* The hook to check.
- * @param string $class
+ * @param class-string $class
* The class the hook is implemented on.
*/
- public static function checkForProceduralOnlyHooks(Hook $hook, string $class): void {
+ public static function checkForProceduralOnlyHooks(Hook $hookAttribute, string $class): void {
$staticDenyHooks = [
'hook_info',
'install',
@@ -379,9 +572,31 @@ class HookCollectorPass implements CompilerPassInterface {
'install_tasks_alter',
];
- if (in_array($hook->hook, $staticDenyHooks) || preg_match('/^(post_update_|preprocess_|update_\d+$)/', $hook->hook)) {
- throw new \LogicException("The hook $hook->hook on class $class does not support attributes and must remain procedural.");
+ if (in_array($hookAttribute->hook, $staticDenyHooks) || preg_match('/^(post_update_|preprocess_|update_\d+$)/', $hookAttribute->hook)) {
+ throw new \LogicException("The hook $hookAttribute->hook on class $class does not support attributes and must remain procedural.");
+ }
+ }
+
+ /**
+ * Get attribute instances from class and method reflections.
+ *
+ * @param \ReflectionClass $reflectionClass
+ * A reflected class.
+ *
+ * @return array<string, list<\Drupal\Core\Hook\Attribute\HookAttributeInterface>>
+ * Lists of Hook attribute instances by method name.
+ */
+ protected static function getAttributeInstances(\ReflectionClass $reflectionClass): array {
+ $attributes = [];
+ $reflections = $reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC);
+ $reflections[] = $reflectionClass;
+ foreach ($reflections as $reflection) {
+ if ($reflectionAttributes = $reflection->getAttributes(HookAttributeInterface::class, \ReflectionAttribute::IS_INSTANCEOF)) {
+ $method = $reflection instanceof \ReflectionMethod ? $reflection->getName() : '__invoke';
+ $attributes[$method] = array_map(static fn (\ReflectionAttribute $ra) => $ra->newInstance(), $reflectionAttributes);
+ }
}
+ return $attributes;
}
}
diff --git a/core/lib/Drupal/Core/Hook/Order/Order.php b/core/lib/Drupal/Core/Hook/Order/Order.php
new file mode 100644
index 00000000000..6a7934df7d2
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Order/Order.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook\Order;
+
+use Drupal\Core\Hook\OrderOperation\FirstOrLast;
+use Drupal\Core\Hook\OrderOperation\OrderOperation;
+
+/**
+ * Set this implementation to be first or last.
+ */
+enum Order: int implements OrderInterface {
+
+ // This implementation should execute first.
+ case First = 1;
+
+ // This implementation should execute last.
+ case Last = 0;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOperation(string $identifier): OrderOperation {
+ return new FirstOrLast($identifier, $this === self::Last);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Hook/Order/OrderAfter.php b/core/lib/Drupal/Core/Hook/Order/OrderAfter.php
new file mode 100644
index 00000000000..73dfd926475
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Order/OrderAfter.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook\Order;
+
+/**
+ * Set this implementation to be after others.
+ */
+readonly class OrderAfter extends RelativeOrderBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function isAfter(): bool {
+ return TRUE;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Hook/Order/OrderBefore.php b/core/lib/Drupal/Core/Hook/Order/OrderBefore.php
new file mode 100644
index 00000000000..cc79560a3d5
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Order/OrderBefore.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook\Order;
+
+/**
+ * Set this implementation to be before others.
+ */
+readonly class OrderBefore extends RelativeOrderBase {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function isAfter(): bool {
+ return FALSE;
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Hook/Order/OrderInterface.php b/core/lib/Drupal/Core/Hook/Order/OrderInterface.php
new file mode 100644
index 00000000000..85fc820a5c6
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Order/OrderInterface.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Core\Hook\Order;
+
+use Drupal\Core\Hook\OrderOperation\OrderOperation;
+
+/**
+ * Interface for order specifiers used in hook attributes.
+ *
+ * Objects implementing this interface allow for relative ordering of hooks.
+ * These objects are passed as an order parameter to a Hook or ReorderHook
+ * attribute.
+ * Order::First and Order::Last are simple order operations that move the hook
+ * implementation to the first or last position of hooks at the time the order
+ * directive is executed.
+ * @code
+ * #[Hook('custom_hook', order: Order::First)]
+ * @endcode
+ * OrderBefore and OrderAfter take additional parameters
+ * for ordering. See Drupal\Core\Hook\Order\RelativeOrderBase.
+ * @code
+ * #[Hook('custom_hook', order: new OrderBefore(['other_module']))]
+ * @endcode
+ */
+interface OrderInterface {
+
+ /**
+ * Gets order operations specified by this object.
+ *
+ * @param string $identifier
+ * Identifier of the implementation to move to a new position. The format
+ * is the class followed by "::" then the method name. For example,
+ * "Drupal\my_module\Hook\MyModuleHooks::methodName".
+ *
+ * @return \Drupal\Core\Hook\OrderOperation\OrderOperation
+ * Order operation to apply to a hook implementation list.
+ */
+ public function getOperation(string $identifier): OrderOperation;
+
+}
diff --git a/core/lib/Drupal/Core/Hook/Order/RelativeOrderBase.php b/core/lib/Drupal/Core/Hook/Order/RelativeOrderBase.php
new file mode 100644
index 00000000000..eaf6eade668
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/Order/RelativeOrderBase.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook\Order;
+
+use Drupal\Core\Hook\OrderOperation\BeforeOrAfter;
+use Drupal\Core\Hook\OrderOperation\OrderOperation;
+
+/**
+ * Orders an implementation relative to other implementations.
+ */
+abstract readonly class RelativeOrderBase implements OrderInterface {
+
+ /**
+ * Constructor.
+ *
+ * @param list<string> $modules
+ * A list of modules the implementations should order against.
+ * @param list<array{class-string, string}> $classesAndMethods
+ * A list of implementations to order against, as [$class, $method].
+ */
+ public function __construct(
+ public array $modules = [],
+ public array $classesAndMethods = [],
+ ) {
+ if (!$this->modules && !$this->classesAndMethods) {
+ throw new \LogicException('Order must provide either modules or class-method pairs to order against.');
+ }
+ }
+
+ /**
+ * Specifies the ordering direction.
+ *
+ * @return bool
+ * TRUE, if the ordered implementation should be inserted after the
+ * implementations specified in the constructor.
+ */
+ abstract protected function isAfter(): bool;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOperation(string $identifier): OrderOperation {
+ return new BeforeOrAfter(
+ $identifier,
+ $this->modules,
+ array_map(
+ static fn(array $class_and_method) => implode('::', $class_and_method),
+ $this->classesAndMethods,
+ ),
+ $this->isAfter(),
+ );
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Hook/OrderOperation/BeforeOrAfter.php b/core/lib/Drupal/Core/Hook/OrderOperation/BeforeOrAfter.php
new file mode 100644
index 00000000000..e87f661fc39
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/OrderOperation/BeforeOrAfter.php
@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Core\Hook\OrderOperation;
+
+/**
+ * Moves one listener to be called before or after other listeners.
+ *
+ * @internal
+ */
+class BeforeOrAfter extends OrderOperation {
+
+ /**
+ * Constructor.
+ *
+ * @param string $identifier
+ * Identifier of the implementation to move to a new position. The format
+ * is the class followed by "::" then the method name. For example,
+ * "Drupal\my_module\Hook\MyModuleHooks::methodName".
+ * @param list<string> $modulesToOrderAgainst
+ * Module names of listeners to order against.
+ * @param list<string> $identifiersToOrderAgainst
+ * Identifiers of listeners to order against.
+ * The format is "$class::$method".
+ * @param bool $isAfter
+ * TRUE, if the listener to move should be moved after the listener to order
+ * against, FALSE if it should be moved before.
+ */
+ public function __construct(
+ protected readonly string $identifier,
+ protected readonly array $modulesToOrderAgainst,
+ protected readonly array $identifiersToOrderAgainst,
+ protected readonly bool $isAfter,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(array &$identifiers, array $module_finder): void {
+ assert(array_is_list($identifiers));
+ $index = array_search($this->identifier, $identifiers);
+ if ($index === FALSE) {
+ // Nothing to reorder.
+ return;
+ }
+ $identifiers_to_order_against = $this->identifiersToOrderAgainst;
+ if ($this->modulesToOrderAgainst) {
+ $identifiers_to_order_against = [
+ ...$identifiers_to_order_against,
+ ...array_keys(array_intersect($module_finder, $this->modulesToOrderAgainst)),
+ ];
+ }
+ $indices_to_order_against = array_keys(array_intersect($identifiers, $identifiers_to_order_against));
+ if ($indices_to_order_against === []) {
+ return;
+ }
+ if ($this->isAfter) {
+ $max_index_to_order_against = max($indices_to_order_against);
+ if ($index >= $max_index_to_order_against) {
+ // The element is already after the other elements.
+ return;
+ }
+ array_splice($identifiers, $max_index_to_order_against + 1, 0, $this->identifier);
+ // Remove the element after splicing.
+ unset($identifiers[$index]);
+ $identifiers = array_values($identifiers);
+ }
+ else {
+ $min_index_to_order_against = min($indices_to_order_against);
+ if ($index <= $min_index_to_order_against) {
+ // The element is already before the other elements.
+ return;
+ }
+ // Remove the element before splicing.
+ unset($identifiers[$index]);
+ array_splice($identifiers, $min_index_to_order_against, 0, $this->identifier);
+ }
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Hook/OrderOperation/FirstOrLast.php b/core/lib/Drupal/Core/Hook/OrderOperation/FirstOrLast.php
new file mode 100644
index 00000000000..2169e533891
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/OrderOperation/FirstOrLast.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Core\Hook\OrderOperation;
+
+/**
+ * Moves one listener to the start or end of the list.
+ *
+ * @internal
+ */
+class FirstOrLast extends OrderOperation {
+
+ /**
+ * Constructor.
+ *
+ * @param string $identifier
+ * Identifier of the implementation to move to a new position. The format
+ * is the class followed by "::" then the method name. For example,
+ * "Drupal\my_module\Hook\MyModuleHooks::methodName".
+ * @param bool $isLast
+ * TRUE to move to the end, FALSE to move to the start.
+ */
+ public function __construct(
+ protected readonly string $identifier,
+ protected readonly bool $isLast,
+ ) {}
+
+ /**
+ * {@inheritdoc}
+ */
+ public function apply(array &$identifiers, array $module_finder): void {
+ $index = array_search($this->identifier, $identifiers);
+ if ($index === FALSE) {
+ // The element does not exist.
+ return;
+ }
+ unset($identifiers[$index]);
+ if ($this->isLast) {
+ $identifiers[] = $this->identifier;
+ }
+ else {
+ $identifiers = [$this->identifier, ...$identifiers];
+ }
+ $identifiers = array_values($identifiers);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Hook/OrderOperation/OrderOperation.php b/core/lib/Drupal/Core/Hook/OrderOperation/OrderOperation.php
new file mode 100644
index 00000000000..329d850481c
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/OrderOperation/OrderOperation.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Core\Hook\OrderOperation;
+
+/**
+ * Base class for order operations.
+ */
+abstract class OrderOperation {
+
+ /**
+ * Changes the order of a list of hook implementations.
+ *
+ * @param list<string> $identifiers
+ * Hook implementation identifiers, as "$class::$method", to be changed by
+ * reference.
+ * The order operation must make sure that the array remains a list, and
+ * that the values are the same as before.
+ * @param array<string, string> $module_finder
+ * Lookup map to find a module name for each implementation.
+ * This may contain more entries than $identifiers.
+ */
+ abstract public function apply(array &$identifiers, array $module_finder): void;
+
+ /**
+ * Converts the operation to a structure that can be stored in the container.
+ *
+ * @return array
+ * Packed operation.
+ */
+ final public function pack(): array {
+ $is_before_or_after = match(get_class($this)) {
+ BeforeOrAfter::class => TRUE,
+ FirstOrLast::class => FALSE,
+ };
+ return [$is_before_or_after, get_object_vars($this)];
+ }
+
+ /**
+ * Converts the stored operation to objects that can apply ordering rules.
+ *
+ * @param array $packed_operation
+ * Packed operation.
+ *
+ * @return self
+ * Unpacked operation.
+ */
+ final public static function unpack(array $packed_operation): self {
+ [$is_before_or_after, $args] = $packed_operation;
+ $class = $is_before_or_after ? BeforeOrAfter::class : FirstOrLast::class;
+ return new $class(...$args);
+ }
+
+}
diff --git a/core/modules/ckeditor5/ckeditor5.module b/core/modules/ckeditor5/ckeditor5.module
index 759900cda00..86be6f62b24 100644
--- a/core/modules/ckeditor5/ckeditor5.module
+++ b/core/modules/ckeditor5/ckeditor5.module
@@ -18,31 +18,6 @@ use Drupal\Core\Ajax\RemoveCommand;
use Drupal\Core\Form\FormStateInterface;
/**
- * Implements hook_module_implements_alter().
- */
-function ckeditor5_module_implements_alter(&$implementations, $hook): void {
- // This module's implementation of form_filter_format_form_alter() must happen
- // after the editor module's implementation, as that implementation adds the
- // active editor to $form_state. It must also happen after the media module's
- // implementation so media_filter_format_edit_form_validate can be removed
- // from the validation chain, as that validator is not needed with CKEditor 5
- // and will trigger a false error.
- if ($hook === 'form_alter' && isset($implementations['ckeditor5']) && isset($implementations['editor'])) {
- $group = $implementations['ckeditor5'];
- unset($implementations['ckeditor5']);
-
- $offset = array_search('editor', array_keys($implementations)) + 1;
- if (array_key_exists('media', $implementations)) {
- $media_offset = array_search('media', array_keys($implementations)) + 1;
- $offset = max([$offset, $media_offset]);
- }
- $implementations = array_slice($implementations, 0, $offset, TRUE) +
- ['ckeditor5' => $group] +
- array_slice($implementations, $offset, NULL, TRUE);
- }
-}
-
-/**
* Form submission handler for filter format forms.
*/
function ckeditor5_filter_format_edit_form_submit(array $form, FormStateInterface $form_state): void {
diff --git a/core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php b/core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php
index 85ead6ae51d..7eb7601e3d8 100644
--- a/core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php
+++ b/core/modules/ckeditor5/src/Hook/Ckeditor5Hooks.php
@@ -2,6 +2,7 @@
namespace Drupal\ckeditor5\Hook;
+use Drupal\Core\Hook\Order\OrderAfter;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Asset\AttachedAssetsInterface;
use Drupal\Core\Render\Element;
@@ -100,8 +101,19 @@ class Ckeditor5Hooks {
/**
* Implements hook_form_FORM_ID_alter().
+ *
+ * This module's implementation of form_filter_format_form_alter() must
+ * happen after the editor module's implementation, as that implementation
+ * adds the active editor to $form_state. It must also happen after the media
+ * module's implementation so media_filter_format_edit_form_validate can be
+ * removed from the validation chain, as that validator is not needed with
+ * CKEditor 5 and will trigger a false error.
*/
- #[Hook('form_filter_format_form_alter')]
+ #[Hook('form_filter_format_form_alter',
+ order: new OrderAfter(
+ modules: ['editor', 'media'],
+ )
+ )]
public function formFilterFormatFormAlter(array &$form, FormStateInterface $form_state, $form_id) : void {
$editor = $form_state->get('editor');
// CKEditor 5 plugin config determines the available HTML tags. If an HTML
diff --git a/core/modules/content_translation/content_translation.module b/core/modules/content_translation/content_translation.module
index 97bdc664e25..49b15bb022b 100644
--- a/core/modules/content_translation/content_translation.module
+++ b/core/modules/content_translation/content_translation.module
@@ -11,28 +11,6 @@ use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
/**
- * Implements hook_module_implements_alter().
- */
-function content_translation_module_implements_alter(&$implementations, $hook): void {
- switch ($hook) {
- // Move our hook_entity_type_alter() implementation to the end of the list.
- case 'entity_type_alter':
- $group = $implementations['content_translation'];
- unset($implementations['content_translation']);
- $implementations['content_translation'] = $group;
- break;
-
- // Move our hook_entity_bundle_info_alter() implementation to the top of the
- // list, so that any other hook implementation can rely on bundles being
- // correctly marked as translatable.
- case 'entity_bundle_info_alter':
- $group = $implementations['content_translation'];
- $implementations = ['content_translation' => $group] + $implementations;
- break;
- }
-}
-
-/**
* Installs Content Translation's fields for a given entity type.
*
* @param string $entity_type_id
diff --git a/core/modules/content_translation/src/Hook/ContentTranslationHooks.php b/core/modules/content_translation/src/Hook/ContentTranslationHooks.php
index 0f38a7f005d..c681b07c059 100644
--- a/core/modules/content_translation/src/Hook/ContentTranslationHooks.php
+++ b/core/modules/content_translation/src/Hook/ContentTranslationHooks.php
@@ -17,6 +17,7 @@ use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\Order;
/**
* Hook implementations for content_translation.
@@ -139,7 +140,7 @@ class ContentTranslationHooks {
*
* @see \Drupal\Core\Entity\Annotation\EntityType
*/
- #[Hook('entity_type_alter')]
+ #[Hook('entity_type_alter', order: Order::Last)]
public function entityTypeAlter(array &$entity_types) : void {
// Provide defaults for translation info.
/** @var \Drupal\Core\Entity\EntityTypeInterface[] $entity_types */
@@ -221,7 +222,7 @@ class ContentTranslationHooks {
/**
* Implements hook_entity_bundle_info_alter().
*/
- #[Hook('entity_bundle_info_alter')]
+ #[Hook('entity_bundle_info_alter', order: Order::First)]
public function entityBundleInfoAlter(&$bundles): void {
/** @var \Drupal\content_translation\ContentTranslationManagerInterface $content_translation_manager */
$content_translation_manager = \Drupal::service('content_translation.manager');
diff --git a/core/modules/layout_builder/layout_builder.module b/core/modules/layout_builder/layout_builder.module
index 32ce8bee945..37dc492bf78 100644
--- a/core/modules/layout_builder/layout_builder.module
+++ b/core/modules/layout_builder/layout_builder.module
@@ -7,20 +7,6 @@
use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage;
/**
- * Implements hook_module_implements_alter().
- */
-function layout_builder_module_implements_alter(&$implementations, $hook): void {
- if ($hook === 'entity_view_alter') {
- // Ensure that this module's implementation of hook_entity_view_alter() runs
- // last so that other modules that use this hook to render extra fields will
- // run before it.
- $group = $implementations['layout_builder'];
- unset($implementations['layout_builder']);
- $implementations['layout_builder'] = $group;
- }
-}
-
-/**
* Implements hook_preprocess_HOOK() for language-content-settings-table.html.twig.
*/
function layout_builder_preprocess_language_content_settings_table(&$variables): void {
diff --git a/core/modules/layout_builder/src/Hook/LayoutBuilderHooks.php b/core/modules/layout_builder/src/Hook/LayoutBuilderHooks.php
index d12f9f0bc68..28159482d31 100644
--- a/core/modules/layout_builder/src/Hook/LayoutBuilderHooks.php
+++ b/core/modules/layout_builder/src/Hook/LayoutBuilderHooks.php
@@ -26,6 +26,7 @@ use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\Order;
/**
* Hook implementations for layout_builder.
@@ -151,7 +152,7 @@ class LayoutBuilderHooks {
* @see \Drupal\layout_builder\Plugin\Block\ExtraFieldBlock::build()
* @see layout_builder_module_implements_alter()
*/
- #[Hook('entity_view_alter')]
+ #[Hook('entity_view_alter', order: Order::Last)]
public function entityViewAlter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display): void {
// Only replace extra fields when Layout Builder has been used to alter the
// build. See \Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay::buildMultiple().
diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module b/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module
index de2e5316d07..d7dda399be3 100644
--- a/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module
+++ b/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module
@@ -49,17 +49,3 @@ function layout_builder_test_preprocess_layout__twocol_section(&$vars): void {
];
}
}
-
-/**
- * Implements hook_module_implements_alter().
- */
-function layout_builder_test_module_implements_alter(&$implementations, $hook): void {
- if ($hook === 'system_breadcrumb_alter') {
- // Move our hook_system_breadcrumb_alter() implementation to run before
- // layout_builder_system_breadcrumb_alter().
- $group = $implementations['layout_builder_test'];
- $implementations = [
- 'layout_builder_test' => $group,
- ] + $implementations;
- }
-}
diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php
index 7a0d4797903..397eedc8dad 100644
--- a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php
+++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php
@@ -11,6 +11,7 @@ use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Entity\Display\EntityFormDisplayInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\OrderBefore;
/**
* Hook implementations for layout_builder_test.
@@ -115,7 +116,12 @@ class LayoutBuilderTestHooks {
/**
* Implements hook_system_breadcrumb_alter().
*/
- #[Hook('system_breadcrumb_alter')]
+ #[Hook(
+ 'system_breadcrumb_alter',
+ order: new OrderBefore(
+ modules: ['layout_builder']
+ )
+ )]
public function systemBreadcrumbAlter(Breadcrumb &$breadcrumb, RouteMatchInterface $route_match, array $context): void {
$breadcrumb->addLink(Link::fromTextAndUrl('External link', Url::fromUri('http://www.example.com')));
}
diff --git a/core/modules/navigation/navigation.module b/core/modules/navigation/navigation.module
index dbc11f191e6..570f13ba3e4 100644
--- a/core/modules/navigation/navigation.module
+++ b/core/modules/navigation/navigation.module
@@ -7,21 +7,6 @@
use Drupal\navigation\TopBarRegion;
/**
- * Implements hook_module_implements_alter().
- */
-function navigation_module_implements_alter(&$implementations, $hook): void {
- if ($hook == 'page_top') {
- $group = $implementations['navigation'];
- unset($implementations['navigation']);
- $implementations['navigation'] = $group;
- }
- if ($hook == 'help') {
- // We take over the layout_builder hook_help().
- unset($implementations['layout_builder']);
- }
-}
-
-/**
* Prepares variables for navigation top bar template.
*
* Default template: top-bar.html.twig
diff --git a/core/modules/navigation/src/Hook/NavigationHooks.php b/core/modules/navigation/src/Hook/NavigationHooks.php
index 673f5325580..ed4eb828366 100644
--- a/core/modules/navigation/src/Hook/NavigationHooks.php
+++ b/core/modules/navigation/src/Hook/NavigationHooks.php
@@ -7,9 +7,12 @@ use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Config\Action\ConfigActionManager;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Attribute\RemoveHook;
+use Drupal\Core\Hook\Order\Order;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\layout_builder\Hook\LayoutBuilderHooks;
use Drupal\navigation\NavigationContentLinks;
use Drupal\navigation\NavigationRenderer;
use Drupal\navigation\Plugin\SectionStorage\NavigationSectionStorage;
@@ -52,6 +55,7 @@ class NavigationHooks {
* Implements hook_help().
*/
#[Hook('help')]
+ #[RemoveHook('help', class: LayoutBuilderHooks::class, method: 'help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.navigation':
@@ -76,7 +80,7 @@ class NavigationHooks {
/**
* Implements hook_page_top().
*/
- #[Hook('page_top')]
+ #[Hook('page_top', order: Order::Last)]
public function pageTop(array &$page_top): void {
if (!$this->currentUser->hasPermission('access navigation')) {
return;
diff --git a/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/aaa_hook_collector_test.info.yml b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/aaa_hook_collector_test.info.yml
new file mode 100644
index 00000000000..0ebae91b673
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/aaa_hook_collector_test.info.yml
@@ -0,0 +1,7 @@
+name: AAA Hook collector test
+type: module
+description: 'Test module used to test hook ordering.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
+hidden: true
diff --git a/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookAfter.php b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookAfter.php
new file mode 100644
index 00000000000..bf2ae7db46d
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookAfter.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\OrderAfter;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookAfter {
+
+ /**
+ * This pair tests OrderAfter.
+ */
+ #[Hook('custom_hook_test_hook_after', order: new OrderAfter(['bbb_hook_collector_test']))]
+ public function hookAfter(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookAfterClassMethod.php b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookAfterClassMethod.php
new file mode 100644
index 00000000000..e3861fe3516
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookAfterClassMethod.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\OrderAfter;
+use Drupal\bbb_hook_collector_test\Hook\TestHookAfterClassMethod as TestHookAfterClassMethodForAfter;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookAfterClassMethod {
+
+ /**
+ * This pair tests OrderAfter with a passed class and method.
+ */
+ #[Hook('custom_hook_test_hook_after_class_method',
+ order: new OrderAfter(
+ classesAndMethods: [[TestHookAfterClassMethodForAfter::class, 'hookAfterClassMethod']],
+ )
+ )]
+ public static function hookAfterClassMethod(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookBefore.php b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookBefore.php
new file mode 100644
index 00000000000..76661c5297d
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookBefore.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookBefore {
+
+ /**
+ * This pair tests OrderBefore.
+ */
+ #[Hook('custom_hook_test_hook_before')]
+ public function hookBefore(): string {
+ // This should be run second, there is another hook reordering before this.
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookFirst.php b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookFirst.php
new file mode 100644
index 00000000000..e3ce6b86963
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookFirst.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookFirst {
+
+ /**
+ * This pair tests OrderFirst.
+ */
+ #[Hook('custom_hook_test_hook_first')]
+ public function hookFirst(): string {
+ // This should be run second, there is another hook reordering before this.
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookLast.php b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookLast.php
new file mode 100644
index 00000000000..427422b378f
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookLast.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\Order;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookLast {
+
+ /**
+ * This pair tests OrderLast.
+ */
+ #[Hook('custom_hook_test_hook_last', order: Order::Last)]
+ public function hookLast(): string {
+ // This should be run after.
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookOrderExtraTypes.php b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookOrderExtraTypes.php
new file mode 100644
index 00000000000..eb2c35472f0
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookOrderExtraTypes.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\OrderAfter;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookOrderExtraTypes {
+
+ /**
+ * This pair tests OrderAfter with ExtraTypes.
+ */
+ #[Hook('custom_hook_extra_types1_alter',
+ order: new OrderAfter(
+ modules: ['bbb_hook_collector_test'],
+ )
+ )]
+ public function customHookExtraTypes(array &$calls): void {
+ // This should be run after.
+ $calls[] = __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookReorderHookFirst.php b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookReorderHookFirst.php
new file mode 100644
index 00000000000..55e12aad240
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/aaa_hook_collector_test/src/Hook/TestHookReorderHookFirst.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\OrderAfter;
+use Drupal\Core\Hook\Attribute\ReorderHook;
+use Drupal\bbb_hook_collector_test\Hook\TestHookReorderHookLast;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookReorderHookFirst {
+
+ /**
+ * This pair tests ReorderHook.
+ */
+ #[Hook('custom_hook_override')]
+ #[ReorderHook(
+ 'custom_hook_override',
+ class: TestHookReorderHookLast::class,
+ method: 'customHookOverride',
+ order: new OrderAfter(
+ classesAndMethods: [[TestHookReorderHookFirst::class, 'customHookOverride']],
+ )
+ )]
+ public function customHookOverride(): string {
+ // This normally would run first.
+ // We override that order in hook_order_second_alphabetically.
+ // We override, that order here with ReorderHook.
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/bbb_hook_collector_test.info.yml b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/bbb_hook_collector_test.info.yml
new file mode 100644
index 00000000000..a084845fb14
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/bbb_hook_collector_test.info.yml
@@ -0,0 +1,7 @@
+name: BBB Hook collector test
+type: module
+description: 'Test module used to test hook ordering.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
+hidden: true
diff --git a/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookAfter.php b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookAfter.php
new file mode 100644
index 00000000000..89a6f3a1b05
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookAfter.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookAfter {
+
+ /**
+ * This pair tests OrderAfter.
+ */
+ #[Hook('custom_hook_test_hook_after')]
+ public function hookAfter(): string {
+ // This should be run before.
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookAfterClassMethod.php b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookAfterClassMethod.php
new file mode 100644
index 00000000000..1cedcdcaa66
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookAfterClassMethod.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookAfterClassMethod {
+
+ /**
+ * This pair tests OrderAfter with a passed class and method.
+ */
+ #[Hook('custom_hook_test_hook_after_class_method')]
+ public static function hookAfterClassMethod(): string {
+ // This should be run first since another hook overrides the natural order.
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookBefore.php b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookBefore.php
new file mode 100644
index 00000000000..12341cd27d2
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookBefore.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\OrderBefore;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookBefore {
+
+ /**
+ * This pair tests OrderBefore.
+ */
+ #[Hook('custom_hook_test_hook_before', order: new OrderBefore(['aaa_hook_collector_test']))]
+ public function hookBefore(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookFirst.php b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookFirst.php
new file mode 100644
index 00000000000..ac72de1da80
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookFirst.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\Order;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookFirst {
+
+ /**
+ * This pair tests OrderFirst.
+ */
+ #[Hook('custom_hook_test_hook_first', order: Order::First)]
+ public function hookFirst(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookLast.php b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookLast.php
new file mode 100644
index 00000000000..6b30344f583
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookLast.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookLast {
+
+ /**
+ * This pair tests OrderLast.
+ */
+ #[Hook('custom_hook_test_hook_last')]
+ public function hookLast(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookOrderExtraTypes.php b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookOrderExtraTypes.php
new file mode 100644
index 00000000000..a32e3529ec6
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookOrderExtraTypes.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookOrderExtraTypes {
+
+ /**
+ * This pair tests OrderAfter with ExtraTypes.
+ */
+ #[Hook('custom_hook_extra_types2_alter')]
+ public function customHookExtraTypes(array &$calls): void {
+ $calls[] = __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookReorderHookLast.php b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookReorderHookLast.php
new file mode 100644
index 00000000000..3b89394347a
--- /dev/null
+++ b/core/modules/system/tests/modules/HookCollector/bbb_hook_collector_test/src/Hook/TestHookReorderHookLast.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_collector_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\Order;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class TestHookReorderHookLast {
+
+ /**
+ * This pair tests ReorderHook.
+ */
+ #[Hook('custom_hook_override', order: Order::First)]
+ public function customHookOverride(): string {
+ // This normally would run second.
+ // We override that order here with Order::First.
+ // We override, that order in aaa_hook_collector_test with
+ // ReorderHook.
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/aaa_hook_order_test.info.yml b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/aaa_hook_order_test.info.yml
new file mode 100644
index 00000000000..0cc98c51c0b
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/aaa_hook_order_test.info.yml
@@ -0,0 +1,6 @@
+name: AAA Hook order test
+type: module
+description: 'Test module used to test hook ordering.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
diff --git a/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/aaa_hook_order_test.module b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/aaa_hook_order_test.module
new file mode 100644
index 00000000000..513a72c21f4
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/aaa_hook_order_test.module
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Contains procedural hook implementations.
+ */
+
+declare(strict_types=1);
+
+use Drupal\aaa_hook_order_test\Hook\ModuleImplementsAlter;
+
+/**
+ * Implements hook_test_hook().
+ */
+function aaa_hook_order_test_test_hook(): string {
+ return __FUNCTION__;
+}
+
+/**
+ * Implements hook_sparse_test_hook().
+ */
+function aaa_hook_order_test_sparse_test_hook(): string {
+ return __FUNCTION__;
+}
+
+/**
+ * Implements hook_procedural_alter().
+ */
+function aaa_hook_order_test_procedural_alter(array &$calls): void {
+ $calls[] = __FUNCTION__;
+}
+
+/**
+ * Implements hook_procedural_subtype_alter().
+ */
+function aaa_hook_order_test_procedural_subtype_alter(array &$calls): void {
+ $calls[] = __FUNCTION__;
+}
+
+/**
+ * Implements hook_module_implements_alter().
+ */
+function aaa_hook_order_test_module_implements_alter(array &$implementations, string $hook): void {
+ ModuleImplementsAlter::call($implementations, $hook);
+}
diff --git a/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/AAlterHooks.php b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/AAlterHooks.php
new file mode 100644
index 00000000000..f9e124e217b
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/AAlterHooks.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_order_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\OrderAfter;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class AAlterHooks {
+
+ #[Hook('test_alter', order: new OrderAfter(modules: ['ccc_hook_order_test']))]
+ public function testAlterAfterC(array &$calls): void {
+ $calls[] = __METHOD__;
+ }
+
+ #[Hook('test_subtype_alter')]
+ public function testSubtypeAlter(array &$calls): void {
+ $calls[] = __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/AHooks.php b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/AHooks.php
new file mode 100644
index 00000000000..8f7f400709c
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/AHooks.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_order_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\Order;
+use Drupal\Core\Hook\Order\OrderAfter;
+use Drupal\ccc_hook_order_test\Hook\CHooks;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class AHooks {
+
+ #[Hook('test_hook')]
+ public function testHook(): string {
+ return __METHOD__;
+ }
+
+ #[Hook('test_hook', order: Order::First)]
+ public function testHookFirst(): string {
+ return __METHOD__;
+ }
+
+ #[Hook('test_hook', order: Order::Last)]
+ public function testHookLast(): string {
+ return __METHOD__;
+ }
+
+ #[Hook('test_hook', order: new OrderAfter(modules: ['bbb_hook_order_test']))]
+ public function testHookAfterB(): string {
+ return __METHOD__;
+ }
+
+ #[Hook(
+ 'test_both_parameters_hook',
+ order: new OrderAfter(
+ modules: ['bbb_hook_order_test'],
+ classesAndMethods: [[CHooks::class, 'testBothParametersHook']]
+ )
+ )]
+ public function testBothParametersHook(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/ModuleImplementsAlter.php b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/ModuleImplementsAlter.php
new file mode 100644
index 00000000000..76d3912bdeb
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/aaa_hook_order_test/src/Hook/ModuleImplementsAlter.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\aaa_hook_order_test\Hook;
+
+/**
+ * Contains a replaceable callback for hook_module_implements_alter().
+ */
+class ModuleImplementsAlter {
+
+ /**
+ * Callback for hook_module_implements_alter().
+ *
+ * @var ?\Closure
+ * @phpstan-var (\Closure(array<string, string|false>&, string): void)|null
+ */
+ private static ?\Closure $callback = NULL;
+
+ /**
+ * Sets a callback for hook_module_implements_alter().
+ *
+ * @param ?\Closure $callback
+ * Callback to set, or NULL to unset.
+ *
+ * @phpstan-param (\Closure(array<string, string|false>&, string): void)|null $callback
+ */
+ public static function set(?\Closure $callback): void {
+ self::$callback = $callback;
+ }
+
+ /**
+ * Invokes the registered callback.
+ *
+ * @param array<string, string|false> $implementations
+ * The implementations, as "group" by module name.
+ * @param string $hook
+ * The hook.
+ */
+ public static function call(array &$implementations, string $hook): void {
+ if (self::$callback === NULL) {
+ return;
+ }
+ (self::$callback)($implementations, $hook);
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/bbb_hook_order_test.info.yml b/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/bbb_hook_order_test.info.yml
new file mode 100644
index 00000000000..4b0667172e6
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/bbb_hook_order_test.info.yml
@@ -0,0 +1,6 @@
+name: BBB Hook order test
+type: module
+description: 'Test module used to test hook ordering.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
diff --git a/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/bbb_hook_order_test.module b/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/bbb_hook_order_test.module
new file mode 100644
index 00000000000..c2ac51b2f29
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/bbb_hook_order_test.module
@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * @file
+ * Contains procedural hook implementations.
+ */
+
+declare(strict_types=1);
+
+/**
+ * Implements hook_test_hook().
+ */
+function bbb_hook_order_test_test_hook(): string {
+ return __FUNCTION__;
+}
+
+/**
+ * Implements hook_procedural_alter().
+ */
+function bbb_hook_order_test_procedural_alter(array &$calls): void {
+ $calls[] = __FUNCTION__;
+}
+
+/**
+ * Implements hook_procedural_subtype_alter().
+ */
+function bbb_hook_order_test_procedural_subtype_alter(array &$calls): void {
+ $calls[] = __FUNCTION__;
+}
diff --git a/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/src/Hook/BAlterHooks.php b/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/src/Hook/BAlterHooks.php
new file mode 100644
index 00000000000..0be826094eb
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/src/Hook/BAlterHooks.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_order_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class BAlterHooks {
+
+ #[Hook('test_subtype_alter')]
+ public function testSubtypeAlter(array &$calls): void {
+ $calls[] = __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/src/Hook/BHooks.php b/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/src/Hook/BHooks.php
new file mode 100644
index 00000000000..4ebb35320dd
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/bbb_hook_order_test/src/Hook/BHooks.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\bbb_hook_order_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class BHooks {
+
+ #[Hook('test_hook')]
+ public function testHook(): string {
+ return __METHOD__;
+ }
+
+ #[Hook('sparse_test_hook')]
+ public function sparseTestHook(): string {
+ return __METHOD__;
+ }
+
+ #[Hook('test_both_parameters_hook')]
+ public function testBothParametersHook(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/ccc_hook_order_test.info.yml b/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/ccc_hook_order_test.info.yml
new file mode 100644
index 00000000000..d20a7a36ab1
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/ccc_hook_order_test.info.yml
@@ -0,0 +1,6 @@
+name: CCC Hook order test
+type: module
+description: 'Test module used to test hook ordering.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
diff --git a/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/ccc_hook_order_test.module b/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/ccc_hook_order_test.module
new file mode 100644
index 00000000000..3c6246298b7
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/ccc_hook_order_test.module
@@ -0,0 +1,36 @@
+<?php
+
+/**
+ * @file
+ * Contains procedural hook implementations.
+ */
+
+declare(strict_types=1);
+
+/**
+ * Implements hook_test_hook().
+ */
+function ccc_hook_order_test_test_hook(): string {
+ return __FUNCTION__;
+}
+
+/**
+ * Implements hook_sparse_test_hook().
+ */
+function ccc_hook_order_test_sparse_test_hook(): string {
+ return __FUNCTION__;
+}
+
+/**
+ * Implements hook_procedural_alter().
+ */
+function ccc_hook_order_test_procedural_alter(array &$calls): void {
+ $calls[] = __FUNCTION__;
+}
+
+/**
+ * Implements hook_procedural_subtype_alter().
+ */
+function ccc_hook_order_test_procedural_subtype_alter(array &$calls): void {
+ $calls[] = __FUNCTION__;
+}
diff --git a/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/src/Hook/CAlterHooks.php b/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/src/Hook/CAlterHooks.php
new file mode 100644
index 00000000000..f005daa0a38
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/src/Hook/CAlterHooks.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\ccc_hook_order_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class CAlterHooks {
+
+ #[Hook('test_alter')]
+ public function testAlter(array &$calls): void {
+ $calls[] = __METHOD__;
+ }
+
+ #[Hook('test_subtype_alter')]
+ public function testSubtypeAlter(array &$calls): void {
+ $calls[] = __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/src/Hook/CHooks.php b/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/src/Hook/CHooks.php
new file mode 100644
index 00000000000..92a8c23af13
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/ccc_hook_order_test/src/Hook/CHooks.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\ccc_hook_order_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Order\Order;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names. Some of the implementations are reordered
+ * using order attributes.
+ */
+class CHooks {
+
+ #[Hook('test_hook')]
+ public function testHook(): string {
+ return __METHOD__;
+ }
+
+ #[Hook('test_hook', order: Order::First)]
+ public function testHookFirst(): string {
+ return __METHOD__;
+ }
+
+ /**
+ * This implementation is reordered from elsewhere.
+ *
+ * @see \Drupal\ddd_hook_order_test\Hook\DHooks
+ */
+ #[Hook('test_hook')]
+ public function testHookReorderFirst(): string {
+ return __METHOD__;
+ }
+
+ /**
+ * This implementation is removed from elsewhere.
+ *
+ * @see \Drupal\ddd_hook_order_test\Hook\DHooks
+ */
+ #[Hook('test_hook')]
+ public function testHookRemoved(): string {
+ return __METHOD__;
+ }
+
+ #[Hook('test_both_parameters_hook')]
+ public function testBothParametersHook(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/ddd_hook_order_test.info.yml b/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/ddd_hook_order_test.info.yml
new file mode 100644
index 00000000000..df2c987a667
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/ddd_hook_order_test.info.yml
@@ -0,0 +1,6 @@
+name: DDD Hook order test
+type: module
+description: 'Test module used to test hook ordering.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
diff --git a/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/ddd_hook_order_test.module b/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/ddd_hook_order_test.module
new file mode 100644
index 00000000000..82cccc7ba6c
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/ddd_hook_order_test.module
@@ -0,0 +1,15 @@
+<?php
+
+/**
+ * @file
+ * Contains procedural hook implementations.
+ */
+
+declare(strict_types=1);
+
+/**
+ * Implements hook_test_hook().
+ */
+function ddd_hook_order_test_test_hook(): string {
+ return __FUNCTION__;
+}
diff --git a/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/src/Hook/DAlterHooks.php b/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/src/Hook/DAlterHooks.php
new file mode 100644
index 00000000000..7da7d91002c
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/src/Hook/DAlterHooks.php
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\ddd_hook_order_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names.
+ */
+class DAlterHooks {
+
+ #[Hook('test_alter')]
+ public function testAlter(array &$calls): void {
+ $calls[] = __METHOD__;
+ }
+
+ #[Hook('test_subtype_alter')]
+ public function testSubtypeAlter(array &$calls): void {
+ $calls[] = __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/src/Hook/DHooks.php b/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/src/Hook/DHooks.php
new file mode 100644
index 00000000000..053228642d6
--- /dev/null
+++ b/core/modules/system/tests/modules/HookOrder/ddd_hook_order_test/src/Hook/DHooks.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\ddd_hook_order_test\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Attribute\RemoveHook;
+use Drupal\Core\Hook\Attribute\ReorderHook;
+use Drupal\Core\Hook\Order\Order;
+use Drupal\ccc_hook_order_test\Hook\CHooks;
+
+/**
+ * This class contains hook implementations.
+ *
+ * By default, these will be called in module order, which is predictable due
+ * to the alphabetical module names.
+ */
+#[ReorderHook('test_hook', CHooks::class, 'testHookReorderFirst', Order::First)]
+#[RemoveHook('test_hook', CHooks::class, 'testHookRemoved')]
+class DHooks {
+
+ #[Hook('test_hook')]
+ public function testHook(): string {
+ return __METHOD__;
+ }
+
+ #[Hook('sparse_test_hook')]
+ public function sparseTestHook(): string {
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/common_test/common_test.module b/core/modules/system/tests/modules/common_test/common_test.module
index 4e978472953..5d7fdc3dc6b 100644
--- a/core/modules/system/tests/modules/common_test/common_test.module
+++ b/core/modules/system/tests/modules/common_test/common_test.module
@@ -42,24 +42,6 @@ function olivero_drupal_alter_alter(&$data, &$arg2 = NULL, &$arg3 = NULL): void
}
/**
- * Implements hook_module_implements_alter().
- *
- * @see block_drupal_alter_foo_alter()
- */
-function common_test_module_implements_alter(&$implementations, $hook): void {
- // For
- // \Drupal::moduleHandler()->alter(['drupal_alter', 'drupal_alter_foo'], ...),
- // make the block module implementations run after all the other modules. Note
- // that when \Drupal::moduleHandler->alter() is called with an array of types,
- // the first type is considered primary and controls the module order.
- if ($hook == 'drupal_alter_alter' && isset($implementations['block'])) {
- $group = $implementations['block'];
- unset($implementations['block']);
- $implementations['block'] = $group;
- }
-}
-
-/**
* Implements MODULE_preprocess().
*
* @see RenderTest::testDrupalRenderThemePreprocessAttached()
diff --git a/core/modules/system/tests/modules/hook_test_remove/hook_test_remove.info.yml b/core/modules/system/tests/modules/hook_test_remove/hook_test_remove.info.yml
new file mode 100644
index 00000000000..9245dd4208f
--- /dev/null
+++ b/core/modules/system/tests/modules/hook_test_remove/hook_test_remove.info.yml
@@ -0,0 +1,7 @@
+name: Hook test removal
+type: module
+description: 'Test module used to test hook removal.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
+hidden: true
diff --git a/core/modules/system/tests/modules/hook_test_remove/src/Hook/TestHookRemove.php b/core/modules/system/tests/modules/hook_test_remove/src/Hook/TestHookRemove.php
new file mode 100644
index 00000000000..3d4e53eba0b
--- /dev/null
+++ b/core/modules/system/tests/modules/hook_test_remove/src/Hook/TestHookRemove.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\hook_test_remove\Hook;
+
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Attribute\RemoveHook;
+
+/**
+ * Add a hook here, then remove it with another attribute.
+ */
+class TestHookRemove {
+
+ /**
+ * This hook should not be run because the next hook replaces it.
+ */
+ #[Hook('custom_hook1')]
+ public function hookDoNotRun(): string {
+ // This hook should not run.
+ return __METHOD__;
+ }
+
+ /**
+ * This hook should run and prevent custom_hook1.
+ */
+ #[Hook('custom_hook1')]
+ #[RemoveHook(
+ 'custom_hook1',
+ class: TestHookRemove::class,
+ method: 'hookDoNotRun'
+ )]
+ public function hookDoRun(): string {
+ // This hook should run.
+ return __METHOD__;
+ }
+
+}
diff --git a/core/modules/system/tests/modules/module_test/module_test.implementations.inc b/core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.implementations.inc
index b971af58c8c..7b6bac4ae95 100644
--- a/core/modules/system/tests/modules/module_test/module_test.implementations.inc
+++ b/core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.implementations.inc
@@ -10,8 +10,8 @@ declare(strict_types=1);
/**
* Implements hook_altered_test_hook().
*
- * @see module_test_module_implements_alter()
+ * @see module_implements_alter_test_module_implements_alter()
*/
-function module_test_altered_test_hook(): string {
+function module_implements_alter_test_altered_test_hook(): string {
return __FUNCTION__;
}
diff --git a/core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.info.yml b/core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.info.yml
new file mode 100644
index 00000000000..25995b17cd9
--- /dev/null
+++ b/core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.info.yml
@@ -0,0 +1,6 @@
+name: 'Test hook_module_implements_alter'
+type: module
+description: 'Support module for module system testing.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
diff --git a/core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.module b/core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.module
new file mode 100644
index 00000000000..c379d30c360
--- /dev/null
+++ b/core/modules/system/tests/modules/module_implements_alter_test/module_implements_alter_test.module
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @file
+ * Module file for test module.
+ */
+
+declare(strict_types=1);
+
+function test_auto_include(): void {}
+
+/**
+ * Implements hook_module_implements_alter().
+ *
+ * @see \Drupal\system\Tests\Module\ModuleImplementsAlterTest::testModuleImplementsAlter()
+ * @see module_implements_alter_test_module_implements_alter()
+ */
+function module_implements_alter_test_module_implements_alter(&$implementations, $hook): void {
+ if ($hook === 'altered_test_hook') {
+ // Add a hook implementation, that will be found in
+ // module_implements_alter_test.implementation.inc.
+ $implementations['module_implements_alter_test'] = 'implementations';
+ }
+ if ($hook === 'unimplemented_test_hook') {
+ // Add the non-existing function module_implements_alter_test_unimplemented_test_hook(). This
+ // should cause an exception to be thrown in
+ // \Drupal\Core\Extension\ModuleHandler::buildImplementationInfo('unimplemented_test_hook').
+ $implementations['module_implements_alter_test'] = FALSE;
+ }
+
+ // For
+ // \Drupal::moduleHandler()->alter(['drupal_alter', 'drupal_alter_foo'], ...),
+ // make the block module implementations run after all the other modules. Note
+ // that when \Drupal::moduleHandler->alter() is called with an array of types,
+ // the first type is considered primary and controls the module order.
+ if ($hook == 'drupal_alter_alter' && isset($implementations['block'])) {
+ $group = $implementations['block'];
+ unset($implementations['block']);
+ $implementations['block'] = $group;
+ }
+}
diff --git a/core/modules/system/tests/modules/module_test/module_test.module b/core/modules/system/tests/modules/module_test/module_test.module
index b7f320a35e3..35561f09c8a 100644
--- a/core/modules/system/tests/modules/module_test/module_test.module
+++ b/core/modules/system/tests/modules/module_test/module_test.module
@@ -95,23 +95,3 @@ function module_test_modules_uninstalled($modules): void {
// can check that the modules were uninstalled in the correct sequence.
\Drupal::state()->set('module_test.uninstall_order', $modules);
}
-
-/**
- * Implements hook_module_implements_alter().
- *
- * @see module_test_altered_test_hook()
- * @see \Drupal\system\Tests\Module\ModuleImplementsAlterTest::testModuleImplementsAlter()
- */
-function module_test_module_implements_alter(&$implementations, $hook): void {
- if ($hook === 'altered_test_hook') {
- // Add a hook implementation, that will be found in
- // module_test.implementation.inc.
- $implementations['module_test'] = 'implementations';
- }
- if ($hook === 'unimplemented_test_hook') {
- // Add the non-existing function module_test_unimplemented_test_hook(). This
- // should cause an exception to be thrown in
- // \Drupal\Core\Extension\ModuleHandler::buildImplementationInfo('unimplemented_test_hook').
- $implementations['module_test'] = FALSE;
- }
-}
diff --git a/core/modules/system/tests/src/Kernel/Common/AlterTest.php b/core/modules/system/tests/src/Kernel/Common/AlterTest.php
index 18217579ad9..0ff2115214f 100644
--- a/core/modules/system/tests/src/Kernel/Common/AlterTest.php
+++ b/core/modules/system/tests/src/Kernel/Common/AlterTest.php
@@ -19,11 +19,14 @@ class AlterTest extends KernelTestBase {
protected static $modules = [
'block',
'common_test',
+ 'module_implements_alter_test',
'system',
];
/**
* Tests if the theme has been altered.
+ *
+ * @group legacy
*/
public function testDrupalAlter(): void {
// This test depends on Olivero, so make sure that it is always the current
diff --git a/core/modules/workspaces/src/Hook/EntityOperations.php b/core/modules/workspaces/src/Hook/EntityOperations.php
index c459f4d065e..377ea62b2e8 100644
--- a/core/modules/workspaces/src/Hook/EntityOperations.php
+++ b/core/modules/workspaces/src/Hook/EntityOperations.php
@@ -12,6 +12,10 @@ use Drupal\Core\Entity\Query\QueryInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Hook\Attribute\ReorderHook;
+use Drupal\Core\Hook\Order\Order;
+use Drupal\Core\Hook\Order\OrderBefore;
+use Drupal\content_moderation\Hook\ContentModerationHooks;
use Drupal\workspaces\WorkspaceAssociationInterface;
use Drupal\workspaces\WorkspaceInformationInterface;
use Drupal\workspaces\WorkspaceManagerInterface;
@@ -72,7 +76,12 @@ class EntityOperations {
/**
* Implements hook_entity_presave().
*/
- #[Hook('entity_presave')]
+ #[Hook('entity_presave', order: Order::First)]
+ #[ReorderHook('entity_presave',
+ class: ContentModerationHooks::class,
+ method: 'entityPresave',
+ order: new OrderBefore(['workspaces'])
+ )]
public function entityPresave(EntityInterface $entity): void {
if ($this->shouldSkipOperations($entity)) {
return;
@@ -129,7 +138,7 @@ class EntityOperations {
/**
* Implements hook_entity_insert().
*/
- #[Hook('entity_insert')]
+ #[Hook('entity_insert', order: Order::Last)]
public function entityInsert(EntityInterface $entity): void {
if ($entity->getEntityTypeId() === 'workspace') {
$this->workspaceAssociation->workspaceInsert($entity);
diff --git a/core/modules/workspaces/workspaces.module b/core/modules/workspaces/workspaces.module
deleted file mode 100644
index a053105f20c..00000000000
--- a/core/modules/workspaces/workspaces.module
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-/**
- * @file
- */
-
-/**
- * Implements hook_module_implements_alter().
- */
-function workspaces_module_implements_alter(&$implementations, $hook): void {
- // Move our 'hook_entity_presave' implementation at the beginning to ensure
- // that other presave implementations are aware of the changes done in
- // \Drupal\workspaces\Hook\EntityOperations::entityPresave().
- if ($hook === 'entity_presave') {
- $implementation = $implementations['workspaces'];
- $implementations = ['workspaces' => $implementation] + $implementations;
-
- // Move Content Moderation's implementation before Workspaces, so we can
- // alter the publishing status for the default revision.
- if (isset($implementations['content_moderation'])) {
- $implementation = $implementations['content_moderation'];
- $implementations = ['content_moderation' => $implementation] + $implementations;
- }
- }
-
- // Move our 'hook_entity_insert' implementation at the end to ensure that
- // the second (pending) revision created for published entities is not used
- // by other 'hook_entity_insert' implementations.
- // @see \Drupal\workspaces\Hook\EntityOperations::entityInsert()
- if ($hook === 'entity_insert') {
- $group = $implementations['workspaces'];
- unset($implementations['workspaces']);
- $implementations['workspaces'] = $group;
- }
-}
diff --git a/core/tests/Drupal/KernelTests/Core/Extension/ModuleImplementsAlterTest.php b/core/tests/Drupal/KernelTests/Core/Extension/ModuleImplementsAlterTest.php
index ef0022b1bfa..2a8ad684b53 100644
--- a/core/tests/Drupal/KernelTests/Core/Extension/ModuleImplementsAlterTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Extension/ModuleImplementsAlterTest.php
@@ -10,6 +10,8 @@ use Drupal\KernelTests\KernelTestBase;
* Tests hook_module_implements_alter().
*
* @group Module
+ *
+ * @group legacy
*/
class ModuleImplementsAlterTest extends KernelTestBase {
@@ -22,7 +24,7 @@ class ModuleImplementsAlterTest extends KernelTestBase {
* Tests hook_module_implements_alter() adding an implementation.
*
* @see \Drupal\Core\Extension\ModuleHandler::buildImplementationInfo()
- * @see module_test_module_implements_alter()
+ * @see module_implements_alter_test_module_implements_alter()
*/
public function testModuleImplementsAlter(): void {
@@ -32,38 +34,32 @@ class ModuleImplementsAlterTest extends KernelTestBase {
$this->assertSame(\Drupal::moduleHandler(), $module_handler, 'Module handler instance is still the same.');
- // Install the module_test module.
- \Drupal::service('module_installer')->install(['module_test']);
+ // Install the module_implements_alter_test module.
+ \Drupal::service('module_installer')->install(['module_implements_alter_test']);
// Assert that the \Drupal::moduleHandler() instance has been replaced.
$this->assertNotSame(\Drupal::moduleHandler(), $module_handler, 'The \Drupal::moduleHandler() instance has been replaced during \Drupal::moduleHandler()->install().');
- // Assert that module_test.module is now included.
- $this->assertTrue(function_exists('module_test_modules_installed'),
- 'The file module_test.module was successfully included.');
-
- $this->assertArrayHasKey('module_test', \Drupal::moduleHandler()->getModuleList());
-
- $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('modules_installed', 'module_test'),
- 'module_test implements hook_modules_installed().');
+ // Assert that module_implements_alter_test.module is now included.
+ $this->assertTrue(function_exists('test_auto_include'),
+ 'The file module_implements_alter_test.module was successfully included.');
- $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('module_implements_alter', 'module_test'),
- 'module_test implements hook_module_implements_alter().');
+ $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('module_implements_alter', 'module_implements_alter_test'),
+ 'module_implements_alter_test implements hook_module_implements_alter().');
- // Assert that module_test.implementations.inc is not included yet.
- $this->assertFalse(function_exists('module_test_altered_test_hook'),
- 'The file module_test.implementations.inc is not included yet.');
+ // Assert that module_implements_alter_test.implementations.inc is not included yet.
+ $this->assertFalse(function_exists('module_implements_alter_test_altered_test_hook'),
+ 'The file module_implements_alter_test.implementations.inc is not included yet.');
// Trigger hook discovery for hook_altered_test_hook().
- // Assert that module_test_module_implements_alter(*, 'altered_test_hook')
+ // Assert that module_implements_alter_test_module_implements_alter(*, 'altered_test_hook')
// has added an implementation.
- $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('altered_test_hook', 'module_test'),
- 'module_test implements hook_altered_test_hook().');
+ $this->assertTrue(\Drupal::moduleHandler()->hasImplementations('altered_test_hook', 'module_implements_alter_test'),
+ 'module_implements_alter_test implements hook_altered_test_hook().');
- // Assert that module_test.implementations.inc was included as part of the
- // process.
- $this->assertTrue(function_exists('module_test_altered_test_hook'),
- 'The file module_test.implementations.inc was included.');
+ // Assert that module_implements_alter_test.implementations.inc was included as part of the process.
+ $this->assertTrue(function_exists('module_implements_alter_test_altered_test_hook'),
+ 'The file module_implements_alter_test.implementations.inc was included.');
}
}
diff --git a/core/tests/Drupal/KernelTests/Core/Hook/HookAlterOrderTest.php b/core/tests/Drupal/KernelTests/Core/Hook/HookAlterOrderTest.php
new file mode 100644
index 00000000000..12873c53c7c
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Hook/HookAlterOrderTest.php
@@ -0,0 +1,232 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Hook;
+
+use Drupal\aaa_hook_order_test\Hook\AAlterHooks;
+use Drupal\aaa_hook_order_test\Hook\ModuleImplementsAlter;
+use Drupal\bbb_hook_order_test\Hook\BAlterHooks;
+use Drupal\ccc_hook_order_test\Hook\CAlterHooks;
+use Drupal\ddd_hook_order_test\Hook\DAlterHooks;
+use Drupal\KernelTests\KernelTestBase;
+use PHPUnit\Framework\Attributes\IgnoreDeprecations;
+
+/**
+ * @group Hook
+ */
+#[IgnoreDeprecations]
+class HookAlterOrderTest extends KernelTestBase {
+
+ use HookOrderTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'aaa_hook_order_test',
+ 'bbb_hook_order_test',
+ 'ccc_hook_order_test',
+ 'ddd_hook_order_test',
+ ];
+
+ /**
+ * Tests procedural implementations of module implements alter ordering.
+ */
+ public function testProceduralModuleImplementsAlterOrder(): void {
+ $this->assertAlterCallOrder($main_unaltered = [
+ 'aaa_hook_order_test_procedural_alter',
+ 'bbb_hook_order_test_procedural_alter',
+ 'ccc_hook_order_test_procedural_alter',
+ ], 'procedural');
+
+ $this->assertAlterCallOrder($sub_unaltered = [
+ 'aaa_hook_order_test_procedural_subtype_alter',
+ 'bbb_hook_order_test_procedural_subtype_alter',
+ 'ccc_hook_order_test_procedural_subtype_alter',
+ ], 'procedural_subtype');
+
+ $this->assertAlterCallOrder($combined_unaltered = [
+ 'aaa_hook_order_test_procedural_alter',
+ 'aaa_hook_order_test_procedural_subtype_alter',
+ 'bbb_hook_order_test_procedural_alter',
+ 'bbb_hook_order_test_procedural_subtype_alter',
+ 'ccc_hook_order_test_procedural_alter',
+ 'ccc_hook_order_test_procedural_subtype_alter',
+ ], ['procedural', 'procedural_subtype']);
+
+ $move_b_down = function (array &$implementations): void {
+ // Move B to the end, no matter which hook.
+ $group = $implementations['bbb_hook_order_test'];
+ unset($implementations['bbb_hook_order_test']);
+ $implementations['bbb_hook_order_test'] = $group;
+ };
+ $modules = ['aaa_hook_order_test', 'bbb_hook_order_test', 'ccc_hook_order_test'];
+
+ // Test with module B moved to the end for both hooks.
+ ModuleImplementsAlter::set(
+ function (array &$implementations, string $hook) use ($modules, $move_b_down): void {
+ if (!in_array($hook, ['procedural_alter', 'procedural_subtype_alter'])) {
+ return;
+ }
+ $this->assertSame($modules, array_keys($implementations));
+ $move_b_down($implementations);
+ },
+ );
+ \Drupal::service('kernel')->rebuildContainer();
+
+ $this->assertAlterCallOrder($main_altered = [
+ 'aaa_hook_order_test_procedural_alter',
+ 'ccc_hook_order_test_procedural_alter',
+ // The implementation of B has been moved.
+ 'bbb_hook_order_test_procedural_alter',
+ ], 'procedural');
+
+ $this->assertAlterCallOrder($sub_altered = [
+ 'aaa_hook_order_test_procedural_subtype_alter',
+ 'ccc_hook_order_test_procedural_subtype_alter',
+ // The implementation of B has been moved.
+ 'bbb_hook_order_test_procedural_subtype_alter',
+ ], 'procedural_subtype');
+
+ $this->assertAlterCallOrder($combined_altered = [
+ 'aaa_hook_order_test_procedural_alter',
+ 'aaa_hook_order_test_procedural_subtype_alter',
+ 'ccc_hook_order_test_procedural_alter',
+ 'ccc_hook_order_test_procedural_subtype_alter',
+ // The implementation of B has been moved.
+ 'bbb_hook_order_test_procedural_alter',
+ 'bbb_hook_order_test_procedural_subtype_alter',
+ ], ['procedural', 'procedural_subtype']);
+
+ // If the altered hook is not the first one, implementations are back in
+ // their unaltered order.
+ $this->assertAlterCallOrder($main_unaltered, ['other_main_type', 'procedural']);
+ $this->assertAlterCallOrder($sub_unaltered, ['other_main_type', 'procedural_subtype']);
+ $this->assertAlterCallOrder($combined_unaltered, ['other_main_type', 'procedural', 'procedural_subtype']);
+
+ // Test with module B moved to the end for the main hook.
+ ModuleImplementsAlter::set(
+ function (array &$implementations, string $hook) use ($modules, $move_b_down): void {
+ if (!in_array($hook, ['procedural_alter', 'procedural_subtype_alter'])) {
+ return;
+ }
+ $this->assertSame($modules, array_keys($implementations));
+ if ($hook !== 'procedural_alter') {
+ return;
+ }
+ $move_b_down($implementations);
+ },
+ );
+ \Drupal::service('kernel')->rebuildContainer();
+
+ $this->assertAlterCallOrder($main_altered, 'procedural');
+ $this->assertAlterCallOrder($sub_unaltered, 'procedural_subtype');
+ $this->assertAlterCallOrder($combined_altered, ['procedural', 'procedural_subtype']);
+
+ // Test with module B moved to the end for the subtype hook.
+ ModuleImplementsAlter::set(
+ function (array &$implementations, string $hook) use ($modules, $move_b_down): void {
+ if (!in_array($hook, ['procedural_alter', 'procedural_subtype_alter'])) {
+ return;
+ }
+ $this->assertSameCallList($modules, array_keys($implementations));
+ if ($hook !== 'procedural_subtype_alter') {
+ return;
+ }
+ $move_b_down($implementations);
+ },
+ );
+ \Drupal::service('kernel')->rebuildContainer();
+
+ $this->assertAlterCallOrder($main_unaltered, 'procedural');
+ $this->assertAlterCallOrder($sub_altered, 'procedural_subtype');
+ $this->assertAlterCallOrder($combined_unaltered, ['procedural', 'procedural_subtype']);
+ }
+
+ /**
+ * Test ordering alter calls.
+ */
+ public function testAlterOrder(): void {
+ $this->assertAlterCallOrder([
+ CAlterHooks::class . '::testAlter',
+ AAlterHooks::class . '::testAlterAfterC',
+ DAlterHooks::class . '::testAlter',
+ ], 'test');
+
+ $this->assertAlterCallOrder([
+ AAlterHooks::class . '::testSubtypeAlter',
+ BAlterHooks::class . '::testSubtypeAlter',
+ CAlterHooks::class . '::testSubtypeAlter',
+ DAlterHooks::class . '::testSubtypeAlter',
+ ], 'test_subtype');
+
+ $this->assertAlterCallOrder([
+ // The implementation from 'D' is gone.
+ AAlterHooks::class . '::testSubtypeAlter',
+ BAlterHooks::class . '::testSubtypeAlter',
+ CAlterHooks::class . '::testAlter',
+ CAlterHooks::class . '::testSubtypeAlter',
+ AAlterHooks::class . '::testAlterAfterC',
+ DAlterHooks::class . '::testAlter',
+ DAlterHooks::class . '::testSubtypeAlter',
+ ], ['test', 'test_subtype']);
+
+ $this->disableModules(['bbb_hook_order_test']);
+
+ $this->assertAlterCallOrder([
+ CAlterHooks::class . '::testAlter',
+ AAlterHooks::class . '::testAlterAfterC',
+ DAlterHooks::class . '::testAlter',
+ ], 'test');
+
+ $this->assertAlterCallOrder([
+ AAlterHooks::class . '::testSubtypeAlter',
+ CAlterHooks::class . '::testSubtypeAlter',
+ DAlterHooks::class . '::testSubtypeAlter',
+ ], 'test_subtype');
+
+ $this->assertAlterCallOrder([
+ AAlterHooks::class . '::testSubtypeAlter',
+ CAlterHooks::class . '::testAlter',
+ CAlterHooks::class . '::testSubtypeAlter',
+ AAlterHooks::class . '::testAlterAfterC',
+ DAlterHooks::class . '::testAlter',
+ DAlterHooks::class . '::testSubtypeAlter',
+ ], ['test', 'test_subtype']);
+ }
+
+ /**
+ * Asserts the call order from an alter call.
+ *
+ * Also asserts additional $type argument values that are meant to produce the
+ * same result.
+ *
+ * @param list<string> $expected
+ * Expected call list, as strings from __METHOD__ or __FUNCTION__.
+ * @param string|list<string> $type
+ * First argument to pass to ->alter().
+ */
+ protected function assertAlterCallOrder(array $expected, string|array $type): void {
+ $this->assertSameCallList(
+ $expected,
+ $this->alter($type),
+ );
+ }
+
+ /**
+ * Invokes ModuleHandler->alter() and returns the altered array.
+ *
+ * @param string|list<string> $type
+ * Alter type or list of alter types.
+ *
+ * @return array
+ * The altered array.
+ */
+ protected function alter(string|array $type): array {
+ $data = [];
+ \Drupal::moduleHandler()->alter($type, $data);
+ return $data;
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php b/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php
index 4156481d3b9..774f1289ccd 100644
--- a/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php
@@ -52,6 +52,8 @@ class HookCollectorPassTest extends KernelTestBase {
/**
* Test that ordering works.
+ *
+ * @group legacy
*/
public function testOrdering(): void {
$container = new ContainerBuilder();
@@ -83,6 +85,23 @@ class HookCollectorPassTest extends KernelTestBase {
}
/**
+ * Test LegacyModuleImplementsAlter.
+ */
+ public function testLegacyModuleImplementsAlter(): void {
+ $container = new ContainerBuilder();
+ $module_filenames = [
+ 'module_implements_alter_test_legacy' => ['pathname' => "core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.info.yml"],
+ ];
+ include_once 'core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.module';
+ $container->setParameter('container.modules', $module_filenames);
+ $container->setDefinition('module_handler', new Definition());
+ (new HookCollectorPass())->process($container);
+
+ // This test will also fail if the deprecation notice shows up.
+ $this->assertFalse(isset($GLOBALS['ShouldNotRunLegacyModuleImplementsAlter']));
+ }
+
+ /**
* Test hooks implemented on behalf of an uninstalled module.
*
* They should be picked up but only executed when the other
@@ -121,7 +140,6 @@ class HookCollectorPassTest extends KernelTestBase {
$this->assertFalse(isset($GLOBALS['procedural_attribute_skip_after_attribute']));
$this->assertTrue(isset($GLOBALS['procedural_attribute_skip_find']));
$this->assertTrue(isset($GLOBALS['skipped_procedural_oop_cache_flush']));
-
}
/**
@@ -137,4 +155,150 @@ class HookCollectorPassTest extends KernelTestBase {
$this->assertTrue(isset($GLOBALS['hook_invoke_method']));
}
+ /**
+ * Tests hook ordering with attributes.
+ */
+ public function testHookFirst(): void {
+ $module_installer = $this->container->get('module_installer');
+ $module_installer->install(['aaa_hook_collector_test']);
+ $module_installer->install(['bbb_hook_collector_test']);
+ $module_handler = $this->container->get('module_handler');
+ // Last alphabetically uses the Order::First enum to place it before
+ // the implementation it would naturally come after.
+ $expected_calls = [
+ 'Drupal\bbb_hook_collector_test\Hook\TestHookFirst::hookFirst',
+ 'Drupal\aaa_hook_collector_test\Hook\TestHookFirst::hookFirst',
+ ];
+ $calls = $module_handler->invokeAll('custom_hook_test_hook_first');
+ $this->assertEquals($expected_calls, $calls);
+ }
+
+ /**
+ * Tests hook ordering with attributes.
+ */
+ public function testHookAfter(): void {
+ $module_installer = $this->container->get('module_installer');
+ $module_installer->install(['aaa_hook_collector_test']);
+ $module_installer->install(['bbb_hook_collector_test']);
+ $module_handler = $this->container->get('module_handler');
+ // First alphabetically uses the OrderAfter to place it after
+ // the implementation it would naturally come before.
+ $expected_calls = [
+ 'Drupal\bbb_hook_collector_test\Hook\TestHookAfter::hookAfter',
+ 'Drupal\aaa_hook_collector_test\Hook\TestHookAfter::hookAfter',
+ ];
+ $calls = $module_handler->invokeAll('custom_hook_test_hook_after');
+ $this->assertEquals($expected_calls, $calls);
+ }
+
+ /**
+ * Tests hook ordering with attributes.
+ */
+ public function testHookAfterClassMethod(): void {
+ $module_installer = $this->container->get('module_installer');
+ $module_installer->install(['aaa_hook_collector_test']);
+ $module_installer->install(['bbb_hook_collector_test']);
+ $module_handler = $this->container->get('module_handler');
+ // First alphabetically uses the OrderAfter to place it after
+ // the implementation it would naturally come before using call and method.
+ $expected_calls = [
+ 'Drupal\bbb_hook_collector_test\Hook\TestHookAfterClassMethod::hookAfterClassMethod',
+ 'Drupal\aaa_hook_collector_test\Hook\TestHookAfterClassMethod::hookAfterClassMethod',
+ ];
+ $calls = $module_handler->invokeAll('custom_hook_test_hook_after_class_method');
+ $this->assertEquals($expected_calls, $calls);
+ }
+
+ /**
+ * Tests hook ordering with attributes.
+ */
+ public function testHookBefore(): void {
+ $module_installer = $this->container->get('module_installer');
+ $module_installer->install(['aaa_hook_collector_test']);
+ $module_installer->install(['bbb_hook_collector_test']);
+ $module_handler = $this->container->get('module_handler');
+ // First alphabetically uses the OrderBefore to place it before
+ // the implementation it would naturally come after.
+ $expected_calls = [
+ 'Drupal\bbb_hook_collector_test\Hook\TestHookBefore::hookBefore',
+ 'Drupal\aaa_hook_collector_test\Hook\TestHookBefore::hookBefore',
+ ];
+ $calls = $module_handler->invokeAll('custom_hook_test_hook_before');
+ $this->assertEquals($expected_calls, $calls);
+ }
+
+ /**
+ * Tests hook ordering with attributes.
+ */
+ public function testHookOrderExtraTypes(): void {
+ $module_installer = $this->container->get('module_installer');
+ $module_installer->install(['aaa_hook_collector_test']);
+ $module_installer->install(['bbb_hook_collector_test']);
+ $module_handler = $this->container->get('module_handler');
+ // First alphabetically uses the OrderAfter to place it after
+ // the implementation it would naturally come before.
+ $expected_calls = [
+ 'Drupal\bbb_hook_collector_test\Hook\TestHookOrderExtraTypes::customHookExtraTypes',
+ 'Drupal\aaa_hook_collector_test\Hook\TestHookOrderExtraTypes::customHookExtraTypes',
+ ];
+ $hooks = [
+ 'custom_hook',
+ 'custom_hook_extra_types1',
+ 'custom_hook_extra_types2',
+ ];
+ $calls = [];
+ $module_handler->alter($hooks, $calls);
+ $this->assertEquals($expected_calls, $calls);
+ }
+
+ /**
+ * Tests hook ordering with attributes.
+ */
+ public function testHookLast(): void {
+ $module_installer = $this->container->get('module_installer');
+ $module_installer->install(['aaa_hook_collector_test']);
+ $module_installer->install(['bbb_hook_collector_test']);
+ $module_handler = $this->container->get('module_handler');
+ // First alphabetically uses the OrderBefore to place it before
+ // the implementation it would naturally come after.
+ $expected_calls = [
+ 'Drupal\bbb_hook_collector_test\Hook\TestHookLast::hookLast',
+ 'Drupal\aaa_hook_collector_test\Hook\TestHookLast::hookLast',
+ ];
+ $calls = $module_handler->invokeAll('custom_hook_test_hook_last');
+ $this->assertEquals($expected_calls, $calls);
+ }
+
+ /**
+ * Tests hook remove.
+ */
+ public function testHookRemove(): void {
+ $module_installer = $this->container->get('module_installer');
+ $this->assertTrue($module_installer->install(['hook_test_remove']));
+ $module_handler = $this->container->get('module_handler');
+ // There are two hooks implementing custom_hook1.
+ // One is removed with RemoveHook so it should not run.
+ $expected_calls = [
+ 'Drupal\hook_test_remove\Hook\TestHookRemove::hookDoRun',
+ ];
+ $calls = $module_handler->invokeAll('custom_hook1');
+ $this->assertEquals($expected_calls, $calls);
+ }
+
+ /**
+ * Tests hook override.
+ */
+ public function testHookOverride(): void {
+ $module_installer = $this->container->get('module_installer');
+ $module_installer->install(['aaa_hook_collector_test']);
+ $module_installer->install(['bbb_hook_collector_test']);
+ $module_handler = $this->container->get('module_handler');
+ $expected_calls = [
+ 'Drupal\aaa_hook_collector_test\Hook\TestHookReorderHookFirst::customHookOverride',
+ 'Drupal\bbb_hook_collector_test\Hook\TestHookReorderHookLast::customHookOverride',
+ ];
+ $calls = $module_handler->invokeAll('custom_hook_override');
+ $this->assertEquals($expected_calls, $calls);
+ }
+
}
diff --git a/core/tests/Drupal/KernelTests/Core/Hook/HookOrderTest.php b/core/tests/Drupal/KernelTests/Core/Hook/HookOrderTest.php
new file mode 100644
index 00000000000..c558e8146bb
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Hook/HookOrderTest.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Hook;
+
+use Drupal\aaa_hook_order_test\Hook\AHooks;
+use Drupal\bbb_hook_order_test\Hook\BHooks;
+use Drupal\ccc_hook_order_test\Hook\CHooks;
+use Drupal\ddd_hook_order_test\Hook\DHooks;
+use Drupal\KernelTests\KernelTestBase;
+use PHPUnit\Framework\Attributes\IgnoreDeprecations;
+
+/**
+ * @group Hook
+ */
+#[IgnoreDeprecations]
+class HookOrderTest extends KernelTestBase {
+
+ use HookOrderTestTrait;
+
+ /**
+ * {@inheritdoc}
+ */
+ protected static $modules = [
+ 'aaa_hook_order_test',
+ 'bbb_hook_order_test',
+ 'ccc_hook_order_test',
+ 'ddd_hook_order_test',
+ ];
+
+ /**
+ * Test hook implementation order.
+ */
+ public function testHookOrder(): void {
+ $this->assertSameCallList(
+ [
+ CHooks::class . '::testHookReorderFirst',
+ CHooks::class . '::testHookFirst',
+ AHooks::class . '::testHookFirst',
+ 'aaa_hook_order_test_test_hook',
+ AHooks::class . '::testHook',
+ 'bbb_hook_order_test_test_hook',
+ BHooks::class . '::testHook',
+ AHooks::class . '::testHookAfterB',
+ 'ccc_hook_order_test_test_hook',
+ CHooks::class . '::testHook',
+ 'ddd_hook_order_test_test_hook',
+ DHooks::class . '::testHook',
+ AHooks::class . '::testHookLast',
+ ],
+ \Drupal::moduleHandler()->invokeAll('test_hook'),
+ );
+ }
+
+ /**
+ * Tests hook order when each module has either oop or procedural listeners.
+ *
+ * This would detect a possible mistake where we would first collect modules
+ * from all procedural and then from all oop implementations, without fixing
+ * the order.
+ */
+ public function testSparseHookOrder(): void {
+ $this->assertSameCallList(
+ [
+ // OOP and procedural listeners are correctly intermixed by module
+ // order.
+ 'aaa_hook_order_test_sparse_test_hook',
+ BHooks::class . '::sparseTestHook',
+ 'ccc_hook_order_test_sparse_test_hook',
+ DHooks::class . '::sparseTestHook',
+ ],
+ \Drupal::moduleHandler()->invokeAll('sparse_test_hook'),
+ );
+ }
+
+ /**
+ * Tests hook order when both parameters are passed to RelativeOrderBase.
+ *
+ * This tests when both $modules and $classesAndMethods are passed as
+ * parameters to OrderAfter.
+ */
+ public function testBothParametersHookOrder(): void {
+ $this->assertSameCallList(
+ [
+ BHooks::class . '::testBothParametersHook',
+ CHooks::class . '::testBothParametersHook',
+ AHooks::class . '::testBothParametersHook',
+ ],
+ \Drupal::moduleHandler()->invokeAll('test_both_parameters_hook'),
+ );
+ }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Hook/HookOrderTestTrait.php b/core/tests/Drupal/KernelTests/Core/Hook/HookOrderTestTrait.php
new file mode 100644
index 00000000000..15238c7b33c
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Hook/HookOrderTestTrait.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Hook;
+
+/**
+ * @group Hook
+ */
+trait HookOrderTestTrait {
+
+ /**
+ * Asserts that two lists of call strings are the same.
+ *
+ * It is meant for strings produced with __FUNCTION__ or __METHOD__.
+ *
+ * The assertion fails exactly when a regular ->assertSame() would fail, but
+ * it provides a more useful output on failure.
+ *
+ * @param list<string> $expected
+ * Expected list of strings.
+ * @param list<string> $actual
+ * Actual list of strings.
+ * @param string $message
+ * Message to pass to ->assertSame().
+ */
+ protected function assertSameCallList(array $expected, array $actual, string $message = ''): void {
+ // Format without the numeric array keys, but in a way that can be easily
+ // copied into the test.
+ $format = function (array $strings): string {
+ if (!$strings) {
+ return '[]';
+ }
+ $parts = array_map(
+ static function (string $call_string) {
+ if (preg_match('@^(\w+\\\\)*(\w+)::(\w+)@', $call_string, $matches)) {
+ [,, $class_shortname, $method] = $matches;
+ return $class_shortname . '::class . ' . var_export('::' . $method, TRUE);
+ }
+ return var_export($call_string, TRUE);
+ },
+ $strings,
+ );
+ return "[\n " . implode(",\n ", $parts) . ",\n]";
+ };
+ $this->assertSame(
+ $format($expected),
+ $format($actual),
+ $message,
+ );
+ // Finally, assert that array keys and the full class names are really the
+ // same, in a way that provides useful output on failure.
+ $this->assertSame($expected, $actual, $message);
+ }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
index 3b36018806f..05a064eab7d 100644
--- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
@@ -102,6 +102,8 @@ class ModuleHandlerTest extends UnitTestCase {
* Tests loading all modules.
*
* @covers ::loadAll
+ *
+ * @group legacy
*/
public function testLoadAllModules(): void {
$moduleList = [
@@ -352,6 +354,8 @@ class ModuleHandlerTest extends UnitTestCase {
* Tests invoke all.
*
* @covers ::invokeAll
+ *
+ * @group legacy
*/
public function testInvokeAll(): void {
$implementations = [
@@ -382,7 +386,7 @@ class ModuleHandlerTest extends UnitTestCase {
};
$implementations['some_hook'][get_class($c)]['some_method'] = 'some_module';
- $module_handler = new ModuleHandler($this->root, [], $this->eventDispatcher, $implementations, []);
+ $module_handler = new ModuleHandler($this->root, [], $this->eventDispatcher, $implementations);
$module_handler->setModuleList(['some_module' => TRUE]);
$r = new \ReflectionObject($module_handler);
diff --git a/core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.info.yml b/core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.info.yml
new file mode 100644
index 00000000000..e286a4ffac3
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.info.yml
@@ -0,0 +1,6 @@
+name: 'Module for testing LegacyModuleImplementsAlter'
+type: module
+description: 'Support module for module system testing.'
+package: Testing
+version: VERSION
+core_version_requirement: '*'
diff --git a/core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.module b/core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.module
new file mode 100644
index 00000000000..355b5616949
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Extension/modules/module_implements_alter_test_legacy/module_implements_alter_test_legacy.module
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * @file
+ * Module file for test module.
+ */
+
+declare(strict_types=1);
+
+use Drupal\Core\Hook\Attribute\LegacyModuleImplementsAlter;
+
+/**
+ * Implements hook_module_implements_alter().
+ *
+ * @see \Drupal\system\Tests\Module\ModuleImplementsAlterTest::testModuleImplementsAlter()
+ */
+#[LegacyModuleImplementsAlter]
+function module_implements_alter_test_legacy_module_implements_alter(&$implementations, $hook): void {
+ $GLOBALS['ShouldNotRunLegacyModuleImplementsAlter'] = TRUE;
+}
diff --git a/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php b/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php
index 4b92b8d6d25..2d6fba3b451 100644
--- a/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php
+++ b/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php
@@ -6,7 +6,6 @@ namespace Drupal\Tests\Core\Hook;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Extension\ProceduralCall;
-use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Hook\HookCollectorPass;
use Drupal\Tests\UnitTestCase;
use Drupal\Tests\Core\GroupIncludesTestTrait;
@@ -85,35 +84,4 @@ __EOF__
$this->assertSame(self::GROUP_INCLUDES, $argument);
}
- /**
- * @covers ::getHookAttributesInClass
- */
- public function testGetHookAttributesInClass(): void {
- // @phpstan-ignore-next-line
- $getHookAttributesInClass = fn ($class) => $this->getHookAttributesInClass($class);
- $p = new HookCollectorPass();
- $getHookAttributesInClass = $getHookAttributesInClass->bindTo($p, $p);
-
- $x = new class {
-
- #[Hook('foo')]
- function foo(): void {}
-
- };
- $hooks = $getHookAttributesInClass(get_class($x));
- $hook = reset($hooks);
- $this->assertInstanceOf(Hook::class, $hook);
- $this->assertSame('foo', $hook->hook);
-
- $x = new class {
-
- #[Hook('install')]
- function foo(): void {}
-
- };
- $this->expectException(\LogicException::class);
- // This will throw exception, and stop code execution.
- $getHookAttributesInClass(get_class($x));
- }
-
}