From 96f3a9369fc9eba81b1ffcea8eb82187ce48a9bf Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Fri, 2 May 2025 01:11:23 +0200
Subject: [PATCH 01/13] Add docs on ModuleHandlerTest::getModuleHandler().

---
 .../Drupal/Tests/Core/Extension/ModuleHandlerTest.php      | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
index 05a064eab7df..fb6b84dc7a39 100644
--- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
@@ -48,6 +48,13 @@ protected function setUp(): void {
    * ModuleHandler objects as a property in unit tests. They must be generated
    * by the test method by calling this method.
    *
+   * @param list<string, string> $modules
+   *   Module paths by module name.
+   * @param array<callable-string, string> $implementations
+   *   Module names by function name implementing hook_hook().
+   * @param bool $loadAll
+   *   TRUE to call ModuleHandler->loadAll() on the new module handler.
+   *
    * @return \Drupal\Core\Extension\ModuleHandler
    *   The module handler to test.
    */
-- 
GitLab


From faaee229dc2f4300e3e680a58a012d6f38bc64da Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Fri, 2 May 2025 02:32:12 +0200
Subject: [PATCH 02/13] Reorder imports in ModuleHandlerTest.php.

---
 core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
index fb6b84dc7a39..3f653e783459 100644
--- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
@@ -4,12 +4,12 @@
 
 namespace Drupal\Tests\Core\Extension;
 
+use Drupal\Core\Extension\Exception\UnknownExtensionException;
 use Drupal\Core\Extension\Extension;
 use Drupal\Core\Extension\ModuleHandler;
-use Drupal\Core\Extension\Exception\UnknownExtensionException;
 use Drupal\Core\Extension\ProceduralCall;
-use Drupal\Tests\UnitTestCase;
 use Drupal\Tests\Core\GroupIncludesTestTrait;
+use Drupal\Tests\UnitTestCase;
 use Symfony\Component\EventDispatcher\EventDispatcher;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 
-- 
GitLab


From 3c15bc3f98bfa82d44ce2d3dda0981d61268f266 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Sat, 29 Mar 2025 15:00:50 +0100
Subject: [PATCH 03/13] Introduce ImplementationList.

---
 .../Drupal/Core/Extension/ModuleHandler.php   |  85 ++++++-------
 .../Drupal/Core/Hook/ImplementationList.php   | 100 +++++++++++++++
 .../Core/Hook/ImplementationListTest.php      | 119 ++++++++++++++++++
 3 files changed, 257 insertions(+), 47 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Hook/ImplementationList.php
 create mode 100644 core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php

diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php
index ad361d3fe669..383d83dd1d2b 100644
--- a/core/lib/Drupal/Core/Extension/ModuleHandler.php
+++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php
@@ -8,6 +8,7 @@
 use Drupal\Core\Extension\Exception\UnknownExtensionException;
 use Drupal\Core\Hook\Attribute\LegacyHook;
 use Drupal\Core\Hook\HookCollectorPass;
+use Drupal\Core\Hook\ImplementationList;
 use Drupal\Core\Hook\OrderOperation\OrderOperation;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 
@@ -61,20 +62,11 @@ class ModuleHandler implements ModuleHandlerInterface {
   protected $includeFileKeys = [];
 
   /**
-   * Lists of implementation callables by hook.
+   * Implementation lists by hook name.
    *
-   * @var array<string, list<callable>>
+   * @var array<string, \Drupal\Core\Hook\ImplementationList>
    */
-  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 = [];
+  protected array $hookImplementationLists = [];
 
   /**
    * Hook and module keyed list of listeners.
@@ -244,13 +236,20 @@ protected function add($type, $name, $path) {
     $hook_collector->loadAllIncludes();
     // Register procedural implementations.
     foreach ($hook_collector->getImplementations() as $hook => $moduleImplements) {
+      $list = $this->hookImplementationLists[$hook] ?? NULL;
+      $listeners = $list?->listeners ?? [];
+      $modules = $list?->modules ?? [];
       foreach ($moduleImplements as $module => $classImplements) {
         foreach ($classImplements[ProceduralCall::class] ?? [] as $method) {
-          $this->listenersByHook[$hook][] = $method;
-          $this->modulesByHook[$hook][] = $module;
+          $listeners[] = $method;
+          $modules[] = $module;
           $this->invokeMap[$hook][$module][] = $method;
         }
       }
+      // @todo Order the listeners after adding.
+      if ($listeners) {
+        $this->hookImplementationLists[$hook] = new ImplementationList($listeners, $modules);
+      }
     }
   }
 
@@ -334,8 +333,7 @@ public function getHookInfo() {
   public function resetImplementations() {
     $this->alterEventListeners = [];
     $this->invokeMap = [];
-    $this->listenersByHook = [];
-    $this->modulesByHook = [];
+    $this->hookImplementationLists = [];
   }
 
   /**
@@ -350,8 +348,8 @@ public function hasImplementations(string $hook, $modules = NULL): bool {
    * {@inheritdoc}
    */
   public function invokeAllWith(string $hook, callable $callback): void {
-    foreach ($this->getFlatHookListeners($hook) as $index => $listener) {
-      $module = $this->modulesByHook[$hook][$index];
+    $list = $this->getHookImplementationList($hook);
+    foreach ($list->iterateByModule() as $module => $listener) {
       $callback($listener, $module);
     }
   }
@@ -484,25 +482,25 @@ public function alter($type, &$data, &$context1 = NULL, &$context2 = NULL) {
    */
   protected function getCombinedListeners(array $hooks): array {
     // Get implementation lists for each hook.
-    $listener_lists = array_map($this->getFlatHookListeners(...), $hooks);
+    /** @var list<\Drupal\Core\Hook\ImplementationList> $lists */
+    $lists = array_map($this->getHookImplementationList(...), $hooks);
     // Remove empty lists.
-    $listener_lists = array_filter($listener_lists);
-    if (!$listener_lists) {
+    /** @var array<int, \Drupal\Core\Hook\ImplementationList> $lists */
+    $lists = array_filter($lists, fn (ImplementationList $list) => $list->hasImplementations());
+    if (!$lists) {
       // No implementations exist.
       return [];
     }
-    if (array_keys($listener_lists) === [0]) {
+    if (array_keys($lists) === [0]) {
       // Only the first hook has implementations.
-      return $listener_lists[0];
+      return $lists[0]->listeners;
     }
     // 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];
+    foreach ($lists as $list) {
+      foreach ($list->iterateByModule() as $module => $listener) {
         $identifier = is_array($listener)
           ? get_class($listener[0]) . '::' . $listener[1]
           : ProceduralCall::class . '::' . $listener;
@@ -709,31 +707,23 @@ public function writeCache() {
    *   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] ?? [];
+    return $this->invokeMap[$hook]
+      ??= $this->getHookImplementationList($hook)->groupByModule();
   }
 
   /**
-   * Gets a list of hook listener callbacks.
+   * Gets a hook implementation list for a specific hook.
    *
    * @param string $hook
    *   The hook name.
    *
-   * @return list<callable>
-   *   A list of hook implementation callables.
-   *
-   * @internal
+   * @return \Drupal\Core\Hook\ImplementationList
+   *   Object with hook implementation callbacks and their modules.
    */
-  protected function getFlatHookListeners(string $hook): array {
-    if (!isset($this->listenersByHook[$hook])) {
-      $this->listenersByHook[$hook] = [];
+  protected function getHookImplementationList(string $hook): ImplementationList {
+    if (!isset($this->hookImplementationLists[$hook])) {
+      $listeners = [];
+      $modules = [];
       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]];
@@ -746,11 +736,12 @@ protected function getFlatHookListeners(string $hook): array {
             $callable = $listener;
           }
           if (isset($this->moduleList[$module])) {
-            $this->listenersByHook[$hook][] = $callable;
-            $this->modulesByHook[$hook][] = $module;
+            $listeners[] = $callable;
+            $modules[] = $module;
           }
         }
       }
+      $this->hookImplementationLists[$hook] = new ImplementationList($listeners, $modules);
       if (isset($this->groupIncludes[$hook])) {
         foreach ($this->groupIncludes[$hook] as $include) {
           @trigger_error('Autoloading hooks in the file (' . $include . ') is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Move the functions in this file to either the .module file or other appropriate location. See https://www.drupal.org/node/3489765', E_USER_DEPRECATED);
@@ -759,7 +750,7 @@ protected function getFlatHookListeners(string $hook): array {
       }
     }
 
-    return $this->listenersByHook[$hook];
+    return $this->hookImplementationLists[$hook];
   }
 
 }
diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php
new file mode 100644
index 000000000000..ce3e79bd7728
--- /dev/null
+++ b/core/lib/Drupal/Core/Hook/ImplementationList.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Hook;
+
+/**
+ * Contains the ordered list of implementations for a hook.
+ *
+ * Also contains information about module names.
+ *
+ * @internal
+ */
+final class ImplementationList {
+
+  /**
+   * Constructor.
+   *
+   * @param list<callable> $listeners
+   *   List of hook implementation callbacks.
+   * @param list<string> $modules
+   *   The module name associated with each hook implementation.
+   *   This must have the same keys as $listeners.
+   */
+  public function __construct(
+    public readonly array $listeners,
+    public readonly array $modules,
+  ) {
+    assert(array_is_list($listeners));
+    assert(array_is_list($modules));
+    assert(count($listeners) === count($modules));
+  }
+
+  /**
+   * Iterates over listeners, using module names as keys.
+   *
+   * @return \Iterator<string, callable>
+   *   Iterator of listeners by module.
+   *   This allows the same module to occur more than once.
+   */
+  public function iterateByModule(): \Iterator {
+    foreach ($this->listeners as $index => $listener) {
+      yield $this->modules[$index] => $listener;
+    }
+  }
+
+  /**
+   * Gets listeners grouped by module.
+   *
+   * @return array<string, list<callable>>
+   *   Listeners grouped by module name.
+   */
+  public function groupByModule(): array {
+    $listeners_by_module = [];
+    foreach ($this->listeners as $index => $listener) {
+      $listeners_by_module[$this->modules[$index]][] = $listener;
+    }
+    return $listeners_by_module;
+  }
+
+  /**
+   * Gets listeners for a specific module.
+   *
+   * @param string $module
+   *   Module name.
+   *
+   * @return list<callable>
+   *   Listeners for that module.
+   */
+  public function getForModule(string $module): array {
+    return array_values(array_intersect_key(
+      $this->listeners,
+      array_intersect($this->modules, [$module]),
+    ));
+  }
+
+  /**
+   * Checks whether the list has any implementations.
+   *
+   * @return bool
+   *   TRUE if it has implementations, FALSE if it is empty.
+   */
+  public function hasImplementations(): bool {
+    return $this->listeners !== [];
+  }
+
+  /**
+   * Checks whether the list has any implementations for specific modules.
+   *
+   * @param list<string> $modules
+   *   Modules for which to check.
+   *
+   * @return bool
+   *   TRUE if it has implementations for any of the modules, FALSE if not.
+   */
+  public function hasImplementationsForModules(array $modules): bool {
+    return (bool) array_intersect($this->modules, $modules);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php
new file mode 100644
index 000000000000..7dc71c44c035
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core\Hook;
+
+use Drupal\Core\Hook\ImplementationList;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Hook\ImplementationList
+ * @group Hook
+ */
+class ImplementationListTest extends UnitTestCase {
+
+  /**
+   * Log for include files.
+   *
+   * @var list<string>
+   */
+  public static array $log = [];
+
+  /**
+   * Tests public methods on a common instance.
+   *
+   * This is easier than separate test methods.
+   */
+  public function testPublicMethods(): void {
+    $listeners = [
+      fn () => 'a0',
+      fn () => 'b',
+      fn () => 'a1',
+    ];
+    $modules = [
+      'module_a',
+      'module_b',
+      // Repeat the same module.
+      'module_a',
+    ];
+    $list = new ImplementationList($listeners, $modules);
+
+    // Test public properties.
+    $this->assertSame($listeners, $list->listeners);
+    $this->assertSame($modules, $list->modules);
+
+    // Test iterateByModule().
+    $i = 0;
+    foreach ($list->iterateByModule() as $module => $listener) {
+      $this->assertSame($modules[$i], $module);
+      $this->assertSame($listeners[$i], $listener);
+      ++$i;
+    }
+
+    // Test groupByModule().
+    $this->assertSame(
+      [
+        'module_a' => [$listeners[0], $listeners[2]],
+        'module_b' => [$listeners[1]],
+      ],
+      $list->groupByModule(),
+    );
+
+    // Test getForModule().
+    $this->assertSame([], $list->getForModule('other_module'));
+    $this->assertSame([$listeners[0], $listeners[2]], $list->getForModule('module_a'));
+    $this->assertSame([$listeners[1]], $list->getForModule('module_b'));
+
+    // Test hasImplementations().
+    $this->assertTrue($list->hasImplementations());
+
+    // Test hasImplementationsForModules().
+    $this->assertFalse($list->hasImplementationsForModules(['other_module']));
+    $this->assertFalse($list->hasImplementationsForModules([]));
+    $this->assertTrue($list->hasImplementationsForModules(['other_module', 'module_a']));
+    $this->assertTrue($list->hasImplementationsForModules(['module_b']));
+  }
+
+  /**
+   * Tests public methods on an empty list.
+   */
+  public function testEmptyList(): void {
+    $list = new ImplementationList([], []);
+
+    // Test public properties.
+    $this->assertSame([], $list->listeners);
+    $this->assertSame([], $list->modules);
+
+    // Test iterateByModule().
+    $iterator = $list->iterateByModule();
+    $this->assertFalse($iterator->valid());
+
+    // Test hasImplementations().
+    $this->assertFalse($list->hasImplementations());
+
+    // Test getForModule().
+    $this->assertSame([], $list->getForModule('any_module'));
+
+    // Test hasImplementationsForModules().
+    $this->assertFalse($list->hasImplementationsForModules(['any_module']));
+    $this->assertFalse($list->hasImplementationsForModules([]));
+  }
+
+  /**
+   * Creates a temporary file which writes to self::$log.
+   *
+   * @param string $message
+   *   Message to write to self::$log on include.
+   *
+   * @return string
+   *   File path.
+   */
+  protected function createTempFile(string $message): string {
+    $file = tempnam(sys_get_temp_dir(), 'test');
+    $export = var_export($message, TRUE);
+    file_put_contents($file, '<?php ' . self::class . "::\$log[] = $export;");
+    return $file;
+  }
+
+}
-- 
GitLab


From c5277ee5b8c342f33665c13cd1bc9aa5194bc692 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Thu, 1 May 2025 20:27:37 +0200
Subject: [PATCH 04/13] Assert that listeners in ImplementationList are
 callable, and update test.

---
 core/lib/Drupal/Core/Hook/ImplementationList.php     |  1 +
 .../Tests/Core/Extension/ModuleHandlerTest.php       | 12 +++++++++---
 2 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php
index ce3e79bd7728..a18db487c2b2 100644
--- a/core/lib/Drupal/Core/Hook/ImplementationList.php
+++ b/core/lib/Drupal/Core/Hook/ImplementationList.php
@@ -29,6 +29,7 @@ public function __construct(
     assert(array_is_list($listeners));
     assert(array_is_list($modules));
     assert(count($listeners) === count($modules));
+    assert(array_filter($listeners, is_callable(...)) === $listeners);
   }
 
   /**
diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
index 3f653e783459..0335cbd39daa 100644
--- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
@@ -52,16 +52,19 @@ protected function setUp(): void {
    *   Module paths by module name.
    * @param array<callable-string, string> $implementations
    *   Module names by function name implementing hook_hook().
+   * @param array<callable-string, string> $includes_per_function
+   *   Include files per function name.
    * @param bool $loadAll
    *   TRUE to call ModuleHandler->loadAll() on the new module handler.
    *
    * @return \Drupal\Core\Extension\ModuleHandler
    *   The module handler to test.
    */
-  protected function getModuleHandler($modules = [], $implementations = [], $loadAll = TRUE) {
+  protected function getModuleHandler($modules = [], $implementations = [], array $includes_per_function = [], $loadAll = TRUE) {
     // This only works if there's a single $hook.
     if ($implementations) {
-      $listeners = array_map(fn ($function) => [new ProceduralCall([]), $function], array_keys($implementations));
+      $procedural_call = new ProceduralCall($includes_per_function);
+      $listeners = array_map(fn ($function) => [$procedural_call, $function], array_keys($implementations));
       $this->eventDispatcher->expects($this->once())
         ->method('getListeners')
         ->with("drupal_hook.hook")
@@ -350,7 +353,10 @@ public function testImplementsHookModuleEnabled(): void {
       'module_handler_test_added' => 'core/tests/Drupal/Tests/Core/Extension/modules/module_handler_test_added',
       'module_handler_test_no_hook' => 'core/tests/Drupal/Tests/Core/Extension/modules/module_handler_test_no_hook',
     ];
-    $module_handler = $this->getModuleHandler($moduleList, $implementations);
+    $includes_per_function = [
+      'module_handler_test_added_hook' => 'core/tests/Drupal/Tests/Core/Extension/modules/module_handler_test_added/module_handler_test_added.hook.inc',
+    ];
+    $module_handler = $this->getModuleHandler($moduleList, $implementations, $includes_per_function);
 
     $this->assertTrue($module_handler->hasImplementations('hook', 'module_handler_test'), 'Installed module implementation found.');
     $this->assertTrue($module_handler->hasImplementations('hook', 'module_handler_test_added'), 'Runtime added module with implementation in include found.');
-- 
GitLab


From afefd18d3b517caef2b87cccb7a92920f775392a Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Sun, 4 May 2025 11:28:09 +0200
Subject: [PATCH 05/13] Assert that modules are strings in
 ImplementationList.php.

---
 core/lib/Drupal/Core/Hook/ImplementationList.php | 1 +
 1 file changed, 1 insertion(+)

diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php
index a18db487c2b2..a38e36a36754 100644
--- a/core/lib/Drupal/Core/Hook/ImplementationList.php
+++ b/core/lib/Drupal/Core/Hook/ImplementationList.php
@@ -30,6 +30,7 @@ public function __construct(
     assert(array_is_list($modules));
     assert(count($listeners) === count($modules));
     assert(array_filter($listeners, is_callable(...)) === $listeners);
+    assert(array_filter($modules, is_string(...)) === $modules, (new \Exception())->getTraceAsString());
   }
 
   /**
-- 
GitLab


From 56877cc22dbecac429811f0b960240fb7440efa5 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Sat, 29 Mar 2025 13:54:45 +0100
Subject: [PATCH 06/13] Drop usage of ->getHookListeners() in ModuleHandler
 methods.

---
 core/lib/Drupal/Core/Extension/ModuleHandler.php | 14 ++++++++++----
 .../Tests/Core/Extension/ModuleHandlerTest.php   | 16 +++++++---------
 2 files changed, 17 insertions(+), 13 deletions(-)

diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php
index 383d83dd1d2b..f9031f7fb7db 100644
--- a/core/lib/Drupal/Core/Extension/ModuleHandler.php
+++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php
@@ -340,8 +340,11 @@ public function resetImplementations() {
    * {@inheritdoc}
    */
   public function hasImplementations(string $hook, $modules = NULL): bool {
-    $implementation_modules = array_keys($this->getHookListeners($hook));
-    return (bool) (isset($modules) ? array_intersect($implementation_modules, (array) $modules) : $implementation_modules);
+    $list = $this->getHookImplementationList($hook);
+    if ($modules === NULL) {
+      return $list->hasImplementations();
+    }
+    return $list->hasImplementationsForModules((array) $modules);
   }
 
   /**
@@ -358,7 +361,9 @@ public function invokeAllWith(string $hook, callable $callback): void {
    * {@inheritdoc}
    */
   public function invoke($module, $hook, array $args = []) {
-    if ($listeners = $this->getHookListeners($hook)[$module] ?? []) {
+    $list = $this->getHookImplementationList($hook);
+    $listeners = $list->getForModule($module);
+    if ($listeners) {
       if (count($listeners) > 1) {
         throw new \LogicException("Module $module should not implement $hook more than once");
       }
@@ -435,7 +440,8 @@ public function invokeAllDeprecated($description, $hook, array $args = []) {
    *   The name of the hook.
    */
   private function triggerDeprecationError($description, $hook) {
-    $modules = array_keys($this->getHookListeners($hook));
+    $list = $this->getHookImplementationList($hook);
+    $modules = array_unique($list->modules);
     if (!empty($modules)) {
       $message = 'The deprecated hook hook_' . $hook . '() is implemented in these modules: ';
       @trigger_error($message . implode(', ', $modules) . '. ' . $description, E_USER_DEPRECATED);
diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
index 0335cbd39daa..526ca970bd03 100644
--- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
@@ -8,6 +8,7 @@
 use Drupal\Core\Extension\Extension;
 use Drupal\Core\Extension\ModuleHandler;
 use Drupal\Core\Extension\ProceduralCall;
+use Drupal\Core\Hook\ImplementationList;
 use Drupal\Tests\Core\GroupIncludesTestTrait;
 use Drupal\Tests\UnitTestCase;
 use Symfony\Component\EventDispatcher\EventDispatcher;
@@ -402,6 +403,7 @@ function some_method(): void {
     $module_handler = new ModuleHandler($this->root, [], $this->eventDispatcher, $implementations);
     $module_handler->setModuleList(['some_module' => TRUE]);
     $r = new \ReflectionObject($module_handler);
+    $get_lists = fn () => $r->getProperty('hookImplementationLists')->getValue($module_handler);
 
     // Set up some synthetic results.
     $this->eventDispatcher
@@ -411,15 +413,11 @@ function some_method(): void {
       ->willReturn([
         [$c, 'some_method'],
       ]);
-    $this->assertNotEmpty($module_handler->hasImplementations('some_hook'));
-
-    $listeners = $r->getProperty('invokeMap')->getValue($module_handler)['some_hook']['some_module'];
-    // Anonymous class doesn't work with assertSame() so assert it piecemeal.
-    $this->assertSame(count($listeners), 1);
-    foreach ($listeners as $listener) {
-      $this->assertSame(get_class($c), get_class($listener[0]));
-      $this->assertSame('some_method', $listener[1]);
-    }
+    $this->assertSame([], $get_lists());
+    $this->assertTrue($module_handler->hasImplementations('some_hook'));
+    $this->assertEquals([
+      'some_hook' => new ImplementationList([[$c, 'some_method']], ['some_module']),
+    ], $get_lists());
   }
 
   /**
-- 
GitLab


From cbdef5dce32569b93806577007fbb841828a9240 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Tue, 29 Apr 2025 18:58:33 +0200
Subject: [PATCH 07/13] Remove ModuleHandler::getHookListeners() and
 ImplementationList::groupByModule().

---
 .../Drupal/Core/Extension/ModuleHandler.php   | 23 -------------------
 .../Drupal/Core/Hook/ImplementationList.php   | 14 -----------
 .../Core/Extension/ModuleHandlerTest.php      |  3 ---
 .../Core/Hook/ImplementationListTest.php      |  9 --------
 4 files changed, 49 deletions(-)

diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php
index f9031f7fb7db..5ba5716d730d 100644
--- a/core/lib/Drupal/Core/Extension/ModuleHandler.php
+++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php
@@ -68,13 +68,6 @@ class ModuleHandler implements ModuleHandlerInterface {
    */
   protected array $hookImplementationLists = [];
 
-  /**
-   * Hook and module keyed list of listeners.
-   *
-   * @var array<string, array<string, list<callable>>>
-   */
-  protected array $invokeMap = [];
-
   /**
    * Ordering rules by hook name.
    *
@@ -243,7 +236,6 @@ protected function add($type, $name, $path) {
         foreach ($classImplements[ProceduralCall::class] ?? [] as $method) {
           $listeners[] = $method;
           $modules[] = $module;
-          $this->invokeMap[$hook][$module][] = $method;
         }
       }
       // @todo Order the listeners after adding.
@@ -332,7 +324,6 @@ public function getHookInfo() {
    */
   public function resetImplementations() {
     $this->alterEventListeners = [];
-    $this->invokeMap = [];
     $this->hookImplementationLists = [];
   }
 
@@ -703,20 +694,6 @@ public function writeCache() {
     @trigger_error(__METHOD__ . '() is deprecated in drupal:11.1.0 and is removed from drupal:12.0.0. There is no need to call this method so there is no replacement. See https://www.drupal.org/node/3442349', E_USER_DEPRECATED);
   }
 
-  /**
-   * Gets hook listeners by module.
-   *
-   * @param string $hook
-   *   The name of the hook.
-   *
-   * @return array<string, list<callable>>
-   *   A list of event listeners implementing this hook.
-   */
-  protected function getHookListeners(string $hook): array {
-    return $this->invokeMap[$hook]
-      ??= $this->getHookImplementationList($hook)->groupByModule();
-  }
-
   /**
    * Gets a hook implementation list for a specific hook.
    *
diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php
index a38e36a36754..9b547df008cf 100644
--- a/core/lib/Drupal/Core/Hook/ImplementationList.php
+++ b/core/lib/Drupal/Core/Hook/ImplementationList.php
@@ -46,20 +46,6 @@ public function iterateByModule(): \Iterator {
     }
   }
 
-  /**
-   * Gets listeners grouped by module.
-   *
-   * @return array<string, list<callable>>
-   *   Listeners grouped by module name.
-   */
-  public function groupByModule(): array {
-    $listeners_by_module = [];
-    foreach ($this->listeners as $index => $listener) {
-      $listeners_by_module[$this->modules[$index]][] = $listener;
-    }
-    return $listeners_by_module;
-  }
-
   /**
    * Gets listeners for a specific module.
    *
diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
index 526ca970bd03..240d91ebe604 100644
--- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
@@ -388,7 +388,6 @@ public function testInvokeAll(): void {
   /**
    * Tests hasImplementations.
    *
-   * @covers ::getHookListeners
    * @covers ::hasImplementations
    */
   public function testHasImplementations(): void {
@@ -436,8 +435,6 @@ public function testGetModuleDirectories(): void {
   }
 
   /**
-   * @covers ::getHookListeners
-   *
    * @group legacy
    */
   public function testGroupIncludes(): void {
diff --git a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php
index 7dc71c44c035..632caf6da47b 100644
--- a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php
+++ b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php
@@ -51,15 +51,6 @@ public function testPublicMethods(): void {
       ++$i;
     }
 
-    // Test groupByModule().
-    $this->assertSame(
-      [
-        'module_a' => [$listeners[0], $listeners[2]],
-        'module_b' => [$listeners[1]],
-      ],
-      $list->groupByModule(),
-    );
-
     // Test getForModule().
     $this->assertSame([], $list->getForModule('other_module'));
     $this->assertSame([$listeners[0], $listeners[2]], $list->getForModule('module_a'));
-- 
GitLab


From 18227d5dc3936e4cad4711ae684c750fc36c0f24 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Thu, 1 May 2025 16:29:23 +0200
Subject: [PATCH 08/13] Include files before getting the listeners, in
 getHookImplementationList().

---
 core/lib/Drupal/Core/Extension/ModuleHandler.php | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php
index 5ba5716d730d..af276747e4e6 100644
--- a/core/lib/Drupal/Core/Extension/ModuleHandler.php
+++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php
@@ -705,6 +705,12 @@ public function writeCache() {
    */
   protected function getHookImplementationList(string $hook): ImplementationList {
     if (!isset($this->hookImplementationLists[$hook])) {
+      if (isset($this->groupIncludes[$hook])) {
+        foreach ($this->groupIncludes[$hook] as $include) {
+          @trigger_error('Autoloading hooks in the file (' . $include . ') is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Move the functions in this file to either the .module file or other appropriate location. See https://www.drupal.org/node/3489765', E_USER_DEPRECATED);
+          include_once $include;
+        }
+      }
       $listeners = [];
       $modules = [];
       foreach ($this->eventDispatcher->getListeners("drupal_hook.$hook") as $listener) {
@@ -725,12 +731,6 @@ protected function getHookImplementationList(string $hook): ImplementationList {
         }
       }
       $this->hookImplementationLists[$hook] = new ImplementationList($listeners, $modules);
-      if (isset($this->groupIncludes[$hook])) {
-        foreach ($this->groupIncludes[$hook] as $include) {
-          @trigger_error('Autoloading hooks in the file (' . $include . ') is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Move the functions in this file to either the .module file or other appropriate location. See https://www.drupal.org/node/3489765', E_USER_DEPRECATED);
-          include_once $include;
-        }
-      }
     }
 
     return $this->hookImplementationLists[$hook];
-- 
GitLab


From 44dccd97d36dd7d5515d65ecad3990896417891f Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Fri, 6 Jun 2025 11:22:09 +0200
Subject: [PATCH 09/13] Remove left-over
 ImplementationListTest::createTempFile().

---
 .../Tests/Core/Hook/ImplementationListTest.php   | 16 ----------------
 1 file changed, 16 deletions(-)

diff --git a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php
index 632caf6da47b..da4d8ee83eb2 100644
--- a/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php
+++ b/core/tests/Drupal/Tests/Core/Hook/ImplementationListTest.php
@@ -91,20 +91,4 @@ public function testEmptyList(): void {
     $this->assertFalse($list->hasImplementationsForModules([]));
   }
 
-  /**
-   * Creates a temporary file which writes to self::$log.
-   *
-   * @param string $message
-   *   Message to write to self::$log on include.
-   *
-   * @return string
-   *   File path.
-   */
-  protected function createTempFile(string $message): string {
-    $file = tempnam(sys_get_temp_dir(), 'test');
-    $export = var_export($message, TRUE);
-    file_put_contents($file, '<?php ' . self::class . "::\$log[] = $export;");
-    return $file;
-  }
-
 }
-- 
GitLab


From 64abd54b88cf9d014990ce9d1f635824b834abb0 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Fri, 6 Jun 2025 11:29:42 +0200
Subject: [PATCH 10/13] Make ImplementationList not final, for now.

---
 core/lib/Drupal/Core/Hook/ImplementationList.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php
index 9b547df008cf..9d2794661b19 100644
--- a/core/lib/Drupal/Core/Hook/ImplementationList.php
+++ b/core/lib/Drupal/Core/Hook/ImplementationList.php
@@ -11,7 +11,7 @@
  *
  * @internal
  */
-final class ImplementationList {
+class ImplementationList {
 
   /**
    * Constructor.
-- 
GitLab


From bb773c9c69a72eb9d26929f3055c4b59ba9cdb06 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Fri, 6 Jun 2025 11:33:25 +0200
Subject: [PATCH 11/13] Refine the docs on ImplementationList, a little.

---
 core/lib/Drupal/Core/Hook/ImplementationList.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php
index 9d2794661b19..688a54155693 100644
--- a/core/lib/Drupal/Core/Hook/ImplementationList.php
+++ b/core/lib/Drupal/Core/Hook/ImplementationList.php
@@ -7,7 +7,7 @@
 /**
  * Contains the ordered list of implementations for a hook.
  *
- * Also contains information about module names.
+ * Also contains a module name for each implementation.
  *
  * @internal
  */
@@ -38,7 +38,7 @@ public function __construct(
    *
    * @return \Iterator<string, callable>
    *   Iterator of listeners by module.
-   *   This allows the same module to occur more than once.
+   *   This allows for the same module to occur more than once.
    */
   public function iterateByModule(): \Iterator {
     foreach ($this->listeners as $index => $listener) {
-- 
GitLab


From b39f4987a25c5a4f576487538cec58ec3dcc36b3 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Fri, 6 Jun 2025 22:02:50 +0200
Subject: [PATCH 12/13] Enhance comments around ModuleHandler::add(), mention
 how it is destructive.

---
 .../lib/Drupal/Core/Extension/ModuleHandler.php | 17 +++++++++++++----
 1 file changed, 13 insertions(+), 4 deletions(-)

diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php
index af276747e4e6..fa60b827ad27 100644
--- a/core/lib/Drupal/Core/Extension/ModuleHandler.php
+++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php
@@ -209,8 +209,8 @@ public function addProfile($name, $path) {
    *   The module path; e.g., 'core/modules/node'.
    *
    * @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0.
-   * There is no direct replacement.
-   * @see https://www.drupal.org/node/3491200
+   *   There is no direct replacement.
+   *   @see https://www.drupal.org/node/3491200
    */
   protected function add($type, $name, $path) {
     $pathname = "$path/$name.info.yml";
@@ -227,7 +227,17 @@ protected function add($type, $name, $path) {
     // Load all includes so the legacy section of invoke can handle hooks in
     // includes.
     $hook_collector->loadAllIncludes();
-    // Register procedural implementations.
+    // Register procedural implementations from the new module.
+    // This is done for BC reasons, but it has some limitations:
+    // - OOP implementations from the new module are not added.
+    //   This is because the services won't be available yet.
+    // - New implementations are all appended to the list, no ordering is
+    //   applied.
+    // - Procedural implementations that call \Drupal::service() may find the
+    //   requested service to be missing, if the added module was not properly
+    //   installed.
+    // @todo Calling ->add() suppresses implementations from other modules.
+    //   See https://www.drupal.org/project/drupal/issues/3528899.
     foreach ($hook_collector->getImplementations() as $hook => $moduleImplements) {
       $list = $this->hookImplementationLists[$hook] ?? NULL;
       $listeners = $list?->listeners ?? [];
@@ -238,7 +248,6 @@ protected function add($type, $name, $path) {
           $modules[] = $module;
         }
       }
-      // @todo Order the listeners after adding.
       if ($listeners) {
         $this->hookImplementationLists[$hook] = new ImplementationList($listeners, $modules);
       }
-- 
GitLab


From 121d86fb70ab8ad52076b1a76b4af9912f96ede8 Mon Sep 17 00:00:00 2001
From: Andreas Hennings <andreas@dqxtech.net>
Date: Fri, 6 Jun 2025 23:10:32 +0200
Subject: [PATCH 13/13] Add comment in ImplementationList::getForModule().

---
 core/lib/Drupal/Core/Hook/ImplementationList.php | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/core/lib/Drupal/Core/Hook/ImplementationList.php b/core/lib/Drupal/Core/Hook/ImplementationList.php
index 688a54155693..0dee1d54642e 100644
--- a/core/lib/Drupal/Core/Hook/ImplementationList.php
+++ b/core/lib/Drupal/Core/Hook/ImplementationList.php
@@ -56,6 +56,8 @@ public function iterateByModule(): \Iterator {
    *   Listeners for that module.
    */
   public function getForModule(string $module): array {
+    // Find indices where $this->modules[$i] === $module.
+    // Then get the listeners for these indices.
     return array_values(array_intersect_key(
       $this->listeners,
       array_intersect($this->modules, [$module]),
-- 
GitLab