diff --git a/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php b/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
index 459ebb5f85da81f14e88364ca9fa1c0b1648129f..530544d3192ca16a9aea0939e837a5cef4a660d9 100644
--- a/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
+++ b/automatic_updates_extensions/tests/src/Functional/UpdaterFormTest.php
@@ -5,6 +5,7 @@ declare(strict_types = 1);
 namespace Drupal\Tests\automatic_updates_extensions\Functional;
 
 use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
+use Drupal\fixture_manipulator\ActiveFixtureManipulator;
 use Drupal\package_manager_test_validation\StagedDatabaseUpdateValidator;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\StatusCheckEvent;
@@ -88,24 +89,26 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
       'access administration pages',
     ]);
     $this->activeDir = $this->container->get('package_manager.path_locator')->getProjectRoot();
-    $this->addPackage($this->activeDir, [
-      'name' => 'drupal/semver_test',
-      'version' => '8.1.0',
-      'type' => 'drupal-module',
-      'install_path' => '../../web/projects/semver_test',
-    ]);
-    $this->addPackage($this->activeDir, [
-      'name' => 'drupal/aaa_update_test',
-      'version' => '2.0.0',
-      'type' => 'drupal-module',
-      'install_path' => '../../web/projects/aaa_update_test',
-    ]);
-    $this->addPackage($this->activeDir, [
-      'name' => 'drupal/automatic_updates_extensions_test_theme',
-      'version' => '2.0.0',
-      'type' => 'drupal-theme',
-      'install_path' => '../../web/projects/automatic_updates_extensions_test_theme',
-    ]);
+    (new ActiveFixtureManipulator())
+      ->addPackage([
+        'name' => 'drupal/semver_test',
+        'version' => '8.1.0',
+        'type' => 'drupal-module',
+        'install_path' => '../../web/projects/semver_test',
+      ])
+      ->addPackage([
+        'name' => 'drupal/aaa_update_test',
+        'version' => '2.0.0',
+        'type' => 'drupal-module',
+        'install_path' => '../../web/projects/aaa_update_test',
+      ])
+      ->addPackage([
+        'name' => 'drupal/automatic_updates_extensions_test_theme',
+        'version' => '2.0.0',
+        'type' => 'drupal-theme',
+        'install_path' => '../../web/projects/automatic_updates_extensions_test_theme',
+      ])
+      ->commitChanges();
     $this->drupalLogin($user);
     $this->drupalPlaceBlock('local_tasks_block', ['primary' => TRUE]);
   }
@@ -448,7 +451,9 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
       ]
     );
     // One module not installed through composer.
-    $this->removePackage($this->activeDir, 'drupal/aaa_update_test');
+    (new ActiveFixtureManipulator())
+      ->removePackage('drupal/aaa_update_test')
+      ->commitChanges();
     $assert = $this->assertSession();
     $user = $this->createUser(
       [
@@ -464,7 +469,9 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
     $this->assertUpdatesCount(1);
 
     // Both of the modules not installed through composer.
-    $this->removePackage($this->activeDir, 'drupal/semver_test');
+    (new ActiveFixtureManipulator())
+      ->removePackage('drupal/semver_test')
+      ->commitChanges();
     $this->getSession()->reload();
     $assert->pageTextContains('Updates were found, but they must be performed manually. See the list of available updates for more information.');
     $this->assertNoUpdates();
diff --git a/package_manager/tests/fixtures/project_package_conversion/composer.json b/package_manager/tests/fixtures/project_package_conversion/composer.json
deleted file mode 100644
index 0967ef424bce6791893e9a57bb952f80fd536e93..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/package_manager/tests/fixtures/project_package_conversion/composer.lock b/package_manager/tests/fixtures/project_package_conversion/composer.lock
deleted file mode 100644
index 0967ef424bce6791893e9a57bb952f80fd536e93..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/composer.lock
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json b/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json
deleted file mode 100644
index 216c981a15b7846019cdc299051c0acae7df2d12..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
-  "packages": [
-    {
-      "name": "drupal/package_project_match",
-      "version": "6.1.3",
-      "type": "drupal-module"
-    },
-    {
-      "name": "drupal/not_match_package",
-      "version": "6.1.3",
-      "type": "drupal-theme"
-    },
-    {
-      "name": "drupal/not_match_path_project",
-      "version": "6.1.3",
-      "type": "drupal-module"
-    },
-    {
-      "name": "non_drupal/other_project",
-      "version": "6.1.3",
-      "type": "drupal-module"
-    },
-    {
-      "name": "drupal/nested_no_match_package",
-      "version": "6.1.3",
-      "type": "drupal-profile"
-    },
-    {
-      "name": "drupal/custom_module",
-      "version": "6.1.3",
-      "type": "drupal-custom-module"
-    }
-  ]
-}
diff --git a/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php b/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php
deleted file mode 100644
index 301eb95cf7692d55bb6af6ab3df791ad41310e2d..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-/**
- * @file
- */
-
-$projects_dir = __DIR__ . '/../../web/projects';
-return [
-  'versions' => [
-    'drupal/package_project_match' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/package_project_match',
-    ],
-    'drupal/not_match_package' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/not_match_project',
-    ],
-    'drupal/not_match_path_project' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/not_match_project',
-    ],
-    'drupal/nested_no_match_package' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/any_folder_name',
-    ],
-    'non_drupal/other_project' => [
-      'type' => 'drupal-module',
-      'install_path' => $projects_dir . '/other_project',
-    ],
-    'drupal/custom_module' => [
-      'type' => 'drupal-custom-module',
-      'install_path' => $projects_dir . '/custom_module',
-    ],
-  ],
-];
diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide
deleted file mode 100644
index 5b69176b90dae06ae6bbfeec53b0dbbf10a89ef9..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide
+++ /dev/null
@@ -1,3 +0,0 @@
-# A test info.yml file where the folder names and info.yml file names do not match the project or package.
-# Only the project key in this file need to match.
-project: nested_no_match_project
diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide
deleted file mode 100644
index 93021a1460bf4a84d331e717483275f270d61f2a..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide
+++ /dev/null
@@ -1 +0,0 @@
-project: custom_module
diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_path_project/not_match_path_project.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_path_project/not_match_path_project.info.yml.hide
deleted file mode 100644
index af58278b9d1bbbf3ebaa06e2733953d5bb67e6f9..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_path_project/not_match_path_project.info.yml.hide
+++ /dev/null
@@ -1 +0,0 @@
-project: not_match_path_project
diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide
deleted file mode 100644
index 7838d71de598855a0af9060c8e91086b185ed7fe..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide
+++ /dev/null
@@ -1 +0,0 @@
-project: not_match_project
diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide
deleted file mode 100644
index ca54a40db9f6896e27791027fef83df2bd1e6d62..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide
+++ /dev/null
@@ -1 +0,0 @@
-project: other_project
diff --git a/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide b/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide
deleted file mode 100644
index 84896e4f27f748a7e4ad23f7a8e425c08b94470c..0000000000000000000000000000000000000000
--- a/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide
+++ /dev/null
@@ -1 +0,0 @@
-project: package_project_match
diff --git a/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml b/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cebed1395be92c8b1667ecc818f9f21ecdf3f2a5
--- /dev/null
+++ b/package_manager/tests/modules/fixture_manipulator/fixture_manipulator.info.yml
@@ -0,0 +1,4 @@
+name: 'Fixture manipulator'
+description: 'Manipulate fixtures for tests.'
+type: module
+package: Testing
diff --git a/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php b/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php
new file mode 100644
index 0000000000000000000000000000000000000000..24ffb6633dd2c2bef59ede3109ec6b3f45c3ec25
--- /dev/null
+++ b/package_manager/tests/modules/fixture_manipulator/src/ActiveFixtureManipulator.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\fixture_manipulator;
+
+/**
+ * A fixture manipulator for the active directory.
+ */
+final class ActiveFixtureManipulator extends FixtureManipulator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function commitChanges(string $dir = NULL): void {
+    if ($dir) {
+      throw new \UnexpectedValueException("$dir cannot be specific for a ActiveFixtureManipulator instance");
+    }
+    $dir = \Drupal::service('package_manager.path_locator')->getProjectRoot();
+    parent::doCommitChanges($dir);
+  }
+
+}
diff --git a/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php b/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php
new file mode 100644
index 0000000000000000000000000000000000000000..18945da82640e703d75b1a651116d1fa505352e9
--- /dev/null
+++ b/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php
@@ -0,0 +1,338 @@
+<?php
+
+namespace Drupal\fixture_manipulator;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Serialization\Yaml;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * It manipulates.
+ */
+class FixtureManipulator {
+
+  /**
+   * Whether changes are currently being committed.
+   *
+   * @var bool
+   */
+  private bool $committingChanges = FALSE;
+
+  /**
+   * Arguments to manipulator functions.
+   *
+   * @var array
+   */
+  private array $manipulatorArguments = [];
+
+  /**
+   * Whether changes have been committed.
+   *
+   * @var bool
+   */
+  protected bool $committed = FALSE;
+
+  /**
+   * The fixture directory.
+   *
+   * @var string
+   */
+  private string $dir;
+
+  /**
+   * Adds a package.
+   *
+   * If $package contains an `install_path` key, it should be relative to the
+   * location of `installed.json` and `installed.php`, which are in
+   * `vendor/composer`. For example, if the package would be installed at
+   * `vendor/kirk/enterprise`, the install path should be `../kirk/enterprise`.
+   * If the package would be installed outside of vendor (for example, a Drupal
+   * module in the `modules` directory), it would be `../../modules/my_module`.
+   *
+   * @param array $package
+   *   The package info that should be added to installed.json and
+   *   installed.php. Must include the `name` and `type` keys.
+   * @param bool $is_dev_requirement
+   *   Whether or not the package is a development requirement.
+   * @param bool $create_project
+   *   Whether or not the project info.yml file should be created.
+   */
+  public function addPackage(array $package, bool $is_dev_requirement = FALSE, bool $create_project = TRUE): self {
+    if (!$this->committingChanges) {
+      $this->manipulatorArguments['addPackage'][] = func_get_args();
+      return $this;
+    }
+    foreach (['name', 'type'] as $required_key) {
+      if (!isset($package[$required_key])) {
+        throw new \UnexpectedValueException("The '$required_key' is required when calling ::addPackage().");
+      }
+    }
+    $this->setPackage($package['name'], $package, FALSE, $is_dev_requirement);
+    $drupal_project_types = [
+      'drupal-module',
+      'drupal-theme',
+      'drupal-custom-module',
+      'drupal-custom-theme',
+    ];
+    if (!$create_project || !in_array($package['type'], $drupal_project_types, TRUE)) {
+      return $this;
+    }
+    if (empty($package['install_path'])) {
+      throw new \LogicException("'install_path' is not set.");
+    }
+    $install_path = "vendor/composer/" . $package['install_path'];
+    $this->addProjectAtPath($install_path);
+    return $this;
+  }
+
+  /**
+   * Modifies a package's installed info.
+   *
+   * See ::addPackage() for information on how the `install_path` key is
+   * handled, if $package has it.
+   *
+   * @param string $name
+   *   The name of the package to modify.
+   * @param array $package
+   *   The package info that should be updated in installed.json and
+   *   installed.php.
+   */
+  public function modifyPackage(string $name, array $package): self {
+    if (!$this->committingChanges) {
+      $this->manipulatorArguments['modifyPackage'][] = func_get_args();
+      return $this;
+    }
+    $this->setPackage($name, $package, TRUE);
+    return $this;
+  }
+
+  /**
+   * Sets a package version.
+   *
+   * @param string $package_name
+   *   The package name.
+   * @param string $version
+   *   The version.
+   *
+   * @return $this
+   */
+  public function setVersion(string $package_name, string $version): self {
+    return $this->modifyPackage($package_name, ['version' => $version]);
+  }
+
+  /**
+   * Removes a package.
+   *
+   * @param string $name
+   *   The name of the package to remove.
+   */
+  public function removePackage(string $name): self {
+    if (!$this->committingChanges) {
+      $this->manipulatorArguments['removePackage'][] = func_get_args();
+      return $this;
+    }
+    $this->setPackage($name, NULL, TRUE);
+    return $this;
+  }
+
+  /**
+   * Changes a package's installation information in a particular directory.
+   *
+   * This function is internal and should not be called directly. Use
+   * ::addPackage(), ::modifyPackage(), and ::removePackage() instead.
+   *
+   * @param string $name
+   *   The name of the package to add, update, or remove.
+   * @param array|null $package
+   *   The package information to be set in installed.json and installed.php, or
+   *   NULL to remove it. Will be merged into the existing information if the
+   *   package is already installed.
+   * @param bool $should_exist
+   *   Whether or not the package is expected to already be installed.
+   * @param bool|null $is_dev_requirement
+   *   Whether or not the package is a developer requirement.
+   */
+  private function setPackage(string $name, ?array $package, bool $should_exist, ?bool $is_dev_requirement = NULL): void {
+    if ($should_exist && isset($is_dev_requirement)) {
+      throw new \LogicException('Changing an existing project to a dev requirement is not supported');
+    }
+    $composer_folder = $this->dir . '/vendor/composer';
+
+    $file = $composer_folder . '/installed.json';
+    self::ensureFilePathIsWritable($file);
+
+    $data = file_get_contents($file);
+    $data = json_decode($data, TRUE, 512, JSON_THROW_ON_ERROR);
+
+    // If the package is already installed, find its numerical index.
+    $position = NULL;
+    for ($i = 0; $i < count($data['packages']); $i++) {
+      if ($data['packages'][$i]['name'] === $name) {
+        $position = $i;
+        break;
+      }
+    }
+    // Ensure that we actually expect to find the package already installed (or
+    // not).
+    $expected_package_message = $should_exist
+      ? "Expected package '$name' to be installed, but it wasn't."
+      : "Expected package '$name' to not be installed, but it was.";
+    if ($should_exist !== isset($position)) {
+      throw new \LogicException($expected_package_message);
+    }
+
+    if ($package) {
+      $package = ['name' => $name] + $package;
+      $install_json_package = array_diff_key($package, array_flip(['install_path']));
+    }
+
+    if (isset($position)) {
+      // If we're going to be updating the package data, merge the incoming data
+      // into what we already have.
+      if ($package) {
+        $install_json_package = NestedArray::mergeDeep($data['packages'][$position], $install_json_package);
+      }
+
+      // Remove the existing package; the array will be re-keyed by
+      // array_splice().
+      array_splice($data['packages'], $position, 1);
+      $is_existing_dev_package = in_array($name, $data['dev-package-names'], TRUE);
+      $data['dev-package-names'] = array_diff($data['dev-package-names'], [$name]);
+      $data['dev-package-names'] = array_values($data['dev-package-names']);
+    }
+    // Add the package back to the list, if we have data for it.
+    if (isset($package)) {
+      $data['packages'][] = $install_json_package;
+
+      if ($is_dev_requirement || !empty($is_existing_dev_package)) {
+        $data['dev-package-names'][] = $name;
+      }
+    }
+    file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+    self::ensureFilePathIsWritable($file);
+
+    $file = $composer_folder . '/installed.php';
+    self::ensureFilePathIsWritable($file);
+
+    $data = require $file;
+
+    // Ensure that we actually expect to find the package already installed (or
+    // not).
+    if ($should_exist !== isset($data['versions'][$name])) {
+      throw new \LogicException($expected_package_message);
+    }
+    if ($package) {
+      // If an install path was provided, ensure it's relative.
+      if (array_key_exists('install_path', $package)) {
+        if (!str_starts_with($package['install_path'], '../')) {
+          throw new \UnexpectedValueException("'install_path' must start with '../'.");
+        }
+      }
+      $install_php_package = $should_exist ?
+        NestedArray::mergeDeep($data['versions'][$name], $package) :
+        $package;
+
+      // The installation paths in $data will have been interpreted by the PHP
+      // runtime, so make them all relative again by stripping $this->dir out.
+      array_walk($data['versions'], function (array &$install_php_package) use ($composer_folder) : void {
+        if (array_key_exists('install_path', $install_php_package)) {
+          $install_php_package['install_path'] = str_replace("$composer_folder/", '', $install_php_package['install_path']);
+        }
+      });
+      $data['versions'][$name] = $install_php_package;
+    }
+    else {
+      unset($data['versions'][$name]);
+    }
+
+    $data = var_export($data, TRUE);
+    $data = str_replace("'install_path' => '../", "'install_path' => __DIR__ . '/../", $data);
+    file_put_contents($file, "<?php\nreturn $data;");
+  }
+
+  /**
+   * Adds a project at a path.
+   *
+   * @param string $path
+   *   The path.
+   * @param string|null $project_name
+   *   (optional) The project name. If none is specified the last part of the
+   *   path will be used.
+   * @param string|null $file_name
+   *   (optional) The file name. If none is specified the project name will be
+   *   used.
+   */
+  public function addProjectAtPath(string $path, ?string $project_name = NULL, ?string $file_name = NULL): self {
+    if (!$this->committingChanges) {
+      $this->manipulatorArguments['addProjectAtPath'][] = func_get_args();
+      return $this;
+    }
+    $path = $this->dir . "/$path";
+    if (file_exists($path)) {
+      throw new \LogicException("'$path' path already exists.");
+    }
+    $fs = new Filesystem();
+    $fs->mkdir($path);
+    if ($project_name === NULL) {
+      $project_name = basename($path);
+    }
+    if ($file_name === NULL) {
+      $file_name = "$project_name.info.yml";
+    }
+    file_put_contents("$path/$file_name", Yaml::encode(['project' => $project_name]));
+    return $this;
+  }
+
+  /**
+   * Commits the changes to the directory.
+   */
+  public function commitChanges(string $dir): void {
+    $this->doCommitChanges($dir);
+    $this->committed = TRUE;
+  }
+
+  /**
+   * Commits all the changes.
+   *
+   * @param string $dir
+   *   The directory to commit the changes to.
+   */
+  protected function doCommitChanges(string $dir): void {
+    if ($this->committed) {
+      throw new \BadMethodCallException('Already committed.');
+    }
+    $this->dir = $dir;
+    $this->committingChanges = TRUE;
+    $manipulator_arguments = $this->manipulatorArguments;
+    $this->manipulatorArguments = [];
+    foreach ($manipulator_arguments as $method => $argument_sets) {
+      foreach ($argument_sets as $argument_set) {
+        $this->{$method}(...$argument_set);
+      }
+    }
+    $this->committed = TRUE;
+    $this->committingChanges = FALSE;
+  }
+
+  /**
+   * Ensure that changes were committed before object is destroyed.
+   */
+  public function __destruct() {
+    if (!$this->committed && !empty($this->manipulatorArguments)) {
+      throw new \LogicException('commitChanges() must be called.');
+    }
+  }
+
+  /**
+   * Ensures a path is writable.
+   *
+   * @param string $path
+   *   The path.
+   */
+  private static function ensureFilePathIsWritable(string $path): void {
+    if (!is_writable($path)) {
+      throw new \LogicException("'$path' is not writable.");
+    }
+  }
+
+}
diff --git a/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php b/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php
new file mode 100644
index 0000000000000000000000000000000000000000..e6cf9b43a2f1f252816bec30ad1cebe7c80fd07b
--- /dev/null
+++ b/package_manager/tests/modules/fixture_manipulator/src/StageFixtureManipulator.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\fixture_manipulator;
+
+use Drupal\package_manager_bypass\Beginner;
+
+/**
+ * A fixture manipulator for the stage directory.
+ */
+final class StageFixtureManipulator extends FixtureManipulator {
+
+  /**
+   * Whether the fixture is ready to commit.
+   *
+   * @var bool
+   */
+  private $ready = FALSE;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function commitChanges(string $dir = NULL): void {
+    if (!$this->ready) {
+      throw new \LogicException("::setReadyToCommit must be called before ::commitChanges");
+    }
+    if (!$dir) {
+      throw new \UnexpectedValueException("$dir must be specific for a StageFixtureManipulator");
+    }
+    parent::doCommitChanges($dir);
+    $this->committed = TRUE;
+  }
+
+  /**
+   * Sets the manipulator as ready to commit.
+   */
+  public function setReadyToCommit(): void {
+    $this->ready = TRUE;
+    Beginner::setStageManipulator($this);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __destruct() {
+    if (!$this->ready) {
+      throw new \LogicException('This fixture manipulator was not yet ready to commit! Please call setReadyToCommit() to signal all necessary changes are queued.');
+    }
+  }
+
+}
diff --git a/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml b/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml
index 95cc7e66e364b1f024571edb3c5e793d728fc5c8..b2731f7e944941af86eacf65e6cd89be7b7e0db0 100644
--- a/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml
+++ b/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml
@@ -4,3 +4,4 @@ type: module
 package: Testing
 dependencies:
   - automatic_updates:package_manager
+  - automatic_updates:fixture_manipulator
diff --git a/package_manager/tests/modules/package_manager_bypass/src/Beginner.php b/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
index 57775bf1bec7f042cc7e3ba9447a76a603f8d877..7b4bea26adb156aa12d0d8c372798882d7f827c7 100644
--- a/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
+++ b/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
@@ -4,6 +4,7 @@ declare(strict_types = 1);
 
 namespace Drupal\package_manager_bypass;
 
+use Drupal\fixture_manipulator\StageFixtureManipulator;
 use PhpTuf\ComposerStager\Domain\Core\Beginner\BeginnerInterface;
 use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface;
 use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ProcessRunnerInterface;
@@ -21,6 +22,22 @@ class Beginner extends BypassedStagerServiceBase implements BeginnerInterface {
   public function begin(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = ProcessRunnerInterface::DEFAULT_TIMEOUT): void {
     $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions, $timeout);
     $this->copyFixtureFilesTo($stagingDir);
+
+    /** @var \Drupal\fixture_manipulator\StageFixtureManipulator|null $stageManipulator */
+    $stageManipulator = $this->state->get(__CLASS__ . '-stage-manipulator', NULL);
+    if ($stageManipulator) {
+      $stageManipulator->commitChanges($stagingDir->resolve());
+    }
+  }
+
+  /**
+   * Sets the manipulator for the stage.
+   *
+   * @param \Drupal\fixture_manipulator\StageFixtureManipulator $manipulator
+   *   The manipulator.
+   */
+  public static function setStageManipulator(StageFixtureManipulator $manipulator): void {
+    \Drupal::state()->set(__CLASS__ . '-stage-manipulator', $manipulator);
   }
 
 }
diff --git a/package_manager/tests/src/Kernel/ComposerUtilityTest.php b/package_manager/tests/src/Kernel/ComposerUtilityTest.php
index dbb649817bb12a3d184f6c4c482f0944a6f413b6..98906327540e25fa558c89c411b3081d3145325b 100644
--- a/package_manager/tests/src/Kernel/ComposerUtilityTest.php
+++ b/package_manager/tests/src/Kernel/ComposerUtilityTest.php
@@ -4,6 +4,7 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\fixture_manipulator\FixtureManipulator;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\package_manager\ComposerUtility;
 use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
@@ -31,7 +32,61 @@ class ComposerUtilityTest extends KernelTestBase {
 
     $fixture = vfsStream::newDirectory('fixture');
     $this->vfsRoot->addChild($fixture);
-    static::copyFixtureFilesTo(__DIR__ . '/../../fixtures/project_package_conversion', $fixture->url());
+    static::copyFixtureFilesTo(__DIR__ . '/../../fixtures/fake_site', $fixture->url());
+    $relative_projects_dir = '../../web/projects';
+    (new FixtureManipulator())
+      ->addPackage(
+        [
+          'name' => 'drupal/package_project_match',
+          'type' => 'drupal-module',
+          'install_path' => "$relative_projects_dir/package_project_match",
+        ]
+      )
+      ->addPackage(
+        [
+          'name' => 'drupal/not_match_package',
+          'type' => 'drupal-module',
+          'install_path' => "$relative_projects_dir/not_match_project",
+        ]
+      )
+      ->addPackage(
+        [
+          'name' => 'drupal/not_match_path_project',
+          'type' => 'drupal-module',
+          'install_path' => "$relative_projects_dir/not_match_project",
+        ],
+        FALSE,
+        FALSE,
+      )
+      ->addProjectAtPath("web/projects/not_match_path_project", 'not_match_path_project')
+      ->addPackage(
+        [
+          'name' => 'drupal/nested_no_match_package',
+          'type' => 'drupal-module',
+          'install_path' => "$relative_projects_dir/any_folder_name",
+        ],
+        FALSE,
+        FALSE,
+      )
+      ->addPackage(
+        [
+          'name' => 'non_drupal/other_project',
+          'type' => 'drupal-module',
+          'install_path' => "$relative_projects_dir/other_project",
+        ]
+      )
+      ->addPackage(
+        [
+          'name' => 'drupal/custom_module',
+          'type' => 'drupal-custom-module',
+          'install_path' => "$relative_projects_dir/custom_module",
+        ]
+      )
+      // A test info.yml file where the folder names and info.yml file names do
+      // not match the project or package. Only the project key in this file
+      // need to match.
+      ->addProjectAtPath("web/projects/any_folder_name/any_sub_folder", 'nested_no_match_project', 'any_yml_file.info.yml')
+      ->commitChanges($fixture->url());
   }
 
   /**
diff --git a/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php b/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php
index b5501c7fcc9a9a881484a376b33a203ce8510ab7..61dd008d5bc8941857600b628899a6bf38d5c916 100644
--- a/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php
+++ b/package_manager/tests/src/Kernel/FakeSiteFixtureTest.php
@@ -4,6 +4,7 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\fixture_manipulator\ActiveFixtureManipulator;
 use Drupal\package_manager\ComposerUtility;
 
 /**
@@ -43,40 +44,35 @@ class FakeSiteFixtureTest extends PackageManagerKernelTestBase {
   /**
    * Tests if `modifyPackage` can be called on all packages in the fixture.
    *
-   * @see \Drupal\Tests\package_manager\Traits\FixtureUtilityTrait::modifyPackage()
+   * @see \Drupal\fixture_manipulator\FixtureManipulator::modifyPackage()
    */
   public function testCallToModifyPackage(): void {
-    $project_root = $this->container->get('package_manager.path_locator')->getProjectRoot();
     $stage = $this->createStage();
     $installed_packages = $stage->getActiveComposer()->getInstalledPackages();
     foreach (self::getExpectedFakeSitePackages() as $package_name) {
       $this->assertArrayHasKey($package_name, $installed_packages);
       $this->assertSame('9.8.0', $installed_packages[$package_name]->getPrettyVersion());
-      $this->modifyPackage(
-        $project_root,
-        $package_name,
-        ['version' => '11.1.0']
-      );
+      (new ActiveFixtureManipulator())
+        ->modifyPackage($package_name, ['version' => '11.1.0'])
+        ->commitChanges();
     }
   }
 
   /**
    * Tests if `removePackage` can be called on all packages in the fixture.
    *
-   * @covers \Drupal\Tests\package_manager\Traits\FixtureUtilityTrait::removePackage()
+   * @covers \Drupal\fixture_manipulator\FixtureManipulator::removePackage()
    */
   public function testCallToRemovePackage(): void {
     $expected_packages = self::getExpectedFakeSitePackages();
-    $project_root = $this->container->get('package_manager.path_locator')->getProjectRoot();
     $stage = $this->createStage();
     $actual_packages = array_keys($stage->getActiveComposer()->getInstalledPackages());
     sort($actual_packages);
     $this->assertSame($expected_packages, $actual_packages);
     foreach (self::getExpectedFakeSitePackages() as $package_name) {
-      $this->removePackage(
-        $project_root,
-        $package_name,
-      );
+      (new ActiveFixtureManipulator())
+        ->removePackage($package_name)
+        ->commitChanges();
     }
   }
 
diff --git a/package_manager/tests/src/Kernel/FixtureUtilityTraitTest.php b/package_manager/tests/src/Kernel/FixtureManipulatorTest.php
similarity index 60%
rename from package_manager/tests/src/Kernel/FixtureUtilityTraitTest.php
rename to package_manager/tests/src/Kernel/FixtureManipulatorTest.php
index 1d60428024d837f99bdebc0e4de6c7c681e57b70..14e520a6b1e4dd620f40e440e9ded8fd96c0684a 100644
--- a/package_manager/tests/src/Kernel/FixtureUtilityTraitTest.php
+++ b/package_manager/tests/src/Kernel/FixtureManipulatorTest.php
@@ -4,18 +4,17 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\package_manager\Kernel;
 
-use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
-use PHPUnit\Framework\AssertionFailedError;
+use Drupal\fixture_manipulator\ActiveFixtureManipulator;
+use Drupal\fixture_manipulator\FixtureManipulator;
+use Drupal\fixture_manipulator\StageFixtureManipulator;
 use Symfony\Component\Filesystem\Filesystem;
 
 /**
- * @coversDefaultClass \Drupal\Tests\package_manager\Traits\FixtureUtilityTrait
+ * @coversDefaultClass \Drupal\fixture_manipulator\FixtureManipulator
+ *
  * @group package_manager
- * @internal
  */
-class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
-
-  use FixtureUtilityTrait;
+class FixtureManipulatorTest extends PackageManagerKernelTestBase {
 
   /**
    * The root directory of the virtual project.
@@ -24,6 +23,29 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
    */
   private string $dir;
 
+  /**
+   * The existing packages in the fixture.
+   *
+   * @var \string[][]
+   */
+  private array $existingCorePackages = [
+    'drupal/core' => [
+      'name' => 'drupal/core',
+      'version' => '9.8.0',
+      'type' => 'drupal-core',
+    ],
+    'drupal/core-recommended' => [
+      'name' => 'drupal/core-recommended',
+      'version' => '9.8.0',
+      'type' => 'drupal-core',
+    ],
+    'drupal/core-dev' => [
+      'name' => 'drupal/core-dev',
+      'version' => '9.8.0',
+      'type' => 'drupal-core',
+    ],
+  ];
+
   /**
    * {@inheritdoc}
    */
@@ -33,18 +55,22 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
     $this->dir = $this->container->get('package_manager.path_locator')
       ->getProjectRoot();
 
-    $this->addPackage($this->dir, [
-      'name' => 'my/package',
-      'type' => 'library',
-    ]);
-    $this->addPackage($this->dir, [
-      'name' => 'my/dev-package',
-      'version' => '2.1.0',
-      'type' => 'library',
-      'install_path' => '../relative/path',
-    ],
-    TRUE,
-    );
+    $manipulator = new ActiveFixtureManipulator();
+    $manipulator
+      ->addPackage([
+        'name' => 'my/package',
+        'type' => 'library',
+      ])
+      ->addPackage(
+        [
+          'name' => 'my/dev-package',
+          'version' => '2.1.0',
+          'type' => 'library',
+          'install_path' => '../relative/path',
+        ],
+        TRUE
+      )
+      ->commitChanges();
   }
 
   /**
@@ -52,47 +78,54 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
    */
   public function testAddPackage(): void {
     // Packages cannot be added without a name.
-    try {
-      $this->addPackage($this->dir, ['type' => 'unknown']);
-      $this->fail('Adding an anonymous package should raise an error.');
-    }
-    catch (AssertionFailedError $e) {
-      $this->assertSame("Failed asserting that an array has the key 'name'.", $e->getMessage());
-    }
-
-    // Packages cannot be added without a type.
-    try {
-      $this->addPackage($this->dir, ['name' => 'unknown']);
-      $this->fail('Adding an package without a type should raise an error.');
-    }
-    catch (AssertionFailedError $e) {
-      $this->assertSame("Failed asserting that an array has the key 'type'.", $e->getMessage());
+    foreach (['name', 'type'] as $require_key) {
+      // Make a package that is missing the required key.
+      $package = array_diff_key(
+        [
+          'name' => 'Any old name',
+          'type' => 'Any old type',
+        ],
+        [$require_key => '']
+      );
+      try {
+        $manipulator = new ActiveFixtureManipulator();
+        $manipulator->addPackage($package)
+          ->commitChanges();
+        $this->fail("Adding a package without the '$require_key' should raise an error.");
+      }
+      catch (\UnexpectedValueException $e) {
+        $this->assertSame("The '$require_key' is required when calling ::addPackage().", $e->getMessage());
+      }
     }
 
     // We should not be able to add an existing package.
     try {
-      $this->addPackage($this->dir, [
+      $manipulator = new ActiveFixtureManipulator();
+      $manipulator->addPackage([
         'name' => 'my/package',
         'type' => 'library',
-      ]);
+      ])
+        ->commitChanges();
       $this->fail('Trying to add an existing package should raise an error.');
     }
-    catch (AssertionFailedError $e) {
+    catch (\LogicException $e) {
       $this->assertStringContainsString("Expected package 'my/package' to not be installed, but it was.", $e->getMessage());
     }
 
     // We should not be able to add a package with an absolute installation
     // path.
     try {
-      $this->addPackage($this->dir, [
-        'name' => 'absolute/path',
-        'install_path' => '/absolute/path',
-        'type' => 'library',
-      ]);
+      (new ActiveFixtureManipulator())
+        ->addPackage([
+          'name' => 'absolute/path',
+          'install_path' => '/absolute/path',
+          'type' => 'library',
+        ])
+        ->commitChanges();
       $this->fail('Add package should have failed.');
     }
-    catch (AssertionFailedError $e) {
-      $this->assertSame('Failed asserting that \'/absolute/path\' starts with "../".', $e->getMessage());
+    catch (\UnexpectedValueException $e) {
+      $this->assertSame("'install_path' must start with '../'.", $e->getMessage());
     }
 
     $installed_json_expected_packages = [
@@ -116,23 +149,7 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
     // have been prefixed with the __DIR__ constant, which should be interpreted
     // when installed.php is loaded by the PHP runtime.
     $installed_php_expected_packages['my/dev-package']['install_path'] = "$this->dir/vendor/composer/../relative/path";
-    $installed_php_expected_packages = [
-      'drupal/core' => [
-        'name' => 'drupal/core',
-        'version' => '9.8.0',
-        'type' => 'drupal-core',
-      ],
-      'drupal/core-recommended' => [
-        'name' => 'drupal/core-recommended',
-        'version' => '9.8.0',
-        'type' => 'drupal-core',
-      ],
-      'drupal/core-dev' => [
-        'name' => 'drupal/core-dev',
-        'version' => '9.8.0',
-        'type' => 'drupal-core',
-      ],
-    ] + $installed_php_expected_packages;
+    $installed_php_expected_packages = $this->existingCorePackages + $installed_php_expected_packages;
     $this->assertSame($installed_php_expected_packages, $installed_php);
   }
 
@@ -151,11 +168,9 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
     };
     $original_installed_json = $decode_installed_json();
     $this->assertIsArray($original_installed_json);
-    $this->modifyPackage(
-      $temp_fixture,
-      'the-org/the-package',
-      ['install_path' => '../../a_new_path'],
-    );
+    (new FixtureManipulator())
+      ->modifyPackage('the-org/the-package', ['install_path' => '../../a_new_path'])
+      ->commitChanges($temp_fixture);
     $this->assertSame($original_installed_json, $decode_installed_json());
 
     // Assert that ::modifyPackage() throws an error if a package exists in the
@@ -166,35 +181,37 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
     $temp_fixture = $this->siteDirectory . $this->randomMachineName('42');
     $fs->mirror($existing_incorrect_fixture, $temp_fixture);
     try {
-      $this->modifyPackage(
-        $temp_fixture,
-        'the-org/the-package',
-        ['install_path' => '../../a_new_path'],
-      );
+      (new FixtureManipulator())
+        ->modifyPackage('the-org/the-package', ['install_path' => '../../a_new_path'])
+        ->commitChanges($temp_fixture);
       $this->fail('Modifying a non-existent package should raise an error.');
     }
-    catch (AssertionFailedError $e) {
-      $this->assertStringContainsString("Failed asserting that an array has the key 'the-org/the-package'.", $e->getMessage());
+    catch (\LogicException $e) {
+      $this->assertSame("Expected package 'the-org/the-package' to be installed, but it wasn't.", $e->getMessage());
     }
 
     // We should not be able to modify a non-existent package.
     try {
-      $this->modifyPackage($this->dir, 'junk/drawer', ['type' => 'library']);
+      (new ActiveFixtureManipulator())
+        ->modifyPackage('junk/drawer', ['type' => 'library'])
+        ->commitChanges();
       $this->fail('Modifying a non-existent package should raise an error.');
     }
-    catch (AssertionFailedError $e) {
+    catch (\LogicException $e) {
       $this->assertStringContainsString("Expected package 'junk/drawer' to be installed, but it wasn't.", $e->getMessage());
     }
 
-    // Add a key to an existing package.
-    $this->modifyPackage($this->dir, 'my/package', ['type' => 'metapackage']);
-    // Change a key in an existing package.
-    $this->modifyPackage($this->dir, 'my/dev-package', ['version' => '3.2.1']);
-    // Move an existing package to dev requirements.
-    $this->addPackage($this->dir, [
-      'name' => 'my/other-package',
-      'type' => 'library',
-    ]);
+    (new ActiveFixtureManipulator())
+      // Add a key to an existing package.
+      ->modifyPackage('my/package', ['type' => 'metapackage'])
+      // Change a key in an existing package.
+      ->setVersion('my/dev-package', '3.2.1')
+      // Move an existing package to dev requirements.
+      ->addPackage([
+        'name' => 'my/other-package',
+        'type' => 'library',
+      ])
+      ->commitChanges();
 
     $install_json_expected_packages = [
       'my/package' => [
@@ -219,23 +236,7 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
     $this->assertContains('my/dev-package', $installed_json['dev-package-names']);
     $this->assertNotContains('my/other-package', $installed_json['dev-package-names']);
     $this->assertNotContains('my/package', $installed_json['dev-package-names']);
-    $installed_php_expected_packages = [
-      'drupal/core' => [
-        'name' => 'drupal/core',
-        'version' => '9.8.0',
-        'type' => 'drupal-core',
-      ],
-      'drupal/core-recommended' => [
-        'name' => 'drupal/core-recommended',
-        'version' => '9.8.0',
-        'type' => 'drupal-core',
-      ],
-      'drupal/core-dev' => [
-        'name' => 'drupal/core-dev',
-        'version' => '9.8.0',
-        'type' => 'drupal-core',
-      ],
-    ] + $installed_php_expected_packages;
+    $installed_php_expected_packages = $this->existingCorePackages + $installed_php_expected_packages;
     // @see ::testAddPackage()
     $this->assertSame($installed_php_expected_packages, $installed_php);
   }
@@ -246,15 +247,19 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
   public function testRemovePackage(): void {
     // We should not be able to remove a package that's not installed.
     try {
-      $this->removePackage($this->dir, 'junk/drawer');
+      (new ActiveFixtureManipulator())
+        ->removePackage('junk/drawer')
+        ->commitChanges();
       $this->fail('Removing a non-existent package should raise an error.');
     }
-    catch (AssertionFailedError $e) {
+    catch (\LogicException $e) {
       $this->assertStringContainsString("Expected package 'junk/drawer' to be installed, but it wasn't.", $e->getMessage());
     }
 
-    $this->removePackage($this->dir, 'my/package');
-    $this->removePackage($this->dir, 'my/dev-package');
+    (new ActiveFixtureManipulator())
+      ->removePackage('my/package')
+      ->removePackage('my/dev-package')
+      ->commitChanges();
 
     foreach (['json', 'php'] as $extension) {
       $file = "$this->dir/vendor/composer/installed.$extension";
@@ -289,4 +294,24 @@ class FixtureUtilityTraitTest extends PackageManagerKernelTestBase {
     ];
   }
 
+  /**
+   * Test that an exception is thrown if ::commitChanges() is not called.
+   */
+  public function testNoCommitError(): void {
+    $this->expectException(\LogicException::class);
+    $this->expectExceptionMessage('commitChanges() must be called.');
+    (new ActiveFixtureManipulator())
+      ->setVersion('drupal/core', '1.2.3');
+  }
+
+  /**
+   * Test that no exception is thrown if ::setReadyToCommit() is called.
+   */
+  public function testNoCommitExpected(): void {
+    $manipulator = new StageFixtureManipulator();
+    $manipulator->setVersion('drupal/core', '1.2.3');
+    $manipulator->setReadyToCommit();
+    $this->assertTrue(TRUE);
+  }
+
 }
diff --git a/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php b/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php
index 0a985ee7dc22f0faa3ddb21901c9a79b5808437e..89e8b49cdf6f52b0776f432859ba4d4143251d60 100644
--- a/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php
+++ b/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php
@@ -4,7 +4,9 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\package_manager\Kernel;
 
-use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\fixture_manipulator\ActiveFixtureManipulator;
+use Drupal\fixture_manipulator\StageFixtureManipulator;
+use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
 
@@ -36,43 +38,40 @@ class OverwriteExistingPackagesValidatorTest extends PackageManagerKernelTestBas
    * by the 'package_manager_bypass' module.
    */
   public function testNewPackagesOverwriteExisting(): void {
-    $active_dir = $this->container->get('package_manager.path_locator')->getProjectRoot();
-    $modules_dir = "$active_dir/modules";
-    $this->addProjectAtPath("$modules_dir/module_1");
-    $this->addProjectAtPath("$modules_dir/module_2");
-    $this->addProjectAtPath("$modules_dir/module_5");
-    $stage = $this->createStage();
-    $stage->create();
-    $stage_dir = $stage->getStageDirectory();
+    (new ActiveFixtureManipulator())
+      ->addProjectAtPath('modules/module_1')
+      ->addProjectAtPath('modules/module_2')
+      ->addProjectAtPath('modules/module_5')
+      ->commitChanges();
+    $stage_manipulator = new StageFixtureManipulator();
 
     // module_1 and module_2 will raise errors because they would overwrite
     // non-Composer managed paths in the active directory.
-    $this->addPackage(
-      $stage_dir,
-      [
-        'name' => 'drupal/module_1',
-        'version' => '1.3.0',
-        'type' => 'drupal-module',
-        'install_path' => '../../modules/module_1',
-      ],
-      FALSE,
-      FALSE
-    );
-    $this->addPackage(
-      $stage_dir,
-      [
-        'name' => 'drupal/module_2',
-        'version' => '1.3.0',
-        'type' => 'drupal-module',
-        'install_path' => '../../modules/module_2',
-      ],
-      FALSE,
-      FALSE
-    );
+    $stage_manipulator
+      ->addPackage(
+        [
+          'name' => 'drupal/module_1',
+          'version' => '1.3.0',
+          'type' => 'drupal-module',
+          'install_path' => '../../modules/module_1',
+        ],
+        FALSE,
+        FALSE
+      )
+      ->addPackage(
+        [
+          'name' => 'drupal/module_2',
+          'version' => '1.3.0',
+          'type' => 'drupal-module',
+          'install_path' => '../../modules/module_2',
+        ],
+        FALSE,
+        FALSE
+      );
 
     // module_3 will cause no problems, since it doesn't exist in the active
     // directory at all.
-    $this->addPackage($stage_dir, [
+    $stage_manipulator->addPackage([
       'name' => 'drupal/module_3',
       'version' => '1.3.0',
       'type' => 'drupal-module',
@@ -82,8 +81,7 @@ class OverwriteExistingPackagesValidatorTest extends PackageManagerKernelTestBas
     // module_4 doesn't exist in the active directory but the 'install_path' as
     // known to Composer in the staged directory collides with module_1 in the
     // active directory which will cause an error.
-    $this->addPackage(
-      $stage_dir,
+    $stage_manipulator->addPackage(
       [
         'name' => 'drupal/module_4',
         'version' => '1.3.0',
@@ -97,7 +95,7 @@ class OverwriteExistingPackagesValidatorTest extends PackageManagerKernelTestBas
     // module_5_different_path will not cause a problem, even though its package
     // name is drupal/module_5, because its project name and path in the staging
     // area differ from the active directory.
-    $this->addPackage($stage_dir, [
+    $stage_manipulator->addPackage([
       'name' => 'drupal/module_5',
       'version' => '1.3.0',
       'type' => 'drupal-module',
@@ -106,11 +104,12 @@ class OverwriteExistingPackagesValidatorTest extends PackageManagerKernelTestBas
 
     // Add a package without an install_path set which will not raise an error.
     // The most common example of this in the Drupal ecosystem is a submodule.
-    $this->addPackage($stage_dir, [
+    $stage_manipulator->addPackage([
       'name' => 'drupal/sub-module',
       'version' => '1.3.0',
       'type' => 'metapackage',
     ]);
+    $stage_manipulator->setReadyToCommit();
 
     $expected_results = [
       ValidationResult::createError([
@@ -123,17 +122,7 @@ class OverwriteExistingPackagesValidatorTest extends PackageManagerKernelTestBas
         'The new package drupal/module_4 will be installed in the directory /vendor/composer/../../modules/module_1, which already exists but is not managed by Composer.',
       ]),
     ];
-
-    $stage->require(['drupal/core:9.8.1']);
-    try {
-      $stage->apply();
-      // If no exception occurs, ensure we weren't expecting any errors.
-      $this->assertValidationResultsEqual($expected_results, []);
-    }
-    catch (StageValidationException $e) {
-      $this->assertNotEmpty($expected_results);
-      $this->assertValidationResultsEqual($expected_results, $e->getResults());
-    }
+    $this->assertResults($expected_results, PreApplyEvent::class);
   }
 
 }
diff --git a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
index e3b18480cd19a7da510b63f25cb6a50b53babe6b..7d064d773c58c6c987b534dc6a318247cbeed27b 100644
--- a/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
+++ b/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
@@ -57,6 +57,7 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase {
    * {@inheritdoc}
    */
   protected static $modules = [
+    'fixture_manipulator',
     'package_manager',
     'package_manager_bypass',
     'system',
diff --git a/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php b/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php
index cd08c843544a3396e268b8181093719923dea3eb..4a67afb4c10cac5bcc61dc0215be2e99cd320bbe 100644
--- a/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php
+++ b/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php
@@ -4,6 +4,8 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\fixture_manipulator\ActiveFixtureManipulator;
+use Drupal\fixture_manipulator\StageFixtureManipulator;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
@@ -22,38 +24,37 @@ class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase {
    */
   protected function setUp(): void {
     parent::setUp();
-
-    $active_dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-    $this->addPackage($active_dir, [
-      'name' => "drupal/dependency",
-      'version' => '9.8.0',
-      'type' => 'drupal-library',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => "drupal/semver_test",
-      'version' => '8.1.0',
-      'type' => 'drupal-module',
-      'install_path' => '../../modules/semver_test',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => "drupal/aaa_update_test",
-      'version' => '2.0.0',
-      'type' => 'drupal-module',
-      'install_path' => '../../modules/aaa_update_test',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => "drupal/package_manager_theme",
-      'version' => '8.1.0',
-      'type' => 'drupal-theme',
-      'install_path' => '../../modules/package_manager_theme',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => "somewhere/a_drupal_module",
-      'version' => '8.1.0',
-      'type' => 'drupal-module',
-      'install_path' => '../../modules/a_drupal_module',
-    ]);
+    (new ActiveFixtureManipulator())
+      ->addPackage([
+        'name' => "drupal/dependency",
+        'version' => '9.8.0',
+        'type' => 'drupal-library',
+      ])
+      ->addPackage([
+        'name' => "drupal/semver_test",
+        'version' => '8.1.0',
+        'type' => 'drupal-module',
+        'install_path' => '../../modules/semver_test',
+      ])
+      ->addPackage([
+        'name' => "drupal/aaa_update_test",
+        'version' => '2.0.0',
+        'type' => 'drupal-module',
+        'install_path' => '../../modules/aaa_update_test',
+      ])
+      ->addPackage([
+        'name' => "drupal/package_manager_theme",
+        'version' => '8.1.0',
+        'type' => 'drupal-theme',
+        'install_path' => '../../modules/package_manager_theme',
+      ])
+      ->addPackage([
+        'name' => "somewhere/a_drupal_module",
+        'version' => '8.1.0',
+        'type' => 'drupal-module',
+        'install_path' => '../../modules/a_drupal_module',
+      ])
+      ->commitChanges();
   }
 
   /**
@@ -63,7 +64,6 @@ class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase {
    *   The test cases.
    */
   public function providerException(): array {
-    $fixtures_folder = __DIR__ . '/../../fixtures/supported_release_validator';
     $release_fixture_folder = __DIR__ . '/../../fixtures/release-history';
     $summary = t('Cannot update because the following project version is not in the list of installable releases.');
     return [
@@ -213,28 +213,18 @@ class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase {
   public function testException(array $release_metadata, bool $project_in_active, array $package, array $expected_results): void {
     $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml'] + $release_metadata);
 
-    $listener = function (PreApplyEvent $event) use ($project_in_active, $package, $expected_results): void {
-
-      $stage_dir = $event->getStage()->getStageDirectory();
-      // @todo add test coverage for packages that don't start with 'drupal/' in
-      //   https://www.drupal.org/node/3321386.
-      if (!$project_in_active) {
-        $this->addPackage($stage_dir, $package);
-      }
-      else {
-        $this->modifyPackage($stage_dir, $package['name'], [
-          'version' => $package['version'],
-        ]);
-      }
-      // We always update this module to prove that the validator will skip this
-      // module as it's of type 'drupal-library'.
-      // @see \Drupal\package_manager\Validator\SupportedReleaseValidator::checkStagedReleases()
-      $this->modifyPackage($stage_dir, "drupal/dependency", [
-        'version' => '9.8.1',
-      ]);
-    };
-    $this->container->get('event_dispatcher')
-      ->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
+    $stage_manipulator = new StageFixtureManipulator();
+    if ($project_in_active) {
+      $stage_manipulator->setVersion($package['name'], $package['version']);
+    }
+    else {
+      $stage_manipulator->addPackage($package);
+    }
+    // We always update this module to prove that the validator will skip this
+    // module as it's of type 'drupal-library'.
+    // @see \Drupal\package_manager\Validator\SupportedReleaseValidator::checkStagedReleases()
+    $stage_manipulator->setVersion('drupal/dependency', '9.8.1');
+    $stage_manipulator->setReadyToCommit();
     $this->assertResults($expected_results, PreApplyEvent::class);
   }
 
diff --git a/package_manager/tests/src/Traits/FixtureUtilityTrait.php b/package_manager/tests/src/Traits/FixtureUtilityTrait.php
index a780c6c339e74d0b14c8cce5e062967e4867e43f..2764935bb4e7f5a01af6c61fdbb6e181a384f418 100644
--- a/package_manager/tests/src/Traits/FixtureUtilityTrait.php
+++ b/package_manager/tests/src/Traits/FixtureUtilityTrait.php
@@ -4,8 +4,6 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\package_manager\Traits;
 
-use Drupal\Component\Utility\NestedArray;
-use Drupal\Core\Serialization\Yaml;
 use Symfony\Component\Filesystem\Filesystem;
 use Symfony\Component\Finder\Iterator\RecursiveDirectoryIterator;
 
@@ -82,212 +80,4 @@ trait FixtureUtilityTrait {
     }
   }
 
-  /**
-   * Adds a package.
-   *
-   * If $package contains an `install_path` key, it should be relative to the
-   * location of `installed.json` and `installed.php`, which are in
-   * `vendor/composer`. For example, if the package would be installed at
-   * `vendor/kirk/enterprise`, the install path should be `../kirk/enterprise`.
-   * If the package would be installed outside of vendor (for example, a Drupal
-   * module in the `modules` directory), it would be `../../modules/my_module`.
-   *
-   * @param string $dir
-   *   The root Composer-managed directory (e.g., the project root or staging
-   *   area).
-   * @param array $package
-   *   The package info that should be added to installed.json and
-   *   installed.php. Must include the `name` and `type` keys.
-   * @param bool $is_dev_requirement
-   *   Whether or not the package is a development requirement.
-   * @param bool $create_project
-   *   Whether or not the project info.yml file should be created.
-   */
-  protected function addPackage(string $dir, array $package, bool $is_dev_requirement = FALSE, bool $create_project = TRUE): void {
-    foreach (['name', 'type'] as $required_key) {
-      $this->assertArrayHasKey($required_key, $package);
-    }
-    $this->setPackage($dir, $package['name'], $package, FALSE, $is_dev_requirement);
-    $drupal_project_types = [
-      'drupal-module',
-      'drupal-theme',
-      'drupal-custom-module',
-      'drupal-custom-theme',
-    ];
-    if (!$create_project || !in_array($package['type'], $drupal_project_types, TRUE)) {
-      return;
-    }
-    $this->assertNotEmpty($package['install_path']);
-    $install_path = "$dir/vendor/composer/" . $package['install_path'];
-    $this->addProjectAtPath($install_path);
-  }
-
-  /**
-   * Adds a project at a path.
-   *
-   * @param string $path
-   *   The path.
-   * @param string|null $project_name
-   *   (optional) The project name. If known is specified the last part of the
-   *   path will be used.
-   *
-   * @todo Move to FixtureManipulator in https://www.drupal.org/i/3322913.
-   */
-  protected function addProjectAtPath(string $path, ?string $project_name = NULL): void {
-    $fs = new Filesystem();
-    $this->assertDirectoryDoesNotExist($path);
-    $fs->mkdir($path);
-    if ($project_name === NULL) {
-      $path_parts = explode('/', $path);
-      $project_name = array_pop($path_parts);
-    }
-    file_put_contents("$path/$project_name.info.yml", Yaml::encode(['project' => $project_name]));
-  }
-
-  /**
-   * Modifies a package's installed info.
-   *
-   * See ::addPackage() for information on how the `install_path` key is
-   * handled, if $package has it.
-   *
-   * @param string $dir
-   *   The root Composer-managed directory (e.g., the project root or staging
-   *   area).
-   * @param string $name
-   *   The name of the package to modify.
-   * @param array $package
-   *   The package info that should be updated in installed.json and
-   *   installed.php.
-   */
-  protected function modifyPackage(string $dir, string $name, array $package): void {
-    $this->setPackage($dir, $name, $package, TRUE);
-  }
-
-  /**
-   * Removes a package.
-   *
-   * @param string $dir
-   *   The root Composer-managed directory (e.g., the project root or staging
-   *   area).
-   * @param string $name
-   *   The name of the package to remove.
-   */
-  protected function removePackage(string $dir, string $name): void {
-    $this->setPackage($dir, $name, NULL, TRUE);
-  }
-
-  /**
-   * Changes a package's installation information in a particular directory.
-   *
-   * This function is internal and should not be called directly. Use
-   * ::addPackage(), ::modifyPackage(), and ::removePackage() instead.
-   *
-   * @param string $dir
-   *   The root Composer-managed directory (e.g., the project root or staging
-   *   area).
-   * @param string $name
-   *   The name of the package to add, update, or remove.
-   * @param array|null $package
-   *   The package information to be set in installed.json and installed.php, or
-   *   NULL to remove it. Will be merged into the existing information if the
-   *   package is already installed.
-   * @param bool $should_exist
-   *   Whether or not the package is expected to already be installed.
-   * @param bool|null $is_dev_requirement
-   *   Whether or not the package is a developer requirement.
-   */
-  private function setPackage(string $dir, string $name, ?array $package, bool $should_exist, ?bool $is_dev_requirement = NULL): void {
-    $this->assertNotTrue($should_exist && isset($is_dev_requirement), 'Changing an existing project to a dev requirement is not supported');
-    $dir .= '/vendor/composer';
-
-    $file = $dir . '/installed.json';
-    $this->assertFileIsWritable($file);
-
-    $data = file_get_contents($file);
-    $data = json_decode($data, TRUE, 512, JSON_THROW_ON_ERROR);
-
-    // If the package is already installed, find its numerical index.
-    $position = NULL;
-    for ($i = 0; $i < count($data['packages']); $i++) {
-      if ($data['packages'][$i]['name'] === $name) {
-        $position = $i;
-        break;
-      }
-    }
-    // Ensure that we actually expect to find the package already installed (or
-    // not).
-    $message = $should_exist
-      ? "Expected package '$name' to be installed, but it wasn't."
-      : "Expected package '$name' to not be installed, but it was.";
-    $this->assertSame($should_exist, isset($position), $message);
-
-    if ($package) {
-      $package = ['name' => $name] + $package;
-      $install_json_package = array_diff_key($package, array_flip(['install_path']));
-    }
-
-    if (isset($position)) {
-      // If we're going to be updating the package data, merge the incoming data
-      // into what we already have.
-      if ($package) {
-        $install_json_package = NestedArray::mergeDeep($data['packages'][$position], $install_json_package);
-      }
-
-      // Remove the existing package; the array will be re-keyed by
-      // array_splice().
-      array_splice($data['packages'], $position, 1);
-      $is_existing_dev_package = in_array($name, $data['dev-package-names'], TRUE);
-      $data['dev-package-names'] = array_diff($data['dev-package-names'], [$name]);
-      $data['dev-package-names'] = array_values($data['dev-package-names']);
-    }
-    // Add the package back to the list, if we have data for it.
-    if (isset($package)) {
-      $data['packages'][] = $install_json_package;
-
-      if ($is_dev_requirement || !empty($is_existing_dev_package)) {
-        $data['dev-package-names'][] = $name;
-      }
-    }
-    file_put_contents($file, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
-
-    $file = $dir . '/installed.php';
-    $this->assertFileIsWritable($file);
-
-    $data = require $file;
-
-    // Ensure that we actually expect to find the package already installed (or
-    // not).
-    if ($should_exist) {
-      $this->assertArrayHasKey($name, $data['versions']);
-    }
-    else {
-      $this->assertArrayNotHasKey($name, $data['versions']);
-    }
-    if ($package) {
-      // If an install path was provided, ensure it's relative.
-      if (array_key_exists('install_path', $package)) {
-        $this->assertStringStartsWith('../', $package['install_path']);
-      }
-      $install_php_package = $should_exist ?
-        NestedArray::mergeDeep($data['versions'][$name], $package) :
-        $package;
-
-      // The installation paths in $data will have been interpreted by the PHP
-      // runtime, so make them all relative again by stripping $dir out.
-      array_walk($data['versions'], function (array &$install_php_package) use ($dir): void {
-        if (array_key_exists('install_path', $install_php_package)) {
-          $install_php_package['install_path'] = str_replace("$dir/", '', $install_php_package['install_path']);
-        }
-      });
-      $data['versions'][$name] = $install_php_package;
-    }
-    else {
-      unset($data['versions'][$name]);
-    }
-
-    $data = var_export($data, TRUE);
-    $data = str_replace("'install_path' => '../", "'install_path' => __DIR__ . '/../", $data);
-    file_put_contents($file, "<?php\nreturn $data;");
-  }
-
 }
diff --git a/tests/fixtures/drupal-9.8.1-installed/composer.json b/tests/fixtures/drupal-9.8.1-installed/composer.json
deleted file mode 100644
index 6a9ed719d00a0b22ac15e2c2bbadd35432cf9908..0000000000000000000000000000000000000000
--- a/tests/fixtures/drupal-9.8.1-installed/composer.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
-  "extra": {
-    "_readme": [
-      "This fixture simulates a staging area in which, according to Composer, Drupal core 9.8.1 is installed."
-    ]
-  }
-}
diff --git a/tests/fixtures/drupal-9.8.1-installed/composer.lock b/tests/fixtures/drupal-9.8.1-installed/composer.lock
deleted file mode 100644
index 0967ef424bce6791893e9a57bb952f80fd536e93..0000000000000000000000000000000000000000
--- a/tests/fixtures/drupal-9.8.1-installed/composer.lock
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.json b/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.json
deleted file mode 100644
index d4f0f343d72658dd2fb15964dba7ddf91705c509..0000000000000000000000000000000000000000
--- a/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
-  "packages": [
-    {
-      "name": "drupal/core",
-      "version": "9.8.1",
-      "type": "drupal-core",
-      "extra": {
-        "drupal-scaffold": {
-          "file-mapping": {}
-        }
-      }
-    }
-  ]
-}
diff --git a/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.php b/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.php
deleted file mode 100644
index 52ff3f53b1b0724748972ffc09b0d6359e86f8e8..0000000000000000000000000000000000000000
--- a/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.php
+++ /dev/null
@@ -1,10 +0,0 @@
-<?php
-
-/**
- * @file
- * Simulates that no packages are installed.
- */
-
-return [
-  'versions' => [],
-];
diff --git a/tests/src/Functional/AutomaticUpdatesFunctionalTestBase.php b/tests/src/Functional/AutomaticUpdatesFunctionalTestBase.php
index 5bf4eeb4885d89726d987679df5186fb126ae062..05693182f2c7142a8360147d566538bb6313d7ec 100644
--- a/tests/src/Functional/AutomaticUpdatesFunctionalTestBase.php
+++ b/tests/src/Functional/AutomaticUpdatesFunctionalTestBase.php
@@ -6,6 +6,7 @@ namespace Drupal\Tests\automatic_updates\Functional;
 
 use Drupal\automatic_updates\CronUpdater;
 use Drupal\Core\Site\Settings;
+use Drupal\fixture_manipulator\StageFixtureManipulator;
 use Drupal\package_manager_bypass\Beginner;
 use Drupal\package_manager_bypass\Stager;
 use Drupal\Tests\BrowserTestBase;
@@ -20,6 +21,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
 abstract class AutomaticUpdatesFunctionalTestBase extends BrowserTestBase {
 
   use FixtureUtilityTrait;
+
   /**
    * {@inheritdoc}
    */
@@ -29,6 +31,20 @@ abstract class AutomaticUpdatesFunctionalTestBase extends BrowserTestBase {
     'package_manager_bypass',
   ];
 
+  /**
+   * Set the core update version.
+   *
+   * @param string $version
+   *   The core version.
+   */
+  protected function setCoreUpdate(string $version):void {
+    $stage_manipulator = new StageFixtureManipulator();
+    $stage_manipulator->setVersion('drupal/core', $version)
+      ->setVersion('drupal/core-recommended', $version)
+      ->setVersion('drupal/core-dev', $version)
+      ->setReadyToCommit();
+  }
+
   /**
    * The service IDs of any validators to disable.
    *
diff --git a/tests/src/Functional/StatusCheckTest.php b/tests/src/Functional/StatusCheckTest.php
index bfa9e3c59c3cc83d50d28df36cb439decdbf0262..4f7b778569bf6aa4c7459e3c547bd58dfc9604f1 100644
--- a/tests/src/Functional/StatusCheckTest.php
+++ b/tests/src/Functional/StatusCheckTest.php
@@ -453,7 +453,7 @@ class StatusCheckTest extends AutomaticUpdatesFunctionalTestBase {
     // status check (without storing the results), and the checker is no
     // longer raising an error.
     $this->drupalGet('/admin/modules/update');
-    $this->useFixtureDirectoryAsStaged(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $this->setCoreUpdate('9.8.1');
     $assert_session->buttonExists('Update');
     // Ensure that the previous results are still displayed on another admin
     // page, to confirm that the updater form is not discarding the previous
diff --git a/tests/src/Functional/UpdateLockTest.php b/tests/src/Functional/UpdateLockTest.php
index 720caba24013d5ace2e7a69d121a7c2e74feced6..5512da72539f0d54b55f552b1c554c972d55a3fc 100644
--- a/tests/src/Functional/UpdateLockTest.php
+++ b/tests/src/Functional/UpdateLockTest.php
@@ -4,8 +4,6 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\automatic_updates\Functional;
 
-use Drupal\package_manager_bypass\Stager;
-
 /**
  * Tests that only one Automatic Update operation can be performed at a time.
  *
@@ -55,7 +53,7 @@ class UpdateLockTest extends AutomaticUpdatesFunctionalTestBase {
     // We should be able to get partway through an update without issue.
     $this->drupalLogin($user_1);
     $this->drupalGet('/admin/modules/update');
-    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $this->setCoreUpdate('9.8.1');
     $page->pressButton('Update');
     $this->checkForMetaRefresh();
     $this->assertUpdateReady('9.8.1');
diff --git a/tests/src/Functional/UpdaterFormTest.php b/tests/src/Functional/UpdaterFormTest.php
index f92049fa0ab079d10f50b722cfbd2be3407ae525..fd89072f35cede489315f58eb5e770e460d27631 100644
--- a/tests/src/Functional/UpdaterFormTest.php
+++ b/tests/src/Functional/UpdaterFormTest.php
@@ -14,7 +14,6 @@ use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\package_manager\ValidationResult;
 use Drupal\automatic_updates_test\EventSubscriber\TestSubscriber1;
 use Drupal\package_manager_bypass\Committer;
-use Drupal\package_manager_bypass\Stager;
 use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber;
 use Drupal\system\SystemManager;
 use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
@@ -458,7 +457,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
     $this->checkForUpdates();
 
     $this->drupalGet('/admin/modules/update');
-    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $this->setCoreUpdate('9.8.1');
     $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
@@ -593,7 +592,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
 
     $page = $this->getSession()->getPage();
     $this->drupalGet('/admin/modules/update');
-    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $this->setCoreUpdate('9.8.1');
     // The warning should be visible.
     $assert_session = $this->assertSession();
     $assert_session->pageTextContains(reset($messages));
@@ -696,7 +695,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
     $cached_message = $this->setAndAssertCachedMessage();
 
     $this->drupalGet($update_form_url);
-    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $this->setCoreUpdate('9.8.1');
     $assert_session->pageTextNotContains($cached_message);
     $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
@@ -752,7 +751,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
     $page = $this->getSession()->getPage();
     // Navigate to the automatic updates form.
     $this->drupalGet('/admin/modules/update');
-    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $this->setCoreUpdate('9.8.1');
     $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
@@ -822,7 +821,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
     $page = $this->getSession()->getPage();
 
     $this->drupalGet('/admin/modules/automatic-update');
-    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $this->setCoreUpdate('9.8.1');
     $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     // Confirm that the site was put into maintenance mode if needed.
@@ -843,7 +842,7 @@ class UpdaterFormTest extends AutomaticUpdatesFunctionalTestBase {
 
     $page = $this->getSession()->getPage();
     $this->drupalGet('/admin/modules/update');
-    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $this->setCoreUpdate('9.8.1');
     $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
diff --git a/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php b/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php
index 2ce4b8ee8e0c8f111d53354ebb536b6952eba0d5..3b1ea4fda28c98c8cb9b8b7d1256293086d83d1a 100644
--- a/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php
+++ b/tests/src/Kernel/StatusCheck/StagedProjectsValidatorTest.php
@@ -4,11 +4,12 @@ declare(strict_types = 1);
 
 namespace Drupal\Tests\automatic_updates\Kernel\StatusCheck;
 
+use Drupal\fixture_manipulator\ActiveFixtureManipulator;
+use Drupal\fixture_manipulator\StageFixtureManipulator;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Exception\StageValidationException;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
-use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
 
 /**
  * @covers \Drupal\automatic_updates\Validator\StagedProjectsValidator
@@ -17,8 +18,6 @@ use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
  */
 class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
 
-  use FixtureUtilityTrait;
-
   /**
    * {@inheritdoc}
    */
@@ -74,86 +73,84 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
    * Tests that an error is raised if Drupal extensions are unexpectedly added.
    */
   public function testProjectsAdded(): void {
-    $active_dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-    $this->addPackage($active_dir, [
-      'name' => 'drupal/test_module',
-      'version' => '1.3.0',
-      'type' => 'drupal-module',
-      'install_path' => '../../modules/test_module',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => 'other/removed',
-      'version' => '1.3.1',
-      'type' => 'library',
-    ]);
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'drupal/dev-test_module',
+    (new ActiveFixtureManipulator())
+      ->addPackage([
+        'name' => 'drupal/test_module',
         'version' => '1.3.0',
-        'type' => 'drupal-module',
-        'install_path' => '../../modules/dev_test_module',
-      ],
-      TRUE
-    );
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'other/dev-removed',
+        'type' => 'drupal_module',
+        'install_path' => '../../modules/test_module',
+      ])
+      ->addPackage([
+        'name' => 'other/removed',
         'version' => '1.3.1',
         'type' => 'library',
-      ],
-      TRUE
-    );
-
-    $updater = $this->container->get('automatic_updates.updater');
-    $updater->begin(['drupal' => '9.8.1']);
-    $updater->stage();
+      ])
+      ->addPackage(
+        [
+          'name' => 'drupal/dev-test_module',
+          'version' => '1.3.0',
+          'type' => 'drupal-module',
+          'install_path' => '../../modules/dev_test_module',
+        ],
+        TRUE
+      )
+      ->addPackage(
+        [
+          'name' => 'other/dev-removed',
+          'version' => '1.3.1',
+          'type' => 'library',
+        ],
+        TRUE
+      )
+      ->commitChanges();
 
-    $stage_dir = $updater->getStageDirectory();
-    $this->addPackage($stage_dir, [
-      'name' => 'drupal/test_module2',
-      'version' => '1.3.1',
-      'type' => 'drupal-module',
-      'install_path' => '../../modules/test_module2',
-    ]);
-    $this->addPackage(
-      $stage_dir,
-      [
-        'name' => 'drupal/dev-test_module2',
+    $stage_manipulator = new StageFixtureManipulator();
+    $stage_manipulator
+      ->addPackage([
+        'name' => 'drupal/test_module2',
         'version' => '1.3.1',
-        'type' => 'drupal-custom-module',
-        'install_path' => '../../modules/dev-test_module2',
-      ],
-      TRUE
-    );
-    // The validator shouldn't complain about these packages being added or
-    // removed, since it only cares about Drupal modules and themes.
-    $this->addPackage($stage_dir, [
-      'name' => 'other/new_project',
-      'version' => '1.3.1',
-      'type' => 'library',
-      'install_path' => '../other/new_project',
-    ]);
-    $this->addPackage(
-      $stage_dir,
-      [
-        'name' => 'other/dev-new_project',
+        'type' => 'drupal-module',
+        'install_path' => '../../modules/test_module2',
+      ])
+      ->addPackage(
+        [
+          'name' => 'drupal/dev-test_module2',
+          'version' => '1.3.1',
+          'type' => 'drupal-custom-module',
+          'install_path' => '../../modules/dev-test_module2',
+        ],
+        TRUE
+      )
+      // The validator shouldn't complain about these packages being added or
+      // removed, since it only cares about Drupal modules and themes.
+      ->addPackage([
+        'name' => 'other/new_project',
         'version' => '1.3.1',
         'type' => 'library',
-        'install_path' => '../other/dev-new_project',
-      ],
-      TRUE
-    );
-    $this->removePackage($stage_dir, 'other/removed');
-    $this->removePackage($stage_dir, 'other/dev-removed');
+        'install_path' => '../other/new_project',
+      ])
+      ->addPackage(
+        [
+          'name' => 'other/dev-new_project',
+          'version' => '1.3.1',
+          'type' => 'library',
+          'install_path' => '../other/dev-new_project',
+        ],
+        TRUE
+      )
+      ->removePackage('other/removed')
+      ->removePackage('other/dev-removed')
+      ->setReadyToCommit();
 
     $messages = [
       "module 'drupal/test_module2' installed.",
       "custom module 'drupal/dev-test_module2' installed.",
     ];
     $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were installed during the update.'));
+
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
     try {
       $updater->apply();
       $this->fail('Expected an error, but none was raised.');
@@ -167,72 +164,69 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
    * Tests that errors are raised if Drupal extensions are unexpectedly removed.
    */
   public function testProjectsRemoved(): void {
-    $active_dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-    $this->addPackage($active_dir, [
-      'name' => 'drupal/test_theme',
-      'version' => '1.3.0',
-      'type' => 'drupal-theme',
-      'install_path' => '../../themes/test_theme',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => 'drupal/test_module2',
-      'version' => '1.3.1',
-      'type' => 'drupal-module',
-      'install_path' => '../../modules/test_module2',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => 'other/removed',
-      'version' => '1.3.1',
-      'type' => 'library',
-    ]);
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'drupal/dev-test_theme',
+    (new ActiveFixtureManipulator())
+      ->addPackage([
+        'name' => 'drupal/test_theme',
         'version' => '1.3.0',
-        'type' => 'drupal-custom-theme',
-        'install_path' => '../../modules/dev_test_theme',
-      ],
-      TRUE
-    );
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'drupal/dev-test_module2',
+        'type' => 'drupal-theme',
+        'install_path' => '../../themes/test_theme',
+      ])
+      ->addPackage([
+        'name' => 'drupal/test_module2',
         'version' => '1.3.1',
         'type' => 'drupal-module',
-        'install_path' => '../../modules/dev_test_module2',
-      ],
-      TRUE
-    );
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'other/dev-removed',
+        'install_path' => '../../modules/test_module2',
+      ])
+      ->addPackage([
+        'name' => 'other/removed',
         'version' => '1.3.1',
         'type' => 'library',
-      ],
-      TRUE
-    );
+      ])
+      ->addPackage(
+        [
+          'name' => 'drupal/dev-test_theme',
+          'version' => '1.3.0',
+          'type' => 'drupal-custom-theme',
+          'install_path' => '../../modules/dev_test_theme',
+        ],
+        TRUE
+      )
+      ->addPackage(
+        [
+          'name' => 'drupal/dev-test_module2',
+          'version' => '1.3.1',
+          'type' => 'drupal-module',
+          'install_path' => '../../modules/dev_test_module2',
+        ],
+        TRUE
+      )
+      ->addPackage(
+        [
+          'name' => 'other/dev-removed',
+          'version' => '1.3.1',
+          'type' => 'library',
+        ],
+        TRUE
+      )
+      ->commitChanges();
 
-    $updater = $this->container->get('automatic_updates.updater');
-    $updater->begin(['drupal' => '9.8.1']);
-    $updater->stage();
-
-    $stage_dir = $updater->getStageDirectory();
-    $this->removePackage($stage_dir, 'drupal/test_theme');
-    $this->removePackage($stage_dir, 'drupal/dev-test_theme');
+    $stage_manipulator = new StageFixtureManipulator();
+    $stage_manipulator->removePackage('drupal/test_theme')
+      ->removePackage('drupal/dev-test_theme')
     // The validator shouldn't complain about these packages being removed,
     // since it only cares about Drupal modules and themes.
-    $this->removePackage($stage_dir, 'other/removed');
-    $this->removePackage($stage_dir, 'other/dev-removed');
+      ->removePackage('other/removed')
+      ->removePackage('other/dev-removed')
+      ->setReadyToCommit();
 
     $messages = [
       "theme 'drupal/test_theme' removed.",
       "custom theme 'drupal/dev-test_theme' removed.",
     ];
     $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were removed during the update.'));
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
     try {
       $updater->apply();
       $this->fail('Expected an error, but none was raised.');
@@ -246,64 +240,55 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
    * Tests that errors are raised if Drupal extensions are unexpectedly updated.
    */
   public function testVersionsChanged(): void {
-    $active_dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-    $this->addPackage($active_dir, [
-      'name' => 'drupal/test_module',
-      'version' => '1.3.0',
-      'type' => 'drupal-module',
-      'install_path' => '../../modules/test_module',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => 'other/changed',
-      'version' => '1.3.1',
-      'type' => 'library',
-    ]);
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'drupal/dev-test_module',
+    (new ActiveFixtureManipulator())
+      ->addPackage([
+        'name' => 'drupal/test_module',
         'version' => '1.3.0',
         'type' => 'drupal-module',
-        'install_path' => '../../modules/dev_test_module',
-      ],
-      TRUE
-    );
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'other/dev-changed',
+        'install_path' => '../../modules/test_module',
+      ])
+      ->addPackage([
+        'name' => 'other/changed',
         'version' => '1.3.1',
         'type' => 'library',
-      ],
-      TRUE
-    );
+      ])
+      ->addPackage(
+        [
+          'name' => 'drupal/dev-test_module',
+          'version' => '1.3.0',
+          'type' => 'drupal-module',
+          'install_path' => '../../modules/dev_test_module',
+        ],
+        TRUE
+      )
+      ->addPackage(
+        [
+          'name' => 'other/dev-changed',
+          'version' => '1.3.1',
+          'type' => 'library',
+        ],
+        TRUE
+      )
+      ->commitChanges();
 
-    $updater = $this->container->get('automatic_updates.updater');
-    $updater->begin(['drupal' => '9.8.1']);
-    $updater->stage();
-
-    $stage_dir = $updater->getStageDirectory();
-    $this->modifyPackage($stage_dir, 'drupal/test_module', [
-      'version' => '1.3.1',
-    ]);
-    $this->modifyPackage($stage_dir, 'drupal/dev-test_module', [
-      'version' => '1.3.1',
-    ]);
+    $stage_manipulator = new StageFixtureManipulator();
+    $stage_manipulator->setVersion('drupal/test_module', '1.3.1')
+      ->setVersion('drupal/dev-test_module', '1.3.1')
     // The validator shouldn't complain about these packages being updated,
     // because it only cares about Drupal modules and themes.
-    $this->modifyPackage($stage_dir, 'other/changed', [
-      'version' => '1.3.2',
-    ]);
-    $this->modifyPackage($stage_dir, 'other/dev-changed', [
-      'version' => '1.3.2',
-    ]);
+      ->setVersion('other/changed', '1.3.2')
+      ->setVersion('other/dev-changed', '1.3.2')
+      ->setReadyToCommit();
 
     $messages = [
       "module 'drupal/test_module' from 1.3.0 to 1.3.1.",
       "module 'drupal/dev-test_module' from 1.3.0 to 1.3.1.",
     ];
     $error = ValidationResult::createError($messages, t('The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.'));
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
     try {
       $updater->apply();
       $this->fail('Expected an error, but none was raised.');
@@ -317,88 +302,80 @@ class StagedProjectsValidatorTest extends AutomaticUpdatesKernelTestBase {
    * Tests that no errors occur if only core and its dependencies are updated.
    */
   public function testNoErrors(): void {
-    $active_dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-    $this->addPackage($active_dir, [
-      'name' => 'drupal/test_module',
-      'version' => '1.3.0',
-      'type' => 'drupal-module',
-      'install_path' => '../../modules/test_module',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => 'other/removed',
-      'version' => '1.3.1',
-      'type' => 'library',
-    ]);
-    $this->addPackage($active_dir, [
-      'name' => 'other/changed',
-      'version' => '1.3.1',
-      'type' => 'library',
-    ]);
-    $this->addPackage(
-      $active_dir, [
-        'name' => 'drupal/dev-test_module',
+    (new ActiveFixtureManipulator())
+      ->addPackage([
+        'name' => 'drupal/test_module',
         'version' => '1.3.0',
         'type' => 'drupal-module',
-        'install_path' => '../../modules/dev_test_module',
-      ],
-      TRUE
-    );
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'other/dev-removed',
+        'install_path' => '../../modules/test_module',
+      ])
+      ->addPackage([
+        'name' => 'other/removed',
         'version' => '1.3.1',
         'type' => 'library',
-      ],
-      TRUE
-    );
-    $this->addPackage(
-      $active_dir,
-      [
-        'name' => 'other/dev-changed',
+      ])
+      ->addPackage([
+        'name' => 'other/changed',
         'version' => '1.3.1',
         'type' => 'library',
-      ],
-      TRUE
-    );
+      ])
+      ->addPackage(
+        [
+          'name' => 'drupal/dev-test_module',
+          'version' => '1.3.0',
+          'type' => 'drupal-module',
+          'install_path' => '../../modules/dev_test_module',
+        ],
+        TRUE
+      )
+      ->addPackage(
+        [
+          'name' => 'other/dev-removed',
+          'version' => '1.3.1',
+          'type' => 'library',
+        ],
+        TRUE
+      )
+      ->addPackage(
+        [
+          'name' => 'other/dev-changed',
+          'version' => '1.3.1',
+          'type' => 'library',
+        ],
+        TRUE
+      )
+      ->commitChanges();
 
-    $updater = $this->container->get('automatic_updates.updater');
-    $updater->begin(['drupal' => '9.8.1']);
-    $updater->stage();
-
-    $stage_dir = $updater->getStageDirectory();
-    $this->modifyPackage($stage_dir, 'drupal/core', [
-      'version' => '9.8.1',
-    ]);
+    $stage_manipulator = new StageFixtureManipulator();
+    $stage_manipulator->setVersion('drupal/core', '9.8.1')
     // The validator shouldn't care what happens to these packages, since it
     // only concerns itself with Drupal modules and themes.
-    $this->addPackage($stage_dir, [
-      'name' => 'other/new_project',
-      'version' => '1.3.1',
-      'type' => 'library',
-      'install_path' => '../other/new_project',
-    ]);
-    $this->addPackage(
-      $stage_dir,
-      [
-        'name' => 'other/dev-new_project',
+      ->addPackage([
+        'name' => 'other/new_project',
         'version' => '1.3.1',
         'type' => 'library',
-        'install_path' => '../other/dev-new_project',
-      ],
-      TRUE
-    );
-    $this->modifyPackage($stage_dir, 'other/changed', [
-      'version' => '1.3.2',
-    ]);
-    $this->modifyPackage($stage_dir, 'other/dev-changed', [
-      'version' => '1.3.2',
-    ]);
-    $this->removePackage($stage_dir, 'other/removed');
-    $this->removePackage($stage_dir, 'other/dev-removed');
+        'install_path' => '../other/new_project',
+      ])
+      ->addPackage(
+        [
+          'name' => 'other/dev-new_project',
+          'version' => '1.3.1',
+          'type' => 'library',
+          'install_path' => '../other/dev-new_project',
+        ],
+        TRUE
+      )
+      ->setVersion('other/changed', '1.3.2')
+      ->setVersion('other/dev-changed', '1.3.2')
+      ->removePackage('other/removed')
+      ->removePackage('other/dev-removed')
+      ->setReadyToCommit();
 
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
     $updater->apply();
+    $this->assertTrue(TRUE);
   }
 
 }