diff --git a/core/core.api.php b/core/core.api.php
index 79b03bd85995213e8f6af5730cd8202a61f4a8ea..c6493f35b77e1d19c22ae2a95764a7eea03436a5 100644
--- a/core/core.api.php
+++ b/core/core.api.php
@@ -1653,6 +1653,9 @@
  * The following hooks can not be implemented as a class method, and must be
  * implemented as procedural:
  *
+ * Legacy meta hooks:
+ * - hook_hook_info()
+ *
  * Install hooks:
  * - hook_install()
  * - hook_module_preinstall()
diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php
index aa1882c101e084536adb7bf543c71feef6a54518..8d787a07629195184adb410434c5eff1c6a5847e 100644
--- a/core/lib/Drupal/Core/Extension/ModuleHandler.php
+++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php
@@ -77,11 +77,13 @@ class ModuleHandler implements ModuleHandlerInterface {
    *   The event dispatcher.
    * @param array $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.
    *
    * @see \Drupal\Core\DrupalKernel
    * @see \Drupal\Core\CoreServiceProvider
    */
-  public function __construct($root, array $module_list, protected EventDispatcherInterface $eventDispatcher, protected array $hookImplementationsMap) {
+  public function __construct($root, array $module_list, protected EventDispatcherInterface $eventDispatcher, protected array $hookImplementationsMap, protected array $groupIncludes = []) {
     $this->root = $root;
     $this->moduleList = [];
     foreach ($module_list as $name => $module) {
@@ -560,6 +562,11 @@ protected function getHookListeners(string $hook): array {
           }
         }
       }
+      if (isset($this->groupIncludes[$hook])) {
+        foreach ($this->groupIncludes[$hook] as $include) {
+          include_once $include;
+        }
+      }
     }
     return $this->invokeMap[$hook] ?? [];
   }
diff --git a/core/lib/Drupal/Core/Extension/module.api.php b/core/lib/Drupal/Core/Extension/module.api.php
index bfe5f5f62ba5625b7e563af71b4b8e6adf6e7241..d2e720c3a6caf4109f810ef8dfc100b044094695 100644
--- a/core/lib/Drupal/Core/Extension/module.api.php
+++ b/core/lib/Drupal/Core/Extension/module.api.php
@@ -62,6 +62,8 @@
 /**
  * Defines one or more hooks that are exposed by a module.
  *
+ * Only procedural implementations are supported for this hook.
+ *
  * Normally hooks do not need to be explicitly defined. However, by declaring a
  * hook explicitly, a module may define a "group" for it. Modules that implement
  * a hook may then place their implementation in either $module.module or in
diff --git a/core/lib/Drupal/Core/Hook/Attribute/Hook.php b/core/lib/Drupal/Core/Hook/Attribute/Hook.php
index 0203434f6c23a6c033e49b25f3a3cf4ba5c81230..324d0181382d5407a959b53334919ad8e4a774da 100644
--- a/core/lib/Drupal/Core/Hook/Attribute/Hook.php
+++ b/core/lib/Drupal/Core/Hook/Attribute/Hook.php
@@ -62,6 +62,9 @@
  *
  * The following hooks can only have procedural hook implementations:
  *
+ * Legacy meta hooks:
+ * - hook_hook_info()
+ *
  * Install hooks:
  * - hook_install()
  * - hook_module_preinstall()
diff --git a/core/lib/Drupal/Core/Hook/HookCollectorPass.php b/core/lib/Drupal/Core/Hook/HookCollectorPass.php
index 273922f69f16abe77930de4234313ab5d63e6795..e2e1feb85dbbabda0c554ffadc0201517ad98b42 100644
--- a/core/lib/Drupal/Core/Hook/HookCollectorPass.php
+++ b/core/lib/Drupal/Core/Hook/HookCollectorPass.php
@@ -67,6 +67,18 @@ class HookCollectorPass implements CompilerPassInterface {
    */
   protected int $priority = 0;
 
+  /**
+   * A list of functions implementing hook_hook_info().
+   *
+   * (This is required only for BC.)
+   */
+  private array $hookInfo = [];
+
+  /**
+   * A list of .inc files.
+   */
+  private array $groupIncludes = [];
+
   /**
    * {@inheritdoc}
    */
@@ -75,6 +87,16 @@ public function process(ContainerBuilder $container): void {
     $map = [];
     $container->register(ProceduralCall::class, ProceduralCall::class)
       ->addArgument($collector->includes);
+    $groupIncludes = [];
+    foreach ($collector->hookInfo as $function) {
+      foreach ($function() as $hook => $info) {
+        if (isset($collector->groupIncludes[$info['group']])) {
+          $groupIncludes[$hook] = $collector->groupIncludes[$info['group']];
+        }
+      }
+    }
+    $definition = $container->getDefinition('module_handler');
+    $definition->setArgument('$groupIncludes', $groupIncludes);
     foreach ($collector->implementations as $hook => $class_implementations) {
       foreach ($class_implementations as $class => $method_hooks) {
         if ($container->has($class)) {
@@ -152,7 +174,7 @@ protected function collectModuleHookImplementations($dir, $module, $module_preg)
       }
       if ($extension === 'php') {
         $namespace = preg_replace('#^src/#', "Drupal/$module/", $iterator->getSubPath());
-        $class = $namespace . '/' . basename($fileinfo->getFilename(), '.php');
+        $class = $namespace . '/' . $fileinfo->getBasename('.php');
         $class = str_replace('/', '\\', $class);
         foreach (static::getHookAttributesInClass($class) as $attribute) {
           $this->addFromAttribute($attribute, $class, $module);
@@ -167,6 +189,12 @@ protected function collectModuleHookImplementations($dir, $module, $module_preg)
           }
         }
       }
+      if ($extension === 'inc') {
+        $parts = explode('.', $fileinfo->getFilename());
+        if (count($parts) === 3 && $parts[0] === $module) {
+          $this->groupIncludes[$parts[1]][] = $fileinfo->getPathname();
+        }
+      }
     }
   }
 
@@ -266,6 +294,9 @@ protected function addFromAttribute(Hook $hook, $class, $module) {
    */
   protected function addProceduralImplementation(\SplFileInfo $fileinfo, string $hook, string $module, string $function) {
     $this->proceduralHooks[$hook][$module] = FALSE;
+    if ($hook === 'hook_info') {
+      $this->hookInfo[] = $function;
+    }
     if ($hook === 'module_implements_alter') {
       $this->moduleImplementsAlters[] = $function;
     }
@@ -330,6 +361,7 @@ public function getImplementations(): array {
    */
   public static function checkForProceduralOnlyHooks(Hook $hook, string $class): void {
     $staticDenyHooks = [
+      'hook_info',
       'install',
       'module_preinstall',
       'module_preuninstall',
diff --git a/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php b/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php
index 0d85e3de88befb9e17932b66dbcced8562bcd55e..809850468d9c706dd13c3b7a0cc8537032ee454a 100644
--- a/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Hook/HookCollectorPassTest.php
@@ -7,6 +7,7 @@
 use Drupal\Core\Hook\HookCollectorPass;
 use Drupal\KernelTests\KernelTestBase;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Definition;
 
 /**
  * @coversDefaultClass \Drupal\Core\Hook\HookCollectorPass
@@ -36,6 +37,7 @@ public function testSymlink(): void {
       'user_hooks_test' => ['pathname' => "$this->siteDirectory/user_hooks_test.info.yml"],
     ];
     $container->setParameter('container.modules', $module_filenames);
+    $container->setDefinition('module_handler', new Definition());
     (new HookCollectorPass())->process($container);
     $implementations = [
       'user_format_name_alter' => [
diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
index b72331bf958123418d675d70aaf0e6b1aec1d795..0644f1db5d213484357fc816ba9c27ffc8f0ea85 100644
--- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php
@@ -9,6 +9,8 @@
 use Drupal\Core\Extension\Exception\UnknownExtensionException;
 use Drupal\Core\Extension\ProceduralCall;
 use Drupal\Tests\UnitTestCase;
+use Drupal\Tests\Core\GroupIncludesTestTrait;
+use Symfony\Component\EventDispatcher\EventDispatcher;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 
 /**
@@ -19,6 +21,8 @@
  */
 class ModuleHandlerTest extends UnitTestCase {
 
+  use GroupIncludesTestTrait;
+
   /**
    * @var \PHPUnit\Framework\MockObject\MockObject|\Symfony\Component\EventDispatcher\EventDispatcherInterface
    */
@@ -378,4 +382,15 @@ public function testGetModuleDirectories(): void {
     $this->assertEquals(['node' => $this->root . '/core/modules/node'], $module_handler->getModuleDirectories());
   }
 
+  /**
+   * @covers ::getHookListeners
+   */
+  public function testGroupIncludes(): void {
+    self::setupGroupIncludes();
+    $moduleHandler = new ModuleHandler('', [], new EventDispatcher(), [], self::GROUP_INCLUDES);
+    $this->assertFalse(function_exists('_test_module_helper'));
+    $moduleHandler->invokeAll('token_info');
+    $this->assertTrue(function_exists('_test_module_helper'));
+  }
+
 }
diff --git a/core/tests/Drupal/Tests/Core/GroupIncludesTestTrait.php b/core/tests/Drupal/Tests/Core/GroupIncludesTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..807ae036e695a2fd411ff8f2b186170309791a05
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/GroupIncludesTestTrait.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\Core;
+
+use org\bovigo\vfs\vfsStream;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Hook\HookCollectorPass
+ * @group Hook
+ */
+trait GroupIncludesTestTrait {
+
+  const GROUP_INCLUDES = ['token_info' => ['vfs://drupal_root/test_module.tokens.inc']];
+
+  /**
+   * @return array[]
+   */
+  public static function setupGroupIncludes(): array {
+    vfsStream::setup('drupal_root');
+    file_put_contents('vfs://drupal_root/test_module_info.yml', '');
+    $module_filenames = [
+      'test_module' => ['pathname' => 'vfs://drupal_root/test_module_info.yml'],
+    ];
+    file_put_contents('vfs://drupal_root/test_module.module', <<<'EOD'
+<?php
+
+function test_module_hook_info() {
+  $hooks['token_info'] = [
+    'group' => 'tokens',
+  ];
+  return $hooks;
+}
+
+EOD
+    );
+    file_put_contents('vfs://drupal_root/test_module.tokens.inc', <<<'EOD'
+<?php
+
+function _test_module_helper() {}
+
+EOD
+    );
+    return $module_filenames;
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php b/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php
index c1fd4ea1c7fe35f80d0acc1f20e0a1e540eb2618..c4559d290f39dc48d9a523234606978f73655490 100644
--- a/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php
+++ b/core/tests/Drupal/Tests/Core/Hook/HookCollectorPassTest.php
@@ -9,8 +9,10 @@
 use Drupal\Core\Hook\Attribute\Hook;
 use Drupal\Core\Hook\HookCollectorPass;
 use Drupal\Tests\UnitTestCase;
+use Drupal\Tests\Core\GroupIncludesTestTrait;
 use org\bovigo\vfs\vfsStream;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Definition;
 
 /**
  * @coversDefaultClass \Drupal\Core\Hook\HookCollectorPass
@@ -18,6 +20,8 @@
  */
 class HookCollectorPassTest extends UnitTestCase {
 
+  use GroupIncludesTestTrait;
+
   /**
    * @covers ::collectAllHookImplementations
    * @covers ::filterIterator
@@ -61,11 +65,26 @@ function test_module_should_be_skipped();
 
     $container = new ContainerBuilder();
     $container->setParameter('container.modules', $module_filenames);
+    $container->setDefinition('module_handler', new Definition());
     (new HookCollectorPass())->process($container);
     $this->assertSame($implementations, $container->getParameter('hook_implementations_map'));
     $this->assertSame($includes, $container->getDefinition(ProceduralCall::class)->getArguments()[0]);
   }
 
+  /**
+   * @covers ::process
+   * @covers ::collectModuleHookImplementations
+   */
+  public function testGroupIncludes(): void {
+    $module_filenames = self::setupGroupIncludes();
+    $container = new ContainerBuilder();
+    $container->setParameter('container.modules', $module_filenames);
+    $container->setDefinition('module_handler', new Definition());
+    (new HookCollectorPass())->process($container);
+    $argument = $container->getDefinition('module_handler')->getArgument('$groupIncludes');
+    $this->assertSame(self::GROUP_INCLUDES, $argument);
+  }
+
   /**
    * @covers ::getHookAttributesInClass
    */