diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
index 2b9feb8b2d91f0509168a620912ceca868fce367..3009388294ee9fc4b2f39a54369a4f95c4005227 100644
--- a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
+++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
@@ -3,11 +3,12 @@
 namespace Drupal\Tests\auto_updates\Build;
 
 use Drupal\Component\Utility\Html;
+use Drupal\Tests\package_manager\Build\TemplateProjectTestBase;
 
 /**
  * Base class for tests that perform in-place updates.
  */
-abstract class UpdateTestBase extends TemplateProjectSiteTestBase {
+abstract class UpdateTestBase extends TemplateProjectTestBase {
 
   /**
    * A secondary server instance, to serve XML metadata about available updates.
@@ -32,16 +33,8 @@ protected function tearDown(): void {
   protected function createTestProject(string $template): void {
     parent::createTestProject($template);
 
-    // Install Drupal. Always allow test modules to be installed in the UI and,
-    // for easier debugging, always display errors in their dubious glory.
+    // Install Drupal, Automatic Updates, and other modules needed for testing.
     $this->installQuickStart('minimal');
-    $php = <<<END
-\$settings['extension_discovery_scan_tests'] = TRUE;
-\$config['system.logging']['error_level'] = 'verbose';
-END;
-    $this->writeSettings($php);
-
-    // Install Automatic Updates and other modules needed for testing.
     $this->formLogin($this->adminUsername, $this->adminPassword);
     $this->installModules([
       'auto_updates',
@@ -50,27 +43,6 @@ protected function createTestProject(string $template): void {
     ]);
   }
 
-  /**
-   * Appends PHP code to the test site's settings.php.
-   *
-   * @param string $php
-   *   The PHP code to append to the test site's settings.php.
-   */
-  protected function writeSettings(string $php): void {
-    // Ensure settings are writable, since this is the only way we can set
-    // configuration values that aren't accessible in the UI.
-    $file = $this->getWebRoot() . '/sites/default/settings.php';
-    $this->assertFileExists($file);
-    chmod(dirname($file), 0744);
-    chmod($file, 0744);
-    $this->assertFileIsWritable($file);
-
-    $stream = fopen($file, 'a');
-    $this->assertIsResource($stream);
-    $this->assertIsInt(fwrite($stream, $php));
-    $this->assertTrue(fclose($stream));
-  }
-
   /**
    * Prepares the test site to serve an XML feed of available release metadata.
    *
@@ -99,33 +71,6 @@ protected function setReleaseMetadata(array $xml_map): void {
     $this->writeSettings($code);
   }
 
-  /**
-   * Installs modules in the UI.
-   *
-   * Assumes that a user with the appropriate permissions is logged in.
-   *
-   * @param string[] $modules
-   *   The machine names of the modules to install.
-   */
-  protected function installModules(array $modules): void {
-    $mink = $this->getMink();
-    $page = $mink->getSession()->getPage();
-    $assert_session = $mink->assertSession();
-
-    $this->visit('/admin/modules');
-    foreach ($modules as $module) {
-      $page->checkField("modules[$module][enable]");
-    }
-    $page->pressButton('Install');
-
-    $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]')
-      ->getValue();
-    if (preg_match('/^system_modules_(experimental_|non_stable_)?confirm_form$/', $form_id)) {
-      $page->pressButton('Continue');
-      $assert_session->statusCodeEquals(200);
-    }
-  }
-
   /**
    * Checks for available updates.
    *
diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
index 2d71da3d93c0f8b96c76cbeb98b3f365bfda815c..20890c80c59f9d3a4bc04e641f88a12e0f8af68b 100644
--- a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
@@ -86,7 +86,9 @@ public function testCorrectVersionsStaged() {
     // invocation recorder, rather than a regular mock, in order to test that
     // the invocation recorder itself works.
     // The production requirements are changed first, followed by the dev
-    // requirements. Then the installed packages are updated.
+    // requirements. Then the installed packages are updated. This is tested
+    // functionally in Package Manager.
+    // @see \Drupal\Tests\package_manager\Build\StagedUpdateTest
     $expected_arguments = [
       [
         'require',
diff --git a/core/modules/package_manager/src/ProcessFactory.php b/core/modules/package_manager/src/ProcessFactory.php
index 4eb5195cbf1659893a3dc9d2e7bccd0dd47e7640..78ef902b2d909cb6e7dfb460ed3fe9612d5fa0e4 100644
--- a/core/modules/package_manager/src/ProcessFactory.php
+++ b/core/modules/package_manager/src/ProcessFactory.php
@@ -87,7 +87,7 @@ public function create(array $command): Process {
    */
   private function getComposerHomePath(): string {
     $home_path = $this->fileSystem->getTempDirectory();
-    $home_path .= '/auto_updates_composer_home-';
+    $home_path .= '/package_manager_composer_home-';
     $home_path .= $this->configFactory->get('system.site')->get('uuid');
     $this->fileSystem->prepareDirectory($home_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
 
diff --git a/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php b/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
index db516e5089a0b643015e0fbd4adb68a6d8cd8258..f2e4e46157be32f80aa96d7cfe2833d91d014176 100644
--- a/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
+++ b/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
@@ -2,9 +2,9 @@
 
 namespace Drupal\package_manager\Validator;
 
+use Composer\Semver\Comparator;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
-use Drupal\Core\Extension\ExtensionVersion;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
 use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
@@ -18,6 +18,13 @@ class ComposerExecutableValidator implements PreOperationStageValidatorInterface
 
   use StringTranslationTrait;
 
+  /**
+   * The minimum required version of Composer.
+   *
+   * @var string
+   */
+  public const MINIMUM_COMPOSER_VERSION = '2.2.4';
+
   /**
    * The Composer runner.
    *
@@ -60,13 +67,11 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
     }
 
     if ($this->version) {
-      $major_version = ExtensionVersion::createFromVersionString($this->version)
-        ->getMajorVersion();
-
-      if ($major_version < 2) {
+      if (Comparator::lessThan($this->version, static::MINIMUM_COMPOSER_VERSION)) {
         $event->addError([
-          $this->t('Composer 2 or later is required, but version @version was detected.', [
-            '@version' => $this->version,
+          $this->t('Composer @minimum_version or later is required, but version @detected_version was detected.', [
+            '@minimum_version' => static::MINIMUM_COMPOSER_VERSION,
+            '@detected_version' => $this->version,
           ]),
         ]);
       }
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6a64061f1ced4f499e98e8faf152b722fa1e02f6
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml
@@ -0,0 +1,6 @@
+name: 'Package Manager Test API'
+description: 'Provides API endpoints for doing stage operations in functional tests.'
+type: module
+package: Testing
+dependencies:
+  - auto_updates:package_manager
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4b5cf58ca1f5f30d96cb984bb097e2bccdf6fae0
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
@@ -0,0 +1,6 @@
+package_manager_test_api.require:
+  path: '/package-manager-test-api/require'
+  defaults:
+    _controller: 'Drupal\package_manager_test_api\ApiController::require'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
new file mode 100644
index 0000000000000000000000000000000000000000..a9f116ff2bc82ac62497304e31790a7c1953a208
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\package_manager_test_api;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\package_manager\Stage;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Provides API endpoints to interact with a staging area in functional tests.
+ */
+class ApiController extends ControllerBase {
+
+  /**
+   * The stage.
+   *
+   * @var \Drupal\package_manager\Stage
+   */
+  private $stage;
+
+  /**
+   * Constructs an ApiController object.
+   *
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage.
+   */
+  public function __construct(Stage $stage) {
+    $this->stage = $stage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    $stage = new Stage(
+      $container->get('package_manager.path_locator'),
+      $container->get('package_manager.beginner'),
+      $container->get('package_manager.stager'),
+      $container->get('package_manager.committer'),
+      $container->get('file_system'),
+      $container->get('event_dispatcher'),
+      $container->get('tempstore.shared'),
+    );
+    return new static($stage);
+  }
+
+  /**
+   * Creates a staging area and requires packages into it.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request. The runtime and dev dependencies are expected to be in
+   *   either the query string or request body, under the 'runtime' and 'dev'
+   *   keys, respectively. There may also be a 'files_to_return' key, which
+   *   contains an array of file paths, relative to the stage directory, whose
+   *   contents should be returned in the response.
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   *   A JSON response containing an associative array of the contents of the
+   *   staged files listed in the 'files_to_return' request key. The array will
+   *   be keyed by path, relative to the stage directory.
+   */
+  public function require(Request $request): JsonResponse {
+    $this->stage->create();
+    $this->stage->require(
+      $request->get('runtime', []),
+      $request->get('dev', [])
+    );
+
+    $stage_dir = $this->stage->getStageDirectory();
+    $staged_file_contents = [];
+    foreach ($request->get('files_to_return', []) as $path) {
+      $staged_file_contents[$path] = file_get_contents($stage_dir . '/' . $path);
+    }
+    $this->stage->destroy();
+
+    return new JsonResponse($staged_file_contents);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php b/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8dbf40363380cadb19729d7d43dc343ae59bdf3e
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Build;
+
+/**
+ * Tests updating packages in a staging area.
+ *
+ * @group package_manager
+ */
+class StagedUpdateTest extends TemplateProjectTestBase {
+
+  /**
+   * Tests that a stage only updates packages with changed constraints.
+   */
+  public function testStagedUpdate(): void {
+    $this->createTestProject('RecommendedProject');
+
+    $this->createModule('alpha');
+    $this->createModule('bravo');
+    $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha drupal/bravo --update-with-all-dependencies', 'project');
+
+    $this->installQuickStart('minimal');
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->installModules(['package_manager_test_api']);
+
+    // Change both modules' upstream version.
+    $this->runComposer('composer config version 1.1.0', 'alpha');
+    $this->runComposer('composer config version 1.1.0', 'bravo');
+
+    // Use the API endpoint to create a stage and update bravo to 1.1.0. Even
+    // though both modules are at version 1.1.0, only bravo should be updated.
+    // We ask the API to return the contents of both modules' staged
+    // composer.json files, so we can assert that the staged versions are what
+    // we expect.
+    // @see \Drupal\package_manager_test_api\ApiController::require()
+    $query = http_build_query([
+      'runtime' => [
+        'drupal/bravo:1.1.0',
+      ],
+      'files_to_return' => [
+        'web/modules/contrib/alpha/composer.json',
+        'web/modules/contrib/bravo/composer.json',
+      ],
+    ]);
+    $this->visit("/package-manager-test-api/require?$query");
+    $mink = $this->getMink();
+    $mink->assertSession()->statusCodeEquals(200);
+
+    $staged_file_contents = $mink->getSession()->getPage()->getContent();
+    $staged_file_contents = json_decode($staged_file_contents, TRUE);
+
+    $expected_versions = [
+      'alpha' => '1.0.0',
+      'bravo' => '1.1.0',
+    ];
+    foreach ($expected_versions as $module_name => $expected_version) {
+      $path = "web/modules/contrib/$module_name/composer.json";
+      $staged_composer_json = json_decode($staged_file_contents[$path]);
+      $this->assertSame($expected_version, $staged_composer_json->version);
+    }
+  }
+
+  /**
+   * Creates an empty module for testing purposes.
+   *
+   * @param string $name
+   *   The machine name of the module, which can be added to the test site as
+   *   'drupal/$name'.
+   */
+  private function createModule(string $name): void {
+    $dir = $this->getWorkspaceDirectory() . '/' . $name;
+    mkdir($dir);
+    $this->assertDirectoryExists($dir);
+    $this->runComposer("composer init --name drupal/$name --type drupal-module", $name);
+    $this->runComposer('composer config version 1.0.0', $name);
+
+    $repository = json_encode([
+      'type' => 'path',
+      'url' => $dir,
+      'options' => [
+        'symlink' => FALSE,
+      ],
+    ]);
+    $this->runComposer("composer config repo.$name '$repository'", 'project');
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
similarity index 80%
rename from core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php
rename to core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
index dbf0d3d8301cfcf27365dff267666a4155627063..21df43a3b6ad0969dbdc8559f247cef1fc1082e3 100644
--- a/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php
+++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
@@ -1,14 +1,19 @@
 <?php
 
-namespace Drupal\Tests\auto_updates\Build;
+namespace Drupal\Tests\package_manager\Build;
 
 use Drupal\BuildTests\QuickStart\QuickStartTestBase;
 use Drupal\Composer\Composer;
 
 /**
  * Base class for tests which create a test site from a core project template.
+ *
+ * The test site will be created from one of the core Composer project templates
+ * (drupal/recommended-project or drupal/legacy-project) and contain complete
+ * copies of Drupal core and all installed dependencies, completely independent
+ * of the currently running code base.
  */
-abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
+abstract class TemplateProjectTestBase extends QuickStartTestBase {
 
   /**
    * The web root of the test site, relative to the workspace directory.
@@ -30,20 +35,6 @@ public function providerTemplate(): array {
     ];
   }
 
-  /**
-   * {@inheritdoc}
-   */
-  public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) {
-    parent::copyCodebase($iterator, $working_dir);
-
-    // In certain situations, like Drupal CI, auto_updates might be
-    // required into the code base by Composer. This may cause it to be added to
-    // the drupal/core-recommended metapackage, which can prevent the test site
-    // from being built correctly, among other deleterious effects. To prevent
-    // such shenanigans, always remove drupal/auto_updates from
-    // drupal/core-recommended.
-    $this->runComposer('composer remove --no-update drupal/auto_updates', 'composer/Metapackage/CoreRecommended');
-  }
 
   /**
    * {@inheritdoc}
@@ -77,6 +68,14 @@ protected function instantiateServer($port, $working_dir = NULL) {
    */
   public function installQuickStart($profile, $working_dir = NULL) {
     parent::installQuickStart($profile, $working_dir ?: $this->webRoot);
+
+    // Always allow test modules to be installed in the UI and, for easier
+    // debugging, always display errors in their dubious glory.
+    $php = <<<END
+\$settings['extension_discovery_scan_tests'] = TRUE;
+\$config['system.logging']['error_level'] = 'verbose';
+END;
+    $this->writeSettings($php);
   }
 
   /**
@@ -197,6 +196,7 @@ protected function createTestProject(string $template): void {
     // Now that we know the project was created successfully, we can set the
     // web root with confidence.
     $this->webRoot = 'project/' . $this->runComposer('composer config extra.drupal-scaffold.locations.web-root', 'project');
+
   }
 
   /**
@@ -292,4 +292,54 @@ protected function runComposer(string $command, string $working_dir = NULL, bool
     return $output;
   }
 
+  /**
+   * Appends PHP code to the test site's settings.php.
+   *
+   * @param string $php
+   *   The PHP code to append to the test site's settings.php.
+   */
+  protected function writeSettings(string $php): void {
+    // Ensure settings are writable, since this is the only way we can set
+    // configuration values that aren't accessible in the UI.
+    $file = $this->getWebRoot() . '/sites/default/settings.php';
+    $this->assertFileExists($file);
+    chmod(dirname($file), 0744);
+    chmod($file, 0744);
+    $this->assertFileIsWritable($file);
+
+    $stream = fopen($file, 'a');
+    $this->assertIsResource($stream);
+    $this->assertIsInt(fwrite($stream, $php));
+    $this->assertTrue(fclose($stream));
+  }
+
+  /**
+   * Installs modules in the UI.
+   *
+   * Assumes that a user with the appropriate permissions is logged in.
+   *
+   * @param string[] $modules
+   *   The machine names of the modules to install.
+   */
+  protected function installModules(array $modules): void {
+    $mink = $this->getMink();
+    $page = $mink->getSession()->getPage();
+    $assert_session = $mink->assertSession();
+
+    $this->visit('/admin/modules');
+    foreach ($modules as $module) {
+      $page->checkField("modules[$module][enable]");
+    }
+    $page->pressButton('Install');
+
+    // If there is a confirmation form warning about additional dependencies
+    // or non-stable modules, submit it.
+    $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]')
+      ->getValue();
+    if (preg_match('/^system_modules_(experimental_|non_stable_)?confirm_form$/', $form_id)) {
+      $page->pressButton('Continue');
+      $assert_session->statusCodeEquals(200);
+    }
+  }
+
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
index 12b4fe415ca89cc14febdada697392017b38e5a0..9b8236a786fd6b309a033deb601b51e24dca154b 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
@@ -52,17 +52,22 @@ public function providerComposerVersionValidation(): array {
     // in the validation result, so we need a function to churn out those fake
     // results for the test method.
     $unsupported_version = function (string $version): ValidationResult {
+      $minimum_version = ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION;
+
       return ValidationResult::createError([
-        "Composer 2 or later is required, but version $version was detected.",
+        "Composer $minimum_version or later is required, but version $version was detected.",
       ]);
     };
 
     return [
-      // A valid 2.x version of Composer should not produce any errors.
       [
-        '2.1.6',
+        ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION,
         [],
       ],
+      [
+        '2.1.6',
+        [$unsupported_version('2.1.6')],
+      ],
       [
         '1.10.22',
         [$unsupported_version('1.10.22')],
@@ -73,11 +78,11 @@ public function providerComposerVersionValidation(): array {
       ],
       [
         '2.0.0-alpha3',
-        [],
+        [$unsupported_version('2.0.0-alpha3')],
       ],
       [
         '2.1.0-RC1',
-        [],
+        [$unsupported_version('2.1.0-RC1')],
       ],
       [
         '1.0.0-RC',