From 9244acff98497d4b7961b4d3eba3e9b30a67b375 Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Sun, 2 Mar 2025 16:32:18 +0000
Subject: [PATCH] Issue #3502882 by catch, alexpott, godotislate: Add a
 classloader that can handle class moves

---
 core/core.services.yml                        |  3 ++
 core/includes/bootstrap.inc                   |  5 --
 .../BackwardsCompatibilityClassLoader.php     | 28 +++++++++++
 core/lib/Drupal/Core/CoreServiceProvider.php  |  4 ++
 .../BackwardsCompatibilityClassLoaderPass.php | 35 ++++++++++++++
 core/lib/Drupal/Core/DrupalKernel.php         |  6 +++
 .../module_autoload_test.services.yml         |  7 +++
 .../BackwardsCompatibilityClassLoaderTest.php | 46 +++++++++++++++++++
 .../TranslationWrapperTest.php                | 27 -----------
 9 files changed, 129 insertions(+), 32 deletions(-)
 create mode 100644 core/lib/Drupal/Core/ClassLoader/BackwardsCompatibilityClassLoader.php
 create mode 100644 core/lib/Drupal/Core/DependencyInjection/Compiler/BackwardsCompatibilityClassLoaderPass.php
 create mode 100644 core/modules/system/tests/modules/module_autoload_test/module_autoload_test.services.yml
 create mode 100644 core/tests/Drupal/KernelTests/Core/ClassLoader/BackwardsCompatibilityClassLoaderTest.php
 delete mode 100644 core/tests/Drupal/Tests/Core/StringTranslation/TranslationWrapperTest.php

diff --git a/core/core.services.yml b/core/core.services.yml
index 54fbf1579777..bb73e29d505b 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -9,6 +9,9 @@ parameters:
   cache_default_bin_backends: []
   memory_cache_default_bin_backends: []
   security.enable_super_user: true
+  core.moved_classes:
+    'Drupal\Core\StringTranslation\TranslationWrapper':
+      class: 'Drupal\Core\StringTranslation\TranslatableMarkup'
   session.storage.options:
     gc_probability: 1
     gc_divisor: 100
diff --git a/core/includes/bootstrap.inc b/core/includes/bootstrap.inc
index 4cffe40471ee..0a90a7472e12 100644
--- a/core/includes/bootstrap.inc
+++ b/core/includes/bootstrap.inc
@@ -44,11 +44,6 @@
  */
 define('DRUPAL_ROOT', dirname(__DIR__, 2));
 
-/**
- * Keep backward compatibility for sites with references to TranslationWrapper.
- */
-class_alias(TranslatableMarkup::class, '\Drupal\Core\StringTranslation\TranslationWrapper', TRUE);
-
 /**
  * Translates a string to the current language or to a given language.
  *
diff --git a/core/lib/Drupal/Core/ClassLoader/BackwardsCompatibilityClassLoader.php b/core/lib/Drupal/Core/ClassLoader/BackwardsCompatibilityClassLoader.php
new file mode 100644
index 000000000000..078579ce4cc7
--- /dev/null
+++ b/core/lib/Drupal/Core/ClassLoader/BackwardsCompatibilityClassLoader.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\ClassLoader;
+
+final class BackwardsCompatibilityClassLoader {
+
+  public function __construct(protected array $movedClasses) {}
+
+  /**
+   * Aliases a moved class to another class, instead of actually autoloading it.
+   *
+   * @param string $class
+   *   The classname to load.
+   */
+  public function loadClass(string $class): void {
+    if (isset($this->movedClasses[$class])) {
+      $moved = $this->movedClasses[$class];
+      if (isset($moved['deprecation_version']) && isset($moved['removed_version']) && isset($moved['change_record'])) {
+        // @phpcs:ignore
+        @trigger_error(sprintf('Class %s is deprecated in %s and is removed from %s, use %s instead. See %s', $class, $moved['deprecation_version'], $moved['removed_version'], $moved['class'], $moved['change_record']), E_USER_DEPRECATED);
+      }
+      class_alias($moved['class'], $class, TRUE);
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Core/CoreServiceProvider.php b/core/lib/Drupal/Core/CoreServiceProvider.php
index 7f0c897b6547..106df846dbd7 100644
--- a/core/lib/Drupal/Core/CoreServiceProvider.php
+++ b/core/lib/Drupal/Core/CoreServiceProvider.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Cache\ListCacheBinsPass;
 use Drupal\Core\DependencyInjection\Compiler\AuthenticationProviderPass;
 use Drupal\Core\DependencyInjection\Compiler\BackendCompilerPass;
+use Drupal\Core\DependencyInjection\Compiler\BackwardsCompatibilityClassLoaderPass;
 use Drupal\Core\DependencyInjection\Compiler\CorsCompilerPass;
 use Drupal\Core\DependencyInjection\Compiler\DeprecatedServicePass;
 use Drupal\Core\DependencyInjection\Compiler\DevelopmentSettingsPass;
@@ -110,6 +111,9 @@ public function register(ContainerBuilder $container) {
 
     $container->addCompilerPass(new DeprecatedServicePass());
 
+    // Collect moved classes for the backwards compatibility class loader.
+    $container->addCompilerPass(new BackwardsCompatibilityClassLoaderPass());
+
     $container->registerForAutoconfiguration(EventSubscriberInterface::class)
       ->addTag('event_subscriber');
 
diff --git a/core/lib/Drupal/Core/DependencyInjection/Compiler/BackwardsCompatibilityClassLoaderPass.php b/core/lib/Drupal/Core/DependencyInjection/Compiler/BackwardsCompatibilityClassLoaderPass.php
new file mode 100644
index 000000000000..d698b45cde00
--- /dev/null
+++ b/core/lib/Drupal/Core/DependencyInjection/Compiler/BackwardsCompatibilityClassLoaderPass.php
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\DependencyInjection\Compiler;
+
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+
+/**
+ * Defines a compiler pass to merge moved classes into a single container parameter.
+ */
+class BackwardsCompatibilityClassLoaderPass implements CompilerPassInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process(ContainerBuilder $container): void {
+    $moved_classes = $container->hasParameter('core.moved_classes') ? $container->getParameter('core.moved_classes') : [];
+    $modules = array_keys($container->getParameter('container.modules'));
+    foreach ($modules as $module) {
+      $parameter_name = $module . '.moved_classes';
+      if ($container->hasParameter($parameter_name)) {
+        $module_moved = $container->getParameter($parameter_name);
+        \assert(is_array($module_moved));
+        \assert(count($module_moved) === count(array_column($module_moved, 'class')), 'Missing class key for moved classes in ' . $module);
+        $moved_classes = $moved_classes + $module_moved;
+      }
+    }
+    if (!empty($moved_classes)) {
+      $container->setParameter('moved_classes', $moved_classes);
+    }
+  }
+
+}
diff --git a/core/lib/Drupal/Core/DrupalKernel.php b/core/lib/Drupal/Core/DrupalKernel.php
index b962ffe589ee..f947419d9448 100644
--- a/core/lib/Drupal/Core/DrupalKernel.php
+++ b/core/lib/Drupal/Core/DrupalKernel.php
@@ -8,6 +8,7 @@
 use Drupal\Component\Serialization\PhpSerialize;
 use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Cache\DatabaseBackend;
+use Drupal\Core\ClassLoader\BackwardsCompatibilityClassLoader;
 use Drupal\Core\Config\BootstrapConfigStorageFactory;
 use Drupal\Core\Config\NullStorage;
 use Drupal\Core\DependencyInjection\ContainerBuilder;
@@ -513,6 +514,11 @@ public function boot() {
       $this->classLoader->setApcuPrefix($prefix);
     }
 
+    if ($this->container->hasParameter('moved_classes')) {
+      $bc_class_loader = new BackwardsCompatibilityClassLoader($this->container->getParameter('moved_classes'));
+      spl_autoload_register([$bc_class_loader, 'loadClass']);
+    }
+
     $this->booted = TRUE;
 
     return $this;
diff --git a/core/modules/system/tests/modules/module_autoload_test/module_autoload_test.services.yml b/core/modules/system/tests/modules/module_autoload_test/module_autoload_test.services.yml
new file mode 100644
index 000000000000..1a34a178f7ff
--- /dev/null
+++ b/core/modules/system/tests/modules/module_autoload_test/module_autoload_test.services.yml
@@ -0,0 +1,7 @@
+parameters:
+  module_autoload_test.moved_classes:
+    'Drupal\module_autoload_test\Foo':
+      class: 'Drupal\Component\Utility\Random'
+      deprecation_version: drupal:11.2.0
+      removed_version: drupal:12.0.0
+      change_record: https://www.drupal.org/project/drupal/issues/3502882
diff --git a/core/tests/Drupal/KernelTests/Core/ClassLoader/BackwardsCompatibilityClassLoaderTest.php b/core/tests/Drupal/KernelTests/Core/ClassLoader/BackwardsCompatibilityClassLoaderTest.php
new file mode 100644
index 000000000000..f96446c42371
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/ClassLoader/BackwardsCompatibilityClassLoaderTest.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\ClassLoader;
+
+use Drupal\Component\Utility\Random;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\StringTranslation\TranslationWrapper;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\module_autoload_test\Foo;
+
+/**
+ * @coversDefaultClass Drupal\Core\ClassLoader\BackwardsCompatibilityClassLoader
+ * @group ClassLoader
+ */
+class BackwardsCompatibilityClassLoaderTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['module_autoload_test'];
+
+  /**
+   * Tests that the bc layer for TranslationWrapper works.
+   */
+  public function testTranslationWrapper(): void {
+    // @phpstan-ignore class.notFound
+    $object = new TranslationWrapper('Backward compatibility');
+    $this->assertInstanceOf(TranslatableMarkup::class, $object);
+  }
+
+  /**
+   * Tests that a moved class from a module works.
+   *
+   * @group legacy
+   */
+  public function testModuleMovedClass():  void {
+    // @phpstan-ignore class.notFound
+    $this->expectDeprecation('Class ' . Foo::class . ' is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0, use Drupal\Component\Utility\Random instead. See https://www.drupal.org/project/drupal/issues/3502882');
+    // @phpstan-ignore class.notFound
+    $object = new Foo();
+    $this->assertInstanceOf(Random::class, $object);
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/StringTranslation/TranslationWrapperTest.php b/core/tests/Drupal/Tests/Core/StringTranslation/TranslationWrapperTest.php
deleted file mode 100644
index e013aaed8cdb..000000000000
--- a/core/tests/Drupal/Tests/Core/StringTranslation/TranslationWrapperTest.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\Tests\Core\StringTranslation;
-
-use Drupal\Core\StringTranslation\TranslatableMarkup;
-use Drupal\Core\StringTranslation\TranslationWrapper;
-use Drupal\Tests\UnitTestCase;
-
-/**
- * Tests the TranslationWrapper backward compatibility layer.
- *
- * @coversDefaultClass \Drupal\Core\StringTranslation\TranslationWrapper
- * @group StringTranslation
- */
-class TranslationWrapperTest extends UnitTestCase {
-
-  /**
-   * @covers ::__construct
-   */
-  public function testTranslationWrapper(): void {
-    $object = new TranslationWrapper('Backward compatibility');
-    $this->assertInstanceOf(TranslatableMarkup::class, $object);
-  }
-
-}
-- 
GitLab