From 35dd66391cb467d1339b5ad79e77f1344f1f14cd Mon Sep 17 00:00:00 2001
From: Michael Strelan <mstrelan@gmail.com>
Date: Thu, 20 Mar 2025 10:32:27 +1000
Subject: [PATCH 1/5] Collect bundle classes

---
 core/lib/Drupal/Core/CoreServiceProvider.php  |  3 +
 .../Drupal/Core/Entity/Attribute/Bundle.php   | 34 ++++++++
 .../Core/Entity/BundleClassCollectorPass.php  | 77 +++++++++++++++++++
 .../src/Entity/EntityTestBundleClass.php      |  5 ++
 .../Entity/SharedEntityTestBundleClassA.php   |  7 ++
 .../Entity/SharedEntityTestBundleClassB.php   |  7 ++
 .../Subdir/EntityTestSubdirBundleClass.php    | 19 +++++
 .../src/Hook/EntityTestBundleClassHooks.php   | 14 ----
 8 files changed, 152 insertions(+), 14 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Entity/Attribute/Bundle.php
 create mode 100644 core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
 create mode 100644 core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/Subdir/EntityTestSubdirBundleClass.php

diff --git a/core/lib/Drupal/Core/CoreServiceProvider.php b/core/lib/Drupal/Core/CoreServiceProvider.php
index 6150c188d182..2b00638d6e46 100644
--- a/core/lib/Drupal/Core/CoreServiceProvider.php
+++ b/core/lib/Drupal/Core/CoreServiceProvider.php
@@ -10,6 +10,7 @@
 use Drupal\Core\DependencyInjection\Compiler\CorsCompilerPass;
 use Drupal\Core\DependencyInjection\Compiler\DeprecatedServicePass;
 use Drupal\Core\DependencyInjection\Compiler\DevelopmentSettingsPass;
+use Drupal\Core\Entity\BundleClassCollectorPass;
 use Drupal\Core\Hook\HookCollectorPass;
 use Drupal\Core\DependencyInjection\Compiler\LoggerAwarePass;
 use Drupal\Core\DependencyInjection\Compiler\ModifyServiceDefinitionsPass;
@@ -115,6 +116,8 @@ public function register(ContainerBuilder $container) {
     // Collect moved classes for the backwards compatibility class loader.
     $container->addCompilerPass(new BackwardsCompatibilityClassLoaderPass());
 
+    $container->addCompilerPass(new BundleClassCollectorPass());
+
     $container->registerForAutoconfiguration(EventSubscriberInterface::class)
       ->addTag('event_subscriber');
 
diff --git a/core/lib/Drupal/Core/Entity/Attribute/Bundle.php b/core/lib/Drupal/Core/Entity/Attribute/Bundle.php
new file mode 100644
index 000000000000..049151e9fe6e
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/Attribute/Bundle.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Entity\Attribute;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Attribute for registering a bundle class.
+ */
+#[\Attribute(\Attribute::TARGET_CLASS)]
+class Bundle {
+
+  /**
+   * Constructs a Bundle attribute object.
+   *
+   * @param string $entityTypeId
+   *   The entity type ID.
+   * @param string|null $bundle
+   *   The bundle ID, or NULL to use the entity type ID.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $label
+   *   The label of the bundle.
+   */
+  public function __construct(
+    // @todo see if we can infer the entity type automatically.
+    public string $entityTypeId,
+    public ?string $bundle = NULL,
+    public ?TranslatableMarkup $label = NULL,
+  ) {
+    $this->bundle ??= $this->entityTypeId;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
new file mode 100644
index 000000000000..7373cbe92d2e
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Entity;
+
+use Drupal\Core\Entity\Attribute\Bundle;
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+
+/**
+ * Collects and registers bundle classes.
+ *
+ * @todo more documentation.
+ */
+class BundleClassCollectorPass implements CompilerPassInterface {
+
+  /**
+   * The bundle classes.
+   *
+   * @var array<class-string, class-string>
+   */
+  protected array $bundleClasses = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process(ContainerBuilder $container): void {
+    // @todo should we limit this to modules/profiles?
+    $namespaces = $container->getParameter('container.namespaces');
+    foreach ($namespaces as $namespace => $directory) {
+      if (!file_exists($directory . '/Entity')) {
+        continue;
+      }
+      $this->collectBundleClasses($directory . '/Entity', $namespace, $directory);
+    }
+    $container->setParameter('entity.bundle_classes', $this->bundleClasses);
+  }
+
+  /**
+   * Collects bundle classes.
+   *
+   * @param string $directory
+   *   The directory to search for bundle classes.
+   * @param string $namespace
+   *   The namespace of the directory.
+   * @param string $namespace_root
+   *   The directory of the namespace root.
+   */
+  protected function collectBundleClasses(string $directory, string $namespace, string $namespace_root): void {
+    $iterator = new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::UNIX_PATHS | \FilesystemIterator::FOLLOW_SYMLINKS);
+    $iterator = new \RecursiveCallbackFilterIterator($iterator, static::filterIterator(...));
+    foreach ($iterator as $fileInfo) {
+      if ($fileInfo->isDir()) {
+        $this->collectBundleClasses($fileInfo->getPathname(), $namespace, $namespace_root);
+        continue;
+      }
+      $subdir = substr($fileInfo->getPath(), strlen($namespace_root));
+      $fqcn = str_replace(DIRECTORY_SEPARATOR, "\\", $namespace . $subdir . '\\' . $fileInfo->getBasename('.php'));
+      if (!class_exists($fqcn)) {
+        continue;
+      }
+      $reflection = new \ReflectionClass($fqcn);
+      if ($reflection->getAttributes(Bundle::class)) {
+        $this->bundleClasses[$fqcn] = $fqcn;
+      }
+    }
+  }
+
+  /**
+   * Filter iterator callback.
+   */
+  protected static function filterIterator(\SplFileInfo $fileInfo, $key, \RecursiveDirectoryIterator $iterator): bool {
+    return $iterator->isDir() || $fileInfo->getExtension() === 'php';
+  }
+
+}
diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/EntityTestBundleClass.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/EntityTestBundleClass.php
index 145aa060f90b..75b1d76819fe 100644
--- a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/EntityTestBundleClass.php
+++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/EntityTestBundleClass.php
@@ -4,12 +4,17 @@
 
 namespace Drupal\entity_test_bundle_class\Entity;
 
+use Drupal\Core\Entity\Attribute\Bundle;
 use Drupal\Core\Entity\EntityStorageInterface;
 use Drupal\entity_test\Entity\EntityTest;
 
 /**
  * The bundle class for the bundle_class bundle of the entity_test entity.
  */
+#[Bundle(
+  entityTypeId: 'entity_test',
+  bundle: 'bundle_class',
+)]
 class EntityTestBundleClass extends EntityTest {
 
   /**
diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassA.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassA.php
index af5444a98ec9..5227865a312c 100644
--- a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassA.php
+++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassA.php
@@ -4,10 +4,17 @@
 
 namespace Drupal\entity_test_bundle_class\Entity;
 
+use Drupal\Core\Entity\Attribute\Bundle;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\entity_test\Entity\EntityTest;
 
 /**
  * A bundle class that shares the same entity type as entity_test.
  */
+#[Bundle(
+  entityTypeId: 'shared_type',
+  bundle: 'bundle_a',
+  label: new TranslatableMarkup('Bundle A'),
+)]
 class SharedEntityTestBundleClassA extends EntityTest {
 }
diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassB.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassB.php
index 93cb06690c53..c4c5abd4e22a 100644
--- a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassB.php
+++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/SharedEntityTestBundleClassB.php
@@ -4,10 +4,17 @@
 
 namespace Drupal\entity_test_bundle_class\Entity;
 
+use Drupal\Core\Entity\Attribute\Bundle;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\entity_test\Entity\EntityTest;
 
 /**
  * A bundle class that shares the same entity type as entity_test.
  */
+#[Bundle(
+  entityTypeId: 'shared_type',
+  bundle: 'bundle_b',
+  label: new TranslatableMarkup('Bundle B'),
+)]
 class SharedEntityTestBundleClassB extends EntityTest {
 }
diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/Subdir/EntityTestSubdirBundleClass.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/Subdir/EntityTestSubdirBundleClass.php
new file mode 100644
index 000000000000..5ace395eabf1
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Entity/Subdir/EntityTestSubdirBundleClass.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\entity_test_bundle_class\Entity\Subdir;
+
+use Drupal\Core\Entity\Attribute\Bundle;
+use Drupal\entity_test\Entity\EntityTest;
+
+/**
+ * A bundle class that is in a subdirectory.
+ */
+#[Bundle(
+  entityTypeId: 'entity_test',
+  bundle: 'subdir_bundle_class',
+)]
+class EntityTestSubdirBundleClass extends EntityTest {
+
+}
diff --git a/core/modules/system/tests/modules/entity_test_bundle_class/src/Hook/EntityTestBundleClassHooks.php b/core/modules/system/tests/modules/entity_test_bundle_class/src/Hook/EntityTestBundleClassHooks.php
index 5e49bd0298b9..57e8eb24303f 100644
--- a/core/modules/system/tests/modules/entity_test_bundle_class/src/Hook/EntityTestBundleClassHooks.php
+++ b/core/modules/system/tests/modules/entity_test_bundle_class/src/Hook/EntityTestBundleClassHooks.php
@@ -6,8 +6,6 @@
 
 use Drupal\entity_test\Entity\EntityTest;
 use Drupal\entity_test_bundle_class\Entity\EntityTestVariant;
-use Drupal\entity_test_bundle_class\Entity\SharedEntityTestBundleClassB;
-use Drupal\entity_test_bundle_class\Entity\SharedEntityTestBundleClassA;
 use Drupal\entity_test_bundle_class\Entity\EntityTestUserClass;
 use Drupal\entity_test_bundle_class\Entity\NonInheritingBundleClass;
 use Drupal\entity_test_bundle_class\Entity\EntityTestAmbiguousBundleClass;
@@ -24,9 +22,6 @@ class EntityTestBundleClassHooks {
    */
   #[Hook('entity_bundle_info_alter')]
   public function entityBundleInfoAlter(&$bundles): void {
-    if (!empty($bundles['entity_test']['bundle_class'])) {
-      $bundles['entity_test']['bundle_class']['class'] = EntityTestBundleClass::class;
-    }
     if (\Drupal::state()->get('entity_test_bundle_class_enable_ambiguous_entity_types', FALSE)) {
       $bundles['entity_test']['bundle_class_2']['class'] = EntityTestBundleClass::class;
       $bundles['entity_test']['entity_test_no_label']['class'] = EntityTestAmbiguousBundleClass::class;
@@ -41,15 +36,6 @@ public function entityBundleInfoAlter(&$bundles): void {
     if (\Drupal::state()->get('entity_test_bundle_class_does_not_exist', FALSE)) {
       $bundles['entity_test']['bundle_class']['class'] = '\Drupal\Core\NonExistentClass';
     }
-    // Have two bundles share the same base entity class.
-    $bundles['shared_type']['bundle_a'] = [
-      'label' => 'Bundle A',
-      'class' => SharedEntityTestBundleClassA::class,
-    ];
-    $bundles['shared_type']['bundle_b'] = [
-      'label' => 'Bundle B',
-      'class' => SharedEntityTestBundleClassB::class,
-    ];
   }
 
   /**
-- 
GitLab


From 6c43efce51bd58dd2fedfbd56a7b6c932e50069e Mon Sep 17 00:00:00 2001
From: Michael Strelan <mstrelan@gmail.com>
Date: Thu, 20 Mar 2025 11:02:45 +1000
Subject: [PATCH 2/5] Set bundle class

---
 core/lib/Drupal/Core/Entity/Attribute/Bundle.php      |  2 +-
 .../Drupal/Core/Entity/BundleClassCollectorPass.php   |  5 +++--
 core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php  | 11 +++++++++++
 3 files changed, 15 insertions(+), 3 deletions(-)

diff --git a/core/lib/Drupal/Core/Entity/Attribute/Bundle.php b/core/lib/Drupal/Core/Entity/Attribute/Bundle.php
index 049151e9fe6e..438232f3bc4c 100644
--- a/core/lib/Drupal/Core/Entity/Attribute/Bundle.php
+++ b/core/lib/Drupal/Core/Entity/Attribute/Bundle.php
@@ -7,7 +7,7 @@
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 
 /**
- * Attribute for registering a bundle class.
+ * Defines an attribute for registering a bundle class.
  */
 #[\Attribute(\Attribute::TARGET_CLASS)]
 class Bundle {
diff --git a/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
index 7373cbe92d2e..22c55a694d99 100644
--- a/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
+++ b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
@@ -61,8 +61,9 @@ protected function collectBundleClasses(string $directory, string $namespace, st
         continue;
       }
       $reflection = new \ReflectionClass($fqcn);
-      if ($reflection->getAttributes(Bundle::class)) {
-        $this->bundleClasses[$fqcn] = $fqcn;
+      $attributes = $reflection->getAttributes(Bundle::class);
+      if (count($attributes) >= 0) {
+        $this->bundleClasses[$fqcn] = $attributes[0]->getArguments();
       }
     }
   }
diff --git a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
index 93a7bc6e2700..2656e143dfa2 100644
--- a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
+++ b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
@@ -107,6 +107,17 @@ public function getAllBundleInfo() {
             $this->bundleInfo[$type][$type]['label'] = $entity_type->getLabel();
           }
         }
+
+        // Bundle classes.
+        // @todo how to inject container to get params?
+        $bundle_classes = \Drupal::getContainer()->getParameter('entity.bundle_classes');
+        foreach ($bundle_classes as $class => $info) {
+          $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['class'] = $class;
+          $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['label'] = $info['label']
+            ?? $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['label']
+            ?? $info['bundle'];
+        }
+
         $this->moduleHandler->alter('entity_bundle_info', $this->bundleInfo);
         $this->cacheSet("entity_bundle_info:$langcode", $this->bundleInfo, Cache::PERMANENT, [
           'entity_types',
-- 
GitLab


From 087e1a63c4ee7c2aafb4c7e144e66c909eb065d4 Mon Sep 17 00:00:00 2001
From: Michael Strelan <mstrelan@gmail.com>
Date: Thu, 20 Mar 2025 11:22:17 +1000
Subject: [PATCH 3/5] Fixes

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

diff --git a/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
index 22c55a694d99..a986384fd4ad 100644
--- a/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
+++ b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
@@ -18,7 +18,7 @@ class BundleClassCollectorPass implements CompilerPassInterface {
   /**
    * The bundle classes.
    *
-   * @var array<class-string, class-string>
+   * @var array<class-string, array{'entityTypeId': string, 'bundle': string|null, 'label': \Drupal\Core\StringTranslation\TranslatableMarkup|null}>
    */
   protected array $bundleClasses = [];
 
@@ -62,7 +62,7 @@ protected function collectBundleClasses(string $directory, string $namespace, st
       }
       $reflection = new \ReflectionClass($fqcn);
       $attributes = $reflection->getAttributes(Bundle::class);
-      if (count($attributes) >= 0) {
+      if (count($attributes) > 0) {
         $this->bundleClasses[$fqcn] = $attributes[0]->getArguments();
       }
     }
-- 
GitLab


From dc2e8d56c7c47b13a87128f28af74c414ec958cd Mon Sep 17 00:00:00 2001
From: Michael Strelan <mstrelan@gmail.com>
Date: Thu, 20 Mar 2025 11:41:40 +1000
Subject: [PATCH 4/5] Inject container params

---
 core/core.services.yml                               | 2 +-
 core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php | 8 ++++----
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/core/core.services.yml b/core/core.services.yml
index dfb5274960f3..047d96b6391e 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -727,7 +727,7 @@ services:
   Drupal\Core\Entity\EntityTypeRepositoryInterface: '@entity_type.repository'
   entity_type.bundle.info:
     class: Drupal\Core\Entity\EntityTypeBundleInfo
-    arguments: ['@entity_type.manager', '@language_manager', '@module_handler', '@typed_data_manager', '@cache.discovery']
+    arguments: ['@entity_type.manager', '@language_manager', '@module_handler', '@typed_data_manager', '@cache.discovery', '%entity.bundle_classes%']
   Drupal\Core\Entity\EntityTypeBundleInfoInterface: '@entity_type.bundle.info'
   entity.repository:
     class: Drupal\Core\Entity\EntityRepository
diff --git a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
index 2656e143dfa2..f0691879e0cb 100644
--- a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
+++ b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
@@ -64,8 +64,10 @@ class EntityTypeBundleInfo implements EntityTypeBundleInfoInterface {
    *   The typed data manager.
    * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
    *   The cache backend.
+   * @param array $bundle_classes
+   *   An array of bundle classes.
    */
-  public function __construct(EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, ModuleHandlerInterface $module_handler, TypedDataManagerInterface $typed_data_manager, CacheBackendInterface $cache_backend) {
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, ModuleHandlerInterface $module_handler, TypedDataManagerInterface $typed_data_manager, CacheBackendInterface $cache_backend, protected array $bundleClasses = []) {
     $this->entityTypeManager = $entity_type_manager;
     $this->languageManager = $language_manager;
     $this->moduleHandler = $module_handler;
@@ -109,9 +111,7 @@ public function getAllBundleInfo() {
         }
 
         // Bundle classes.
-        // @todo how to inject container to get params?
-        $bundle_classes = \Drupal::getContainer()->getParameter('entity.bundle_classes');
-        foreach ($bundle_classes as $class => $info) {
+        foreach ($this->bundleClasses as $class => $info) {
           $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['class'] = $class;
           $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['label'] = $info['label']
             ?? $this->bundleInfo[$info['entityTypeId']][$info['bundle']]['label']
-- 
GitLab


From 93d3deefde607450587387c313e4e95b1bb6ee6d Mon Sep 17 00:00:00 2001
From: Michael Strelan <mstrelan@gmail.com>
Date: Thu, 20 Mar 2025 11:45:38 +1000
Subject: [PATCH 5/5] cs

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

diff --git a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
index f0691879e0cb..f247df4456f4 100644
--- a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
+++ b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
@@ -64,8 +64,8 @@ class EntityTypeBundleInfo implements EntityTypeBundleInfoInterface {
    *   The typed data manager.
    * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
    *   The cache backend.
-   * @param array $bundle_classes
-   *   An array of bundle classes.
+   * @param array $bundleClasses
+   *   An array of bundle class info, keyed by fully qualified class name.
    */
   public function __construct(EntityTypeManagerInterface $entity_type_manager, LanguageManagerInterface $language_manager, ModuleHandlerInterface $module_handler, TypedDataManagerInterface $typed_data_manager, CacheBackendInterface $cache_backend, protected array $bundleClasses = []) {
     $this->entityTypeManager = $entity_type_manager;
-- 
GitLab