diff --git a/automatic_updates_extensions/automatic_updates_extensions.info.yml b/automatic_updates_extensions/automatic_updates_extensions.info.yml
index 921c13feeb4acc770e3a6bd81f413e6bb6b6f692..9f27bcc0f3cadee71511a07b4302777eef618406 100644
--- a/automatic_updates_extensions/automatic_updates_extensions.info.yml
+++ b/automatic_updates_extensions/automatic_updates_extensions.info.yml
@@ -4,4 +4,3 @@ description: 'Allows updates to themes and modules'
 core_version_requirement: ^9.2
 dependencies:
   - drupal:automatic_updates
-hidden: true
diff --git a/automatic_updates_extensions/src/ExtensionUpdater.php b/automatic_updates_extensions/src/ExtensionUpdater.php
index dbb577cf361bc946d687b960c6db4b7a2182e3b4..e88a5693255b06daaedbcce9b53771b8539734bd 100644
--- a/automatic_updates_extensions/src/ExtensionUpdater.php
+++ b/automatic_updates_extensions/src/ExtensionUpdater.php
@@ -2,10 +2,94 @@
 
 namespace Drupal\automatic_updates_extensions;
 
+use Drupal\automatic_updates\Exception\UpdateException;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\StageValidationException;
 use Drupal\package_manager\Stage;
 
 /**
  * Defines a service to perform updates for modules and themes.
  */
 class ExtensionUpdater extends Stage {
+
+  /**
+   * Begins the update.
+   *
+   * @param string[] $project_versions
+   *   The versions of the packages to update to, keyed by package name.
+   *
+   * @return string
+   *   The unique ID of the stage.
+   *
+   * @throws \InvalidArgumentException
+   *   Thrown if no project version for Drupal core is provided.
+   */
+  public function begin(array $project_versions): string {
+    $composer = $this->getActiveComposer();
+    $package_versions = [
+      'production' => [],
+      'dev' => [],
+    ];
+
+    $require_dev = $composer->getComposer()
+      ->getPackage()
+      ->getDevRequires();
+    foreach ($project_versions as $project_name => $version) {
+      $package = "drupal/$project_name";
+      $group = array_key_exists($package, $require_dev) ? 'dev' : 'production';
+      $package_versions[$group][$package] = $version;
+    }
+
+    // Ensure that package versions are available to pre-create event
+    // subscribers. We can't use ::setMetadata() here because it requires the
+    // stage to be claimed, but that only happens during ::create().
+    $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, [
+      'packages' => $package_versions,
+    ]);
+    return parent::create();
+  }
+
+  /**
+   * Returns the package versions that will be required during the update.
+   *
+   * @return string[][]
+   *   An array with two sub-arrays: 'production' and 'dev'. Each is a set of
+   *   package versions, where the keys are package names and the values are
+   *   version constraints understood by Composer.
+   */
+  public function getPackageVersions(): array {
+    return $this->getMetadata('packages');
+  }
+
+  /**
+   * Stages the update.
+   */
+  public function stage(): void {
+    $this->checkOwnership();
+
+    // Convert an associative array of package versions, keyed by name, to
+    // command-line arguments in the form `vendor/name:version`.
+    $map = function (array $versions): array {
+      $requirements = [];
+      foreach ($versions as $package => $version) {
+        $requirements[] = "$package:$version";
+      }
+      return $requirements;
+    };
+    $versions = array_map($map, $this->getPackageVersions());
+    $this->require($versions['production'], $versions['dev']);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
+    try {
+      parent::dispatch($event, $on_error);
+    }
+    catch (StageValidationException $e) {
+      throw new UpdateException($e->getResults(), $e->getMessage() ?: "Unable to complete the update because of errors.", $e->getCode(), $e);
+    }
+  }
+
 }
diff --git a/automatic_updates_extensions/tests/fixtures/fake-site/composer.json b/automatic_updates_extensions/tests/fixtures/fake-site/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..57b1c92348806f8c5743d53559b6ef52f37188ed
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/fake-site/composer.json
@@ -0,0 +1,9 @@
+{
+    "require": {
+        "drupal/core-recommended": "^9",
+        "drupal/my_module": "^9"
+    },
+    "require-dev": {
+        "drupal/my_dev_module": "^9"
+    }
+}
diff --git a/automatic_updates_extensions/tests/fixtures/fake-site/composer.lock b/automatic_updates_extensions/tests/fixtures/fake-site/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..1baca28450cf75958ef17a9fc40cd286e5c17db1
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/fake-site/composer.lock
@@ -0,0 +1,21 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/my_module",
+            "version": "9.8.0"
+        }
+    ],
+    "packages-dev": [
+        {
+            "name": "drupal/my_dev_module",
+            "version": "9.8.1"
+        }
+    ]
+}
diff --git a/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json b/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json
new file mode 100644
index 0000000000000000000000000000000000000000..dffe8ff002d4a3cb434d6548a41e4e5b829adad2
--- /dev/null
+++ b/automatic_updates_extensions/tests/fixtures/fake-site/vendor/composer/installed.json
@@ -0,0 +1,27 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+            "name": "drupal/my_module",
+            "version": "9.8.0"
+        },
+        {
+            "name": "drupal/my_dev_module",
+            "version": "9.8.1"
+        }
+    ],
+    "dev": true,
+    "dev-package-names": [
+        "drupal/my_dev_module"
+    ]
+}
diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..81a15e3ea75a4d46e75392c73d1ffe786b46b377
--- /dev/null
+++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.info.yml
@@ -0,0 +1,6 @@
+name: 'Automatic Updates Extensions Test API'
+description: 'Provides API endpoints for doing stage operations in functional tests.'
+type: module
+package: Testing
+dependencies:
+  - automatic_updates:automatic_updates_extensions
diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c74bb67edc0dcb48ec5c5a7d628669e08a4c95ca
--- /dev/null
+++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/automatic_updates_extensions_test_api.routing.yml
@@ -0,0 +1,6 @@
+automatic_updates_extensions_test_api:
+  path: '/automatic-updates-extensions-test-api'
+  defaults:
+    _controller: 'Drupal\automatic_updates_extensions_test_api\ApiController::run'
+  requirements:
+    _access: 'TRUE'
diff --git a/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php
new file mode 100644
index 0000000000000000000000000000000000000000..fc1c2207c8e558d1a299483a6b01eb6ff930093c
--- /dev/null
+++ b/automatic_updates_extensions/tests/modules/automatic_updates_extensions_test_api/src/ApiController.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\automatic_updates_extensions_test_api;
+
+use Drupal\automatic_updates_extensions\ExtensionUpdater;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Provides API endpoints to interact with a staging area in functional tests.
+ */
+class ApiController extends ControllerBase {
+
+
+  /**
+   * The extension updater.
+   *
+   * @var \Drupal\automatic_updates_extensions\ExtensionUpdater
+   */
+  private $extensionUpdater;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  private $pathLocator;
+
+  /**
+   * Constructs an ApiController object.
+   *
+   * @param \Drupal\automatic_updates_extensions\ExtensionUpdater $extensionUpdater
+   *   The updater.
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(ExtensionUpdater $extensionUpdater, PathLocator $path_locator) {
+    $this->extensionUpdater = $extensionUpdater;
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('automatic_updates_extensions.updater'),
+      $container->get('package_manager.path_locator')
+    );
+  }
+
+  /**
+   * Runs a complete stage life cycle.
+   *
+   * Creates a staging area, requires packages into it, applies changes to the
+   * active directory, and destroys the stage.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request. The runtime and dev dependencies are expected to be in
+   *   either the query string or request body, under the 'runtime' and 'dev'
+   *   keys, respectively. There may also be a 'files_to_return' key, which
+   *   contains an array of file paths, relative to the project root, whose
+   *   contents should be returned in the response.
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   *   A JSON response containing an associative array of the contents of the
+   *   files listed in the 'files_to_return' request key. The array will be
+   *   keyed by path, relative to the project root.
+   */
+  public function run(Request $request): JsonResponse {
+    $this->extensionUpdater->begin($request->get('projects', []));
+    $this->extensionUpdater->stage();
+    $this->extensionUpdater->apply();
+    $this->extensionUpdater->destroy();
+
+    $dir = $this->pathLocator->getProjectRoot();
+    $file_contents = [];
+    foreach ($request->get('files_to_return', []) as $path) {
+      $file_contents[$path] = file_get_contents($dir . '/' . $path);
+    }
+    return new JsonResponse($file_contents);
+  }
+
+}
diff --git a/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f84c55f974b18818b01faca7831228a934cc9d9d
--- /dev/null
+++ b/automatic_updates_extensions/tests/src/Build/ModuleUpdateTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates_extensions\Build;
+
+use Drupal\Tests\package_manager\Build\TemplateProjectTestBase;
+
+/**
+ * Tests updating modules in a staging area.
+ *
+ * @group automatic_updates_extensions
+ */
+class ModuleUpdateTest extends TemplateProjectTestBase {
+
+  /**
+   * Tests updating a module in a staging area.
+   */
+  public function testApi(): void {
+    $this->createTestProject('RecommendedProject');
+
+    $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.0.0');
+    $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha --update-with-all-dependencies', 'project');
+
+    $this->installQuickStart('minimal');
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->installModules(['automatic_updates_extensions_test_api']);
+
+    // Change both modules' upstream version.
+    $this->addRepository('alpha', __DIR__ . '/../../../../package_manager/tests/fixtures/alpha/1.1.0');
+
+    // Use the API endpoint to create a stage and update the 'alpha' module to
+    // 1.1.0. We ask the API to return the contents of the module's
+    // composer.json file, so we can assert that they were updated to the
+    // version we expect.
+    // @see \Drupal\automatic_updates_extensions_test_api\ApiController::run()
+    $query = http_build_query([
+      'projects' => [
+        'alpha' => '1.1.0',
+      ],
+      'files_to_return' => [
+        'web/modules/contrib/alpha/composer.json',
+      ],
+    ]);
+    $this->visit("/automatic-updates-extensions-test-api?$query");
+    $mink = $this->getMink();
+    $mink->assertSession()->statusCodeEquals(200);
+
+    $file_contents = $mink->getSession()->getPage()->getContent();
+    $file_contents = json_decode($file_contents, TRUE);
+
+    $module_composer_json = json_decode($file_contents['web/modules/contrib/alpha/composer.json']);
+    $this->assertSame('1.1.0', $module_composer_json->version);
+  }
+
+}
diff --git a/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php b/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1305b87a31a2cb74e773a3970421227cf1f1d7c7
--- /dev/null
+++ b/automatic_updates_extensions/tests/src/Kernel/ExtensionUpdaterTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates_extensions\Kernel;
+
+use Drupal\Tests\automatic_updates\Kernel\AutomaticUpdatesKernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates_extensions\ExtensionUpdater
+ *
+ * @group automatic_updates_extensions
+ */
+class ExtensionUpdaterTest extends AutomaticUpdatesKernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'automatic_updates',
+    'automatic_updates_test',
+    'automatic_updates_extensions',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installEntitySchema('user');
+  }
+
+  /**
+   * Tests that correct versions are staged after calling ::begin().
+   */
+  public function testCorrectVersionsStaged(): void {
+
+    // Create a user who will own the stage even after the container is rebuilt.
+    $user = $this->createUser([], NULL, TRUE, ['uid' => 2]);
+    $this->setCurrentUser($user);
+
+    $fixture_dir = __DIR__ . '/../../fixtures/fake-site';
+    $locator = $this->mockPathLocator($fixture_dir, $fixture_dir);
+
+    $id = $this->container->get('automatic_updates_extensions.updater')->begin([
+      'my_module' => '9.8.1',
+      'my_dev_module' => '9.8.2',
+    ]);
+    // Rebuild the container to ensure the package versions are persisted.
+    /** @var \Drupal\Core\DrupalKernel $kernel */
+    $kernel = $this->container->get('kernel');
+    $kernel->rebuildContainer();
+    $this->container = $kernel->getContainer();
+    // Keep using the mocked path locator and current user.
+    $this->container->set('package_manager.path_locator', $locator);
+    $this->setCurrentUser($user);
+
+    $extension_updater = $this->container->get('automatic_updates_extensions.updater');
+
+    // Ensure that the target package versions are what we expect.
+    $expected_versions = [
+      'production' => [
+        'drupal/my_module' => '9.8.1',
+      ],
+      'dev' => [
+        'drupal/my_dev_module' => '9.8.2',
+      ],
+    ];
+    $this->assertSame($expected_versions, $extension_updater->claim($id)->getPackageVersions());
+
+    // When we call ExtensionUpdater::stage(), the stored project versions
+    // should be read from state and passed to Composer Stager's Stager service,
+    // in the form of a Composer command. This is done using
+    // package_manager_bypass's invocation recorder, rather than a regular mock,
+    // in order to test that the invocation recorder itself works. The
+    // production requirements are changed first, followed by the dev
+    // requirements. Then the installed packages are updated. This is tested
+    // functionally in Package Manager.
+    // @see \Drupal\Tests\package_manager\Build\StagedUpdateTest
+    $expected_arguments = [
+      [
+        'require',
+        '--no-update',
+        'drupal/my_module:9.8.1',
+      ],
+      [
+        'require',
+        '--dev',
+        '--no-update',
+        'drupal/my_dev_module:9.8.2',
+      ],
+      [
+        'update',
+        '--with-all-dependencies',
+        'drupal/my_module:9.8.1',
+        'drupal/my_dev_module:9.8.2',
+      ],
+    ];
+    $extension_updater->stage();
+
+    $actual_arguments = $this->container->get('package_manager.stager')
+      ->getInvocationArguments();
+
+    $this->assertSame(count($expected_arguments), count($actual_arguments));
+    foreach ($actual_arguments as $i => [$arguments]) {
+      $this->assertSame($expected_arguments[$i], $arguments);
+    }
+  }
+
+}