diff --git a/core/core.services.yml b/core/core.services.yml
index dfb5274960f3cf026bd5221c6211911e2f3e2d26..047d96b6391e9fcb08390e0c4cc8886a5f008335 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/CoreServiceProvider.php b/core/lib/Drupal/Core/CoreServiceProvider.php
index 6150c188d182b746bcf26d2e6cf42796e58de236..2b00638d6e469a302e1df7ea6dbfd497ae16652c 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 0000000000000000000000000000000000000000..438232f3bc4cade05b9fc6bf77e7f818208d4593
--- /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;
+
+/**
+ * Defines an 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 0000000000000000000000000000000000000000..a986384fd4ad4163e6a7cea6faf8466677936dc0
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/BundleClassCollectorPass.php
@@ -0,0 +1,78 @@
+<?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, array{'entityTypeId': string, 'bundle': string|null, 'label': \Drupal\Core\StringTranslation\TranslatableMarkup|null}>
+   */
+  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);
+      $attributes = $reflection->getAttributes(Bundle::class);
+      if (count($attributes) > 0) {
+        $this->bundleClasses[$fqcn] = $attributes[0]->getArguments();
+      }
+    }
+  }
+
+  /**
+   * Filter iterator callback.
+   */
+  protected static function filterIterator(\SplFileInfo $fileInfo, $key, \RecursiveDirectoryIterator $iterator): bool {
+    return $iterator->isDir() || $fileInfo->getExtension() === 'php';
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php b/core/lib/Drupal/Core/Entity/EntityTypeBundleInfo.php
index 93a7bc6e27002b4d5948941d0c1f9b22286945d8..f247df4456f4d7e3e26579b40d4998f4c9459156 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 $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) {
+  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;
@@ -107,6 +109,15 @@ public function getAllBundleInfo() {
             $this->bundleInfo[$type][$type]['label'] = $entity_type->getLabel();
           }
         }
+
+        // Bundle classes.
+        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']
+            ?? $info['bundle'];
+        }
+
         $this->moduleHandler->alter('entity_bundle_info', $this->bundleInfo);
         $this->cacheSet("entity_bundle_info:$langcode", $this->bundleInfo, Cache::PERMANENT, [
           'entity_types',
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 145aa060f90b5607fb8ea27263e0e2c9c224a48c..75b1d76819fe6665f56cde024ec8cf7e8c2d6dd7 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 af5444a98ec9f5bf400791856b9c4cf6508bedb5..5227865a312c97273d430f8ee719e4768c9dda17 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 93cb06690c53ed82eef263445e3ddd267f879278..c4c5abd4e22ac9161568ea5a4da453f2232e92a6 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 0000000000000000000000000000000000000000..5ace395eabf1d35a745446c164e3fbb47ddb3583
--- /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 5e49bd0298b95ea8cb6ee028a758fbc2fcf4e7c4..57e8eb24303f5c2b133518d3d3792e495a132fd8 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,
-    ];
   }
 
   /**