diff --git a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
index 720c5387283c4bd5caf323b6d2fdd5876fda1eea..896e5c6774a23a57c5e74ec4c6775bd0c587181b 100644
--- a/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
+++ b/modules/project_browser_devel/src/Plugin/ProjectBrowserSource/RandomDataPlugin.php
@@ -198,7 +198,7 @@ class RandomDataPlugin extends ProjectBrowserSourceBase {
         author: [
           'name' => $this->randomGenerator->word(10),
         ],
-        composerNamespace: 'random/' . $machine_name,
+        packageName: 'random/' . $machine_name,
         categories: [$categories[array_rand($categories)]],
         images: $project_images,
       );
diff --git a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php
index 6375836f8c81cb917f6770ad94fee48e7c3bfe24..5d150425e5ee3d2fb19009650b7634a369fefb2f 100644
--- a/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php
+++ b/modules/project_browser_source_example/src/Plugin/ProjectBrowserSource/ProjectBrowserSourceExample.php
@@ -154,7 +154,7 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase {
         changed: $project_from_source['updated_at'],
         created: $project_from_source['created_at'],
         author: $author,
-        composerNamespace: $project_from_source['composer_namespace'],
+        packageName: $project_from_source['composer_namespace'],
         categories: $categories,
         // Images: Array of images using the same structure as $logo, above.
         images: [],
@@ -182,7 +182,7 @@ class ProjectBrowserSourceExample extends ProjectBrowserSourceBase {
         changed: $project_from_source['updated_at'],
         created: $project_from_source['created_at'],
         author: $author,
-        composerNamespace: $project_from_source['composer_namespace'],
+        packageName: $project_from_source['composer_namespace'],
         categories: $categories,
         // Images: Array of images using the same structure as $logo, above.
         images: [],
diff --git a/project_browser.services.yml b/project_browser.services.yml
index 0d657f75361fbb2488077ced97b3fb056221071c..00cb8599d7d49935297c376724f2d546aaab1d01 100644
--- a/project_browser.services.yml
+++ b/project_browser.services.yml
@@ -27,3 +27,12 @@ services:
     arguments: ['@config.factory']
     tags:
       - { name: event_subscriber }
+  Drupal\project_browser\Activator:
+    tags:
+      - { name: service_collector, tag: project_browser.activator, call: addActivator }
+  Drupal\project_browser\ActivatorInterface: '@Drupal\project_browser\Activator'
+  Drupal\project_browser\ModuleActivator:
+    autowire: true
+    public: false
+    tags:
+      - { name: project_browser.activator }
diff --git a/src/Activator.php b/src/Activator.php
new file mode 100644
index 0000000000000000000000000000000000000000..9e38e32c35ab8b976621de9026f92e14515ea346
--- /dev/null
+++ b/src/Activator.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser;
+
+use Drupal\project_browser\ProjectBrowser\Project;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * A generalized activator that can handle any type of project.
+ *
+ * This is a service collector that tries to delegate to the first registered
+ * activator that says it supports a given project.
+ */
+final class Activator implements ActivatorInterface {
+
+  /**
+   * The registered activators.
+   *
+   * @var \Drupal\project_browser\ActivatorInterface[]
+   */
+  private array $activators = [];
+
+  /**
+   * Registers an activator.
+   *
+   * @param \Drupal\project_browser\ActivatorInterface $activator
+   *   The activator to register.
+   */
+  public function addActivator(ActivatorInterface $activator): void {
+    if (in_array($activator, $this->activators, TRUE)) {
+      return;
+    }
+    $this->activators[] = $activator;
+  }
+
+  /**
+   * Returns the registered activator to handle a given project.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project object.
+   *
+   * @return \Drupal\project_browser\ActivatorInterface
+   *   The activator which can handle the given project.
+   *
+   * @throws \InvalidArgumentException
+   *   Thrown if none of the registered activators can handle the given project.
+   */
+  private function getActivatorForProject(Project $project): ActivatorInterface {
+    foreach ($this->activators as $activator) {
+      if ($activator->supports($project)) {
+        return $activator;
+      }
+    }
+    throw new \InvalidArgumentException("The project '$project->machineName' is not supported by any registered activators.");
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isActive(Project $project): bool {
+    return $this->getActivatorForProject($project)->isActive($project);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supports(Project $project): bool {
+    try {
+      return $this->getActivatorForProject($project) instanceof ActivatorInterface;
+    }
+    catch (\InvalidArgumentException) {
+      return FALSE;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function activate(Project $project): ?Response {
+    return $this->getActivatorForProject($project)->activate($project);
+  }
+
+}
diff --git a/src/ActivatorInterface.php b/src/ActivatorInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..103707273cb85d704b125abe338e6c6e6eaea60f
--- /dev/null
+++ b/src/ActivatorInterface.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser;
+
+use Drupal\project_browser\ProjectBrowser\Project;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Defines an interface for services which can activate projects.
+ *
+ * An activator is the "source of truth" about the state of a particular project
+ * in the current site -- for example, an activator that handles modules knows
+ * if the module is already installed.
+ */
+interface ActivatorInterface {
+
+  /**
+   * Determines if a particular project is activated on the current site.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project to check.
+   *
+   * @return bool
+   *   TRUE if the project is activated on the current site, FALSE otherwise.
+   */
+  public function isActive(Project $project): bool;
+
+  /**
+   * Determines if this activator can handle a particular project.
+   *
+   * For example, an activator that handles themes might return TRUE from this
+   * method if the project's Composer package type is `drupal-theme`.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project to check.
+   *
+   * @return bool
+   *   TRUE if this activator is responsible for the given project, FALSE
+   *   otherwise.
+   */
+  public function supports(Project $project): bool;
+
+  /**
+   * Activates a project on the current site.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   The project to activate.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response|null
+   *   Optionally, a response that should be presented to the user in Project
+   *   Browser. This could be a set of additional instructions to display in a
+   *   modal, for example, or a redirect to a configuration form.
+   */
+  public function activate(Project $project): ?Response;
+
+}
diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 126245d5b7c896d03f82590efba186c1a25a7a38..87c973c0c2927201c14e41a620038a3654af3cce 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -5,11 +5,11 @@ namespace Drupal\project_browser\Controller;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Controller\ControllerBase;
-use Drupal\Core\Extension\ModuleInstallerInterface;
 use Drupal\Core\TempStore\SharedTempStore;
 use Drupal\Core\TempStore\SharedTempStoreFactory;
 use Drupal\Core\Url;
 use Drupal\package_manager\Exception\StageException;
+use Drupal\project_browser\ActivatorInterface;
 use Drupal\project_browser\ComposerInstaller\Installer;
 use Drupal\project_browser\EnabledSourceHandler;
 use Psr\Log\LoggerInterface;
@@ -64,22 +64,22 @@ class InstallerController extends ControllerBase {
    *   The installer service.
    * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_temp_store_factory
    *   The temporary storage factory.
-   * @param \Drupal\Core\Extension\ModuleInstallerInterface $moduleInstaller
-   *   The the module installer.
    * @param \Drupal\project_browser\EnabledSourceHandler $enabledSourceHandler
    *   The enabled project browser source.
    * @param \Drupal\Component\Datetime\TimeInterface $time
    *   The system time.
    * @param \Psr\Log\LoggerInterface $logger
    *   The logger instance.
+   * @param \Drupal\project_browser\ActivatorInterface $activator
+   *   The project activator service.
    */
   public function __construct(
     private readonly Installer $installer,
     SharedTempStoreFactory $shared_temp_store_factory,
-    private readonly ModuleInstallerInterface $moduleInstaller,
     private readonly EnabledSourceHandler $enabledSourceHandler,
     private readonly TimeInterface $time,
     private readonly LoggerInterface $logger,
+    private readonly ActivatorInterface $activator,
   ) {
     $this->projectBrowserTempStore = $shared_temp_store_factory->get('project_browser');
   }
@@ -91,10 +91,10 @@ class InstallerController extends ControllerBase {
     return new static(
       $container->get('project_browser.installer'),
       $container->get('tempstore.shared'),
-      $container->get('module_installer'),
       $container->get('project_browser.enabled_source'),
       $container->get('datetime.time'),
       $container->get('logger.channel.project_browser'),
+      $container->get(ActivatorInterface::class),
     );
   }
 
@@ -140,8 +140,10 @@ class InstallerController extends ControllerBase {
   /**
    * Returns the status of the project in the temp store.
    *
+   * @param string $source
+   *   The source plugin ID.
    * @param string $project_id
-   *   The project machine name.
+   *   The ID of the project, as known to the source plugin.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Information about the project's require/install status.
@@ -153,11 +155,14 @@ class InstallerController extends ControllerBase {
    *   regularly so it can monitor the progress of the process and report which
    *   stage is taking place.
    */
-  public function inProgress(string $project_id): JsonResponse {
+  public function inProgress(string $source, string $project_id): JsonResponse {
     $requiring = $this->projectBrowserTempStore->get('requiring');
     $core_installing = $this->projectBrowserTempStore->get('installing');
     $return = ['status' => self::STATUS_IDLE];
 
+    // Prepend the source plugin ID, to create a fully qualified project ID.
+    $project_id = $source . '/' . $project_id;
+
     if (isset($requiring['project_id']) && $requiring['project_id'] === $project_id) {
       $return['status'] = self::STATUS_REQUIRING_PROJECT;
       $return['phase'] = $requiring['phase'];
@@ -243,18 +248,22 @@ class InstallerController extends ControllerBase {
    * Updates the 'requiring' state in the temp store.
    *
    * @param string $project_id
-   *   The module being required.
+   *   The fully qualified ID of the project being required.
    * @param string $phase
    *   The require phase in progress.
    * @param string $stage_id
    *   The stage id.
    */
-  private function setRequiringState(string $project_id, string $phase, string $stage_id = ''): void {
-    $this->projectBrowserTempStore->set('requiring', [
-      'project_id' => $project_id,
-      'phase' => $phase,
-      'stage_id' => $stage_id,
-    ]);
+  private function setRequiringState(?string $project_id, string $phase, ?string $stage_id): void {
+    $data = $this->projectBrowserTempStore->get('requiring') ?? [];
+    if ($project_id) {
+      $data['project_id'] = $project_id;
+    }
+    if ($stage_id) {
+      $data['stage_id'] = $stage_id;
+    }
+    $data['phase'] = $phase;
+    $this->projectBrowserTempStore->set('requiring', $data);
   }
 
   /**
@@ -322,15 +331,16 @@ class InstallerController extends ControllerBase {
   /**
    * Begins requiring by creating a stage.
    *
-   * @param string $composer_namespace
-   *   The project composer namespace.
+   * @param string $source
+   *   The source plugin ID.
    * @param string $project_id
-   *   The project id.
+   *   The ID of the project, as known to the source plugin.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function begin(string $composer_namespace, string $project_id): JsonResponse {
+  public function begin(string $source, string $project_id): JsonResponse {
+    $source_id = $source;
     // @todo Expand to support other plugins in https://drupal.org/i/3312354.
     $source = $this->enabledSourceHandler->getCurrentSources()['drupalorg_mockapi'] ?? NULL;
     if ($source === NULL) {
@@ -339,6 +349,7 @@ class InstallerController extends ControllerBase {
     if (!$source->isProjectSafe($project_id)) {
       return new JsonResponse(['message' => "$project_id is not safe to add because its security coverage has been revoked"], 500);
     }
+
     $stage_available = $this->installer->isAvailable();
     if (!$stage_available) {
       $requiring_metadata = $this->projectBrowserTempStore->getMetadata('requiring');
@@ -388,7 +399,7 @@ class InstallerController extends ControllerBase {
 
     try {
       $stage_id = $this->installer->create();
-      $this->setRequiringState($project_id, 'creating install stage', $stage_id);
+      $this->setRequiringState($source_id . '/' . $project_id, 'creating install stage', $stage_id);
     }
     catch (\Exception $e) {
       $this->cancelRequire();
@@ -401,49 +412,49 @@ class InstallerController extends ControllerBase {
   /**
    * Performs require operations on the stage.
    *
-   * @param string $composer_namespace
-   *   The project composer namespace.
+   * @param string $source
+   *   The source plugin ID.
    * @param string $project_id
-   *   The project id.
-   * @param string $stage_id
-   *   ID of stage created in the begin() method.
+   *   The ID of the project, as known to the source plugin.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function require(string $composer_namespace, string $project_id, string $stage_id): JsonResponse {
+  public function require(string $source, string $project_id): JsonResponse {
     $requiring = $this->projectBrowserTempStore->get('requiring');
-    if (empty($requiring['project_id']) || $requiring['project_id'] !== $project_id) {
+    if (empty($requiring['project_id']) || $requiring['project_id'] !== $source . '/' . $project_id) {
       return new JsonResponse([
         'message' => sprintf('Error: a request to install %s was ignored as an install for a different module is in progress.', $project_id),
       ], 500);
     }
-    $this->setRequiringState($project_id, 'requiring module', $stage_id);
-    try {
-      $this->installer->claim($stage_id)->require(["$composer_namespace/$project_id"]);
-    }
-    catch (\Exception $e) {
-      $this->cancelRequire();
-      return $this->errorResponse($e, 'require');
+    $this->setRequiringState(NULL, 'requiring module', NULL);
+
+    $projects = $this->enabledSourceHandler->getCurrentSources()[$source]?->getProjects()->list ?? [];
+    foreach ($projects as $project) {
+      if ($project->id === $project_id) {
+        try {
+          $this->installer->claim($requiring['stage_id'])->require([
+            $project->packageName,
+          ]);
+        }
+        catch (\Exception $e) {
+          $this->cancelRequire();
+          return $this->errorResponse($e, 'require');
+        }
+      }
     }
-    return $this->successResponse('require', $stage_id);
+    return $this->successResponse('require', $requiring['stage_id']);
   }
 
   /**
    * Performs apply operations on the stage.
    *
-   * @param string $composer_namespace
-   *   The project composer namespace.
-   * @param string $project_id
-   *   The project id.
-   * @param string $stage_id
-   *   ID of stage created in the begin() method.
-   *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function apply(string $composer_namespace, string $project_id, string $stage_id): JsonResponse {
-    $this->setRequiringState($project_id, 'applying', $stage_id);
+  public function apply(): JsonResponse {
+    $stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id'];
+    $this->setRequiringState(NULL, 'applying', NULL);
     try {
       $this->installer->claim($stage_id)->apply();
     }
@@ -457,18 +468,12 @@ class InstallerController extends ControllerBase {
   /**
    * Performs post apply operations on the stage.
    *
-   * @param string $composer_namespace
-   *   The project composer namespace.
-   * @param string $project_id
-   *   The project id.
-   * @param string $stage_id
-   *   ID of stage created in the begin() method.
-   *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function postApply(string $composer_namespace, string $project_id, string $stage_id): JsonResponse {
-    $this->setRequiringState($project_id, 'post apply', $stage_id);
+  public function postApply(): JsonResponse {
+    $stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id'];
+    $this->setRequiringState(NULL, 'post apply', NULL);
     try {
       $this->installer->claim($stage_id)->postApply();
     }
@@ -481,18 +486,12 @@ class InstallerController extends ControllerBase {
   /**
    * Performs destroy operations on the stage.
    *
-   * @param string $composer_namespace
-   *   The project composer namespace.
-   * @param string $project_id
-   *   The project id.
-   * @param string $stage_id
-   *   ID of stage created in the begin() method.
-   *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function destroy(string $composer_namespace, string $project_id, string $stage_id): JsonResponse {
-    $this->setRequiringState($project_id, 'completing', $stage_id);
+  public function destroy(): JsonResponse {
+    $stage_id = $this->projectBrowserTempStore->get('requiring')['stage_id'];
+    $this->setRequiringState(NULL, 'completing', NULL);
     try {
       $this->installer->claim($stage_id)->destroy();
     }
@@ -510,25 +509,32 @@ class InstallerController extends ControllerBase {
   /**
    * Installs an already downloaded module.
    *
+   * @param string $source
+   *   The source plugin ID.
    * @param string $project_id
-   *   The project machine name.
+   *   The ID of the project, as known to the source plugin.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   Status message.
    */
-  public function activateModule(string $project_id): JsonResponse {
+  public function activate(string $source, string $project_id): JsonResponse {
     $this->projectBrowserTempStore->set('installing', $project_id);
-    try {
-      $this->moduleInstaller->install([$project_id]);
-    }
-    catch (\Exception $e) {
-      $this->resetProgress();
-      return $this->errorResponse($e, 'project install');
+
+    $projects = $this->enabledSourceHandler->getCurrentSources()[$source]?->getProjects()->list ?? [];
+    foreach ($projects as $project) {
+      if ($project->id === $project_id) {
+        try {
+          $this->activator->activate($project);
+        }
+        catch (\Throwable $e) {
+          return $this->errorResponse($e, 'project install');
+        }
+        finally {
+          $this->resetProgress();
+        }
+      }
     }
-    $this->projectBrowserTempStore->delete('installing');
-    return new JsonResponse([
-      'status' => 0,
-    ]);
+    return new JsonResponse(['status' => 0]);
   }
 
 }
diff --git a/src/ModuleActivator.php b/src/ModuleActivator.php
new file mode 100644
index 0000000000000000000000000000000000000000..08846840d7bbb4bde29a2f70827e82a0490dc4c7
--- /dev/null
+++ b/src/ModuleActivator.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser;
+
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Extension\ModuleInstallerInterface;
+use Drupal\project_browser\ProjectBrowser\Project;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * An activator for Drupal modules.
+ */
+final class ModuleActivator implements ActivatorInterface {
+
+  public function __construct(
+    private readonly ModuleHandlerInterface $moduleHandler,
+    private readonly ModuleInstallerInterface $moduleInstaller,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isActive(Project $project): bool {
+    return $this->moduleHandler->moduleExists($project->machineName);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supports(Project $project): bool {
+    // At the moment, Project Browser only supports modules, so all projects can
+    // be handled by this activator.
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function activate(Project $project): ?Response {
+    $this->moduleInstaller->install([$project->machineName]);
+    return NULL;
+  }
+
+}
diff --git a/src/Plugin/ProjectBrowserSource/DrupalCore.php b/src/Plugin/ProjectBrowserSource/DrupalCore.php
index b58136cdfb7c4de9b71a8aa84b4b3ea8398c8c31..80c5fd2cf98adcba160e33e9fef4f4ed88b3f9a9 100644
--- a/src/Plugin/ProjectBrowserSource/DrupalCore.php
+++ b/src/Plugin/ProjectBrowserSource/DrupalCore.php
@@ -205,7 +205,7 @@ class DrupalCore extends ProjectBrowserSourceBase {
         author: [
           'name' => 'Drupal Core',
         ],
-        composerNamespace: '',
+        packageName: '',
         categories: [
           [
             'id' => $module->info['package'],
diff --git a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
index 8995d2b6e333204d4074dbb69552b2036af94c0f..c1cb103a3ca5b7f56f53e71507aa73fc16103263 100644
--- a/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
+++ b/src/Plugin/ProjectBrowserSource/MockDrupalDotOrg.php
@@ -385,7 +385,7 @@ class MockDrupalDotOrg extends ProjectBrowserSourceBase {
           changed: $project_data['changed'],
           created: $project_data['created'],
           author: ['name' => $project_data['author']],
-          composerNamespace: 'drupal/' . $project_data['field_project_machine_name'],
+          packageName: 'drupal/' . $project_data['field_project_machine_name'],
           url: 'https://www.drupal.org/project/' . $project_data['field_project_machine_name'],
           // Add name property to each category, so it can be rendered.
           categories: array_map(fn($category) => $categories[$category['id']] ?? '', $project_data['project_data']['taxonomy_vocabulary_3'] ?? []),
diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php
index 805538e43d74afb06c6540cf780c9465b7728158..32d08f252998747f415e76f1f70d6e35052f14da 100644
--- a/src/ProjectBrowser/Project.php
+++ b/src/ProjectBrowser/Project.php
@@ -11,6 +11,13 @@ use Drupal\Component\Utility\Xss;
  */
 class Project implements \JsonSerializable {
 
+  /**
+   * The unqualified project ID.
+   *
+   * @var string
+   */
+  public readonly string $id;
+
   /**
    * Constructs a Project object.
    *
@@ -42,8 +49,8 @@ class Project implements \JsonSerializable {
    *   When was the project created last timestamp.
    * @param array $author
    *   Author of the project in array format.
-   * @param string $composerNamespace
-   *   Composer namespace of the project.
+   * @param string $packageName
+   *   The Composer package name of this project, e.g. `drupal/project_browser`.
    * @param string $url
    *   URL of the project.
    * @param array $categories
@@ -64,6 +71,7 @@ class Project implements \JsonSerializable {
    *   the contents of the "View Commands" popup.
    *   To include a paste-able command that includes a copy button, use this
    *   markup structure:
+   *
    *   @code
    *   <div class="command-box">
    *     <input value="THE_COMMAND_TO_BE_COPIED" readonly="" />
@@ -72,6 +80,9 @@ class Project implements \JsonSerializable {
    *     </button>
    *   </div>
    *  @endcode
+   * @param string $id
+   *   (optional) The unqualified project ID. Cannot contain a slash. Defaults
+   *   to the machine name.
    */
   public function __construct(
     public array $logo,
@@ -88,15 +99,22 @@ class Project implements \JsonSerializable {
     public int $changed,
     public int $created,
     public array $author,
-    public string $composerNamespace,
+    public string $packageName,
     public string $url = '',
     public array $categories = [],
     public array $images = [],
     public array $warnings = [],
     public string $type = 'module:drupalorg',
     public string|bool $commands = FALSE,
+    string $id = '',
   ) {
     $this->setSummary($body);
+    // @see \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage::jsonSerialize()
+    // @see \Drupal\project_browser\Routing\ProjectBrowserRoutes::routes()
+    if (str_contains($id, '/')) {
+      throw new \InvalidArgumentException("Project IDs cannot contain slashes.");
+    }
+    $this->id = $id ? $id : $machineName;
   }
 
   /**
@@ -144,7 +162,7 @@ class Project implements \JsonSerializable {
       'title' => $this->title,
       'author' => $this->author,
       'warnings' => $this->warnings,
-      'composer_namespace' => $this->composerNamespace,
+      'package_name' => $this->packageName,
       // @todo Not used in Svelte. Audit in https://www.drupal.org/i/3309273.
       'is_maintained' => $this->isMaintained,
       'is_active' => $this->isActive,
diff --git a/src/ProjectBrowser/ProjectsResultsPage.php b/src/ProjectBrowser/ProjectsResultsPage.php
index 6660df92fbc622096d541026a1758d6fb9676c02..5f520abaa9dfc97a22e9e79f453cb62306e3d9a6 100644
--- a/src/ProjectBrowser/ProjectsResultsPage.php
+++ b/src/ProjectBrowser/ProjectsResultsPage.php
@@ -7,15 +7,6 @@ namespace Drupal\project_browser\ProjectBrowser;
  */
 class ProjectsResultsPage implements \JsonSerializable {
 
-  /**
-   * Separates the source plugin ID from a project's local ID.
-   *
-   * @var string
-   *
-   * @see ::jsonSerialize()
-   */
-  public const ID_SEPARATOR = '::';
-
   /**
    * Constructor for project browser results page.
    *
@@ -48,7 +39,7 @@ class ProjectsResultsPage implements \JsonSerializable {
 
     $map = function (Project $project): object {
       $serialized = $project->jsonSerialize();
-      $serialized->id = $this->pluginId . static::ID_SEPARATOR . $project->machineName;
+      $serialized->id = $this->pluginId . '/' . $project->id;
       return $serialized;
     };
     $values['list'] = array_map($map, $values['list']);
diff --git a/src/Routing/ProjectBrowserRoutes.php b/src/Routing/ProjectBrowserRoutes.php
index 6476426c7f5064cb8bc68047daaa3db5c5a4e493..4bbb15db47c6c675479efcba87ccb1d24af74094 100644
--- a/src/Routing/ProjectBrowserRoutes.php
+++ b/src/Routing/ProjectBrowserRoutes.php
@@ -46,97 +46,80 @@ class ProjectBrowserRoutes implements ContainerInjectionInterface {
       return [];
     }
     $routes = [];
-    $stage_id_regex = $machine_name_regex = '[a-zA-Z0-9_-]+';
     $routes['project_browser.stage.begin'] = new Route(
-      '/admin/modules/project_browser/install-begin/{composer_namespace}/{project_id}',
+      '/admin/modules/project_browser/install-begin/{source}/{project_id}',
       [
         '_controller' => InstallerController::class . '::begin',
         '_title' => 'Create phase',
       ],
       [
         '_permission' => 'administer modules',
-        'composer_namespace' => $machine_name_regex,
-        'project_id' => $machine_name_regex,
         '_custom_access' => InstallerController::class . '::access',
       ],
     );
     $routes['project_browser.stage.require'] = new Route(
-      '/admin/modules/project_browser/install-require/{composer_namespace}/{project_id}/{stage_id}',
+      '/admin/modules/project_browser/install-require/{source}/{project_id}',
       [
         '_controller' => InstallerController::class . '::require',
         '_title' => 'Require phase',
       ],
       [
         '_permission' => 'administer modules',
-        'composer_namespace' => $machine_name_regex,
-        'project_id' => $machine_name_regex,
-        'stage_id' => $stage_id_regex,
         '_custom_access' => InstallerController::class . '::access',
       ],
     );
     $routes['project_browser.stage.apply'] = new Route(
-      '/admin/modules/project_browser/install-apply/{composer_namespace}/{project_id}/{stage_id}',
+      '/admin/modules/project_browser/install-apply',
       [
         '_controller' => InstallerController::class . '::apply',
         '_title' => 'Apply phase',
       ],
       [
         '_permission' => 'administer modules',
-        'composer_namespace' => $machine_name_regex,
-        'project_id' => $machine_name_regex,
-        'stage_id' => $stage_id_regex,
         '_custom_access' => InstallerController::class . '::access',
       ],
     );
     $routes['project_browser.stage.post_apply'] = new Route(
-      '/admin/modules/project_browser/install-post_apply/{composer_namespace}/{project_id}/{stage_id}',
+      '/admin/modules/project_browser/install-post_apply',
       [
         '_controller' => InstallerController::class . '::postApply',
         '_title' => 'Post apply phase',
       ],
       [
         '_permission' => 'administer modules',
-        'composer_namespace' => $machine_name_regex,
-        'project_id' => $machine_name_regex,
-        'stage_id' => $stage_id_regex,
         '_custom_access' => InstallerController::class . '::access',
       ],
     );
     $routes['project_browser.stage.destroy'] = new Route(
-      '/admin/modules/project_browser/install-destroy/{composer_namespace}/{project_id}/{stage_id}',
+      '/admin/modules/project_browser/install-destroy',
       [
         '_controller' => InstallerController::class . '::destroy',
         '_title' => 'Destroy phase',
       ],
       [
         '_permission' => 'administer modules',
-        'composer_namespace' => $machine_name_regex,
-        'project_id' => $machine_name_regex,
-        'stage_id' => $stage_id_regex,
         '_custom_access' => InstallerController::class . '::access',
       ],
     );
-    $routes['project_browser.activate.module'] = new Route(
-      '/admin/modules/project_browser/activate-module/{project_id}',
+    $routes['project_browser.activate'] = new Route(
+      '/admin/modules/project_browser/activate/{source}/{project_id}',
       [
-        '_controller' => InstallerController::class . '::activateModule',
+        '_controller' => InstallerController::class . '::activate',
         '_title' => 'Install module in core',
       ],
       [
         '_permission' => 'administer modules',
-        'project_id' => $machine_name_regex,
         '_custom_access' => InstallerController::class . '::access',
       ],
     );
     $routes['project_browser.module.install_in_progress'] = new Route(
-      '/admin/modules/project_browser/install_in_progress/{project_id}',
+      '/admin/modules/project_browser/install_in_progress/{source}/{project_id}',
       [
         '_controller' => InstallerController::class . '::inProgress',
         '_title' => 'Install in progress',
       ],
       [
         '_permission' => 'administer modules',
-        'project_id' => $machine_name_regex,
         '_custom_access' => InstallerController::class . '::access',
       ],
     );
diff --git a/sveltejs/public/build/bundle.js b/sveltejs/public/build/bundle.js
index 71bda0301671ab16e54561ecfb29b3507f95c9d5..87c244f65bde301946546c9c4104f3fb7abfe152 100644
Binary files a/sveltejs/public/build/bundle.js and b/sveltejs/public/build/bundle.js differ
diff --git a/sveltejs/public/build/bundle.js.map b/sveltejs/public/build/bundle.js.map
index d9f70bd0776fc52e39e0792a88d8224c3f27e5e8..a75b81a7d650a3977aae6efc31ad4e1ed457f979 100644
Binary files a/sveltejs/public/build/bundle.js.map and b/sveltejs/public/build/bundle.js.map differ
diff --git a/sveltejs/src/Project/ActionButton.svelte b/sveltejs/src/Project/ActionButton.svelte
index e92b4627c0ffa4bc8cf6b922d060bd4c5c7edcaa..3e9feb7b801a8f8ceefa4ae7bc896448559c25dd 100644
--- a/sveltejs/src/Project/ActionButton.svelte
+++ b/sveltejs/src/Project/ActionButton.svelte
@@ -67,7 +67,7 @@
    *   Return is not used, but is a promise due to this being async.
    */
   const showStatus = async (initiate = false) => {
-    const url = `${ORIGIN_URL}/admin/modules/project_browser/install_in_progress/${project.project_machine_name}`;
+    const url = `${ORIGIN_URL}/admin/modules/project_browser/install_in_progress/${project.id}`;
 
     //
     /**
diff --git a/sveltejs/src/Project/AddInstallButton.svelte b/sveltejs/src/Project/AddInstallButton.svelte
index 0e2844cb512dd5754465ee748c4f6b83f76e427b..615395be7caa0e4691f512d946b3e56b8fc7aa30 100644
--- a/sveltejs/src/Project/AddInstallButton.svelte
+++ b/sveltejs/src/Project/AddInstallButton.svelte
@@ -60,7 +60,7 @@
    */
   async function installModule() {
     loading = true;
-    const url = `${ORIGIN_URL}/admin/modules/project_browser/activate-module/${project.project_machine_name}`;
+    const url = `${ORIGIN_URL}/admin/modules/project_browser/activate/${project.id}`;
     const installResponse = await fetch(url);
     if (!installResponse.ok) {
       handleError(installResponse);
@@ -98,22 +98,21 @@
      */
     async function doRequests() {
       loading = true;
-      const beginInstallUrl = `${ORIGIN_URL}/admin/modules/project_browser/install-begin/${project.composer_namespace}`;
+      const beginInstallUrl = `${ORIGIN_URL}/admin/modules/project_browser/install-begin/${project.id}`;
       const beginInstallResponse = await fetch(beginInstallUrl);
       if (!beginInstallResponse.ok) {
         await handleError(beginInstallResponse);
       } else {
         const beginInstallJson = await beginInstallResponse.json();
-        const stageId = beginInstallJson.stage_id;
 
         // The process of adding a module is separated into four stages, each
         // with their own endpoint. When one stage completes, the next one is
         // requested.
         const installSteps = [
-          `${ORIGIN_URL}/admin/modules/project_browser/install-require/${project.composer_namespace}/${stageId}`,
-          `${ORIGIN_URL}/admin/modules/project_browser/install-apply/${project.composer_namespace}/${stageId}`,
-          `${ORIGIN_URL}/admin/modules/project_browser/install-post_apply/${project.composer_namespace}/${stageId}`,
-          `${ORIGIN_URL}/admin/modules/project_browser/install-destroy/${project.composer_namespace}/${stageId}`,
+          `${ORIGIN_URL}/admin/modules/project_browser/install-require/${project.id}`,
+          `${ORIGIN_URL}/admin/modules/project_browser/install-apply`,
+          `${ORIGIN_URL}/admin/modules/project_browser/install-post_apply`,
+          `${ORIGIN_URL}/admin/modules/project_browser/install-destroy`,
         ];
 
         // eslint-disable-next-line no-restricted-syntax,guard-for-in
diff --git a/sveltejs/src/popup.js b/sveltejs/src/popup.js
index c6d80261a19e651b0e9609242082a4f98179ac79..4c716eda4914d6d90c154cf0ce1704ffa8358575 100644
--- a/sveltejs/src/popup.js
+++ b/sveltejs/src/popup.js
@@ -111,7 +111,7 @@ export const getCommandsPopupMessage = (project) => {
               <p>${composerText}</p>
               <p>${composerExistsText}:</p>
               <div class="command-box">
-                <input value="composer require ${project.composer_namespace}" readonly/>
+                <input value="composer require ${project.package_name}" readonly/>
                 ${downloadCopyButton}
               </div>
 
diff --git a/tests/modules/project_browser_test/project_browser_test.services.yml b/tests/modules/project_browser_test/project_browser_test.services.yml
index 91638f0b9f875bd77cb90e539eaf35c01dad11a1..b01467dcf2912ebd7a7f00850adc962f207fe296 100644
--- a/tests/modules/project_browser_test/project_browser_test.services.yml
+++ b/tests/modules/project_browser_test/project_browser_test.services.yml
@@ -8,3 +8,9 @@ services:
     tags:
       - { name: http_client_middleware }
     arguments: ['@module_handler']
+  Drupal\project_browser_test\TestActivator:
+    autowire: true
+    public: false
+    decorates: 'Drupal\project_browser\ActivatorInterface'
+    arguments:
+      - '@.inner'
diff --git a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
index 51048153a3644495ef20d316ed5824cf1c62e7f1..d3ef9eb2e515eaef38ee601ad4096973a8ab6029 100644
--- a/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
+++ b/tests/modules/project_browser_test/src/Plugin/ProjectBrowserSource/DrupalDotOrgJsonApi.php
@@ -393,7 +393,7 @@ class DrupalDotOrgJsonApi extends ProjectBrowserSourceBase {
           author: [
             'name' => $related[$uid_info['type']][$uid_info['id']]['name'],
           ],
-          composerNamespace: $project['attributes']['field_composer_namespace'] ?? 'drupal/' . $machine_name,
+          packageName: $project['attributes']['field_composer_namespace'] ?? 'drupal/' . $machine_name,
           categories: $module_categories,
           images: $project_images,
         );
diff --git a/tests/modules/project_browser_test/src/TestActivator.php b/tests/modules/project_browser_test/src/TestActivator.php
new file mode 100644
index 0000000000000000000000000000000000000000..fcc01dc98de6302bebd3fd534cdf4cf5900ec715
--- /dev/null
+++ b/tests/modules/project_browser_test/src/TestActivator.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser_test;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\project_browser\ActivatorInterface;
+use Drupal\project_browser\ProjectBrowser\Project;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * A test activator that simply logs a state message.
+ */
+class TestActivator implements ActivatorInterface {
+
+  public function __construct(
+    private readonly ActivatorInterface $decorated,
+    private readonly StateInterface $state,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function supports(Project $project): bool {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isActive(Project $project): bool {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function activate(Project $project): ?Response {
+    $this->state->set('test activator', "$project->title was activated!");
+    return $this->decorated->activate($project);
+  }
+
+}
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index ee5c09b1566142ac21eb16a813cf63070cf28931..bce90909c48ea045f3949b56c18818b6d47f92a4 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -99,7 +99,11 @@ class InstallerControllerTest extends BrowserTestBase {
       'field_security_advisory_coverage' => 'covered',
       'flag_project_star_user_count' => 0,
       'field_project_type' => 'full',
-      'project_data' => serialize([]),
+      'project_data' => serialize([
+        'body' => [
+          'value' => $this->getRandomGenerator()->paragraphs(1),
+        ],
+      ]),
       'field_project_machine_name' => 'awesome_module',
     ]);
     $query->values([
@@ -115,9 +119,33 @@ class InstallerControllerTest extends BrowserTestBase {
       'field_security_advisory_coverage' => 'covered',
       'flag_project_star_user_count' => 0,
       'field_project_type' => 'full',
-      'project_data' => serialize([]),
+      'project_data' => serialize([
+        'body' => [
+          'value' => $this->getRandomGenerator()->paragraphs(1),
+        ],
+      ]),
       'field_project_machine_name' => 'awesome_module',
     ]);
+    $query->values([
+      'nid' => 333,
+      'title' => 'Drupal core',
+      'author' => 'The usual gang of geniuses',
+      'created' => 1383917647,
+      'changed' => 1663534145,
+      'project_usage_total' => 987654321,
+      'maintenance_status' => 13028,
+      'development_status' => 9988,
+      'status' => 1,
+      'field_security_advisory_coverage' => 'covered',
+      'flag_project_star_user_count' => 0,
+      'field_project_type' => 'full',
+      'project_data' => serialize([
+        'body' => [
+          'value' => $this->getRandomGenerator()->paragraphs(1),
+        ],
+      ]),
+      'field_project_machine_name' => 'core',
+    ]);
     $query->execute();
     $this->initPackageManager();
     $this->sharedTempStore = $this->container->get('tempstore.shared');
@@ -133,7 +161,7 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   public function testUiInstallUnavailableIfDisabled() {
     $this->config('project_browser.admin_settings')->set('allow_ui_install', FALSE)->save();
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(403);
     $this->assertSession()->pageTextContains('Access denied');
   }
@@ -145,7 +173,7 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   public function testInstallSecurityRevokedModule() {
     $this->assertProjectBrowserTempStatus(NULL, NULL);
-    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/security_revoked_module');
+    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/security_revoked_module');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"security_revoked_module is not safe to add because its security coverage has been revoked"}', $content);
   }
@@ -160,9 +188,9 @@ class InstallerControllerTest extends BrowserTestBase {
     // Though core is not available as a choice in project browser, it works
     // well for the purposes of this test as it's definitely already added
     // via composer.
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/core');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/core');
     $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0];
-    $content = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/core/$this->stageId");
+    $content = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/core");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: The following package is already installed: drupal\/core\n","phase":"require"}', $content);
   }
@@ -174,12 +202,12 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   private function doStart() {
     $this->assertProjectBrowserTempStatus(NULL, NULL);
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->stageId = $this->sharedTempStore->get('package_manager_stage')->get('lock')[0];
     $this->assertSession()->statusCodeEquals(200);
     $expected_output = sprintf('{"phase":"create","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('awesome_module', 'creating install stage');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'creating install stage');
   }
 
   /**
@@ -188,10 +216,10 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::require
    */
   private function doRequire() {
-    $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId");
+    $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module");
     $expected_output = sprintf('{"phase":"require","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('awesome_module', 'requiring module');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'requiring module');
   }
 
   /**
@@ -200,10 +228,10 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::apply
    */
   private function doApply() {
-    $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId");
+    $this->drupalGet("/admin/modules/project_browser/install-apply");
     $expected_output = sprintf('{"phase":"apply","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('awesome_module', 'applying');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'applying');
   }
 
   /**
@@ -212,10 +240,10 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::postApply
    */
   private function doPostApply() {
-    $this->drupalGet("/admin/modules/project_browser/install-post_apply/drupal/awesome_module/$this->stageId");
+    $this->drupalGet("/admin/modules/project_browser/install-post_apply");
     $expected_output = sprintf('{"phase":"post apply","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('awesome_module', 'post apply');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'post apply');
   }
 
   /**
@@ -224,7 +252,7 @@ class InstallerControllerTest extends BrowserTestBase {
    * @covers ::destroy
    */
   private function doDestroy() {
-    $this->drupalGet("/admin/modules/project_browser/install-destroy/drupal/awesome_module/$this->stageId");
+    $this->drupalGet("/admin/modules/project_browser/install-destroy");
     $expected_output = sprintf('{"phase":"destroy","status":0,"stage_id":"%s"}', $this->stageId);
     $this->assertSame($expected_output, $this->getSession()->getPage()->getContent());
     $this->assertInstallNotInProgress('awesome_module');
@@ -250,7 +278,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $message = t('This is a PreCreate error.');
     $result = ValidationResult::createError([$message]);
     TestSubscriber::setTestResult([$result], PreCreateEvent::class);
-    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: This is a PreCreate error.\n","phase":"create"}', $contents);
   }
@@ -263,7 +291,7 @@ class InstallerControllerTest extends BrowserTestBase {
   public function testPreCreateException() {
     $error = new \Exception('PreCreate did not go well.');
     TestSubscriber::setException($error, PreCreateEvent::class);
-    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PreCreate did not go well.","phase":"create"}', $contents);
   }
@@ -276,7 +304,7 @@ class InstallerControllerTest extends BrowserTestBase {
   public function testPostCreateException() {
     $error = new \Exception('PostCreate did not go well.');
     TestSubscriber::setException($error, PostCreateEvent::class);
-    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $contents = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PostCreate did not go well.","phase":"create"}', $contents);
   }
@@ -291,7 +319,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $result = ValidationResult::createError([$message]);
     $this->doStart();
     TestSubscriber::setTestResult([$result], PreRequireEvent::class);
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId");
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: This is a PreRequire error.\n","phase":"require"}', $contents);
   }
@@ -305,7 +333,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $error = new \Exception('PreRequire did not go well.');
     TestSubscriber::setException($error, PreRequireEvent::class);
     $this->doStart();
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId");
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PreRequire did not go well.","phase":"require"}', $contents);
   }
@@ -319,7 +347,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $error = new \Exception('PostRequire did not go well.');
     TestSubscriber::setException($error, PostRequireEvent::class);
     $this->doStart();
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupal/awesome_module/$this->stageId");
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-require/drupalorg_mockapi/awesome_module");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PostRequire did not go well.","phase":"require"}', $contents);
   }
@@ -335,7 +363,7 @@ class InstallerControllerTest extends BrowserTestBase {
     TestSubscriber::setTestResult([$result], PreApplyEvent::class);
     $this->doStart();
     $this->doRequire();
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId");
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-apply");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: This is a PreApply error.\n","phase":"apply"}', $contents);
   }
@@ -350,7 +378,7 @@ class InstallerControllerTest extends BrowserTestBase {
     TestSubscriber::setException($error, PreApplyEvent::class);
     $this->doStart();
     $this->doRequire();
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-apply/drupal/awesome_module/$this->stageId");
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-apply");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PreApply did not go well.","phase":"apply"}', $contents);
   }
@@ -366,7 +394,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->doStart();
     $this->doRequire();
     $this->doApply();
-    $contents = $this->drupalGet("/admin/modules/project_browser/install-post_apply/drupal/awesome_module/$this->stageId");
+    $contents = $this->drupalGet("/admin/modules/project_browser/install-post_apply");
     $this->assertSession()->statusCodeEquals(500);
     $this->assertSame('{"message":"StageEventException: PostApply did not go well.","phase":"post apply"}', $contents);
   }
@@ -380,37 +408,37 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->doStart();
 
     // Check for mid install unlock offer message.
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked less than 1 minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent());
-    $this->assertInstallInProgress('awesome_module', 'creating install stage');
+    $this->assertInstallInProgress('drupalorg_mockapi/awesome_module', 'creating install stage');
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
     TestTime::setFakeTimeByOffset("+800 seconds");
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent());
     $this->doRequire();
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
     $this->doApply();
     TestTime::setFakeTimeByOffset('+800 seconds');
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertTrue($this->installer->isApplying());
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 13 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent());
     TestTime::setFakeTimeByOffset("+55 minutes");
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 55 minutes ago. It should not be unlocked as the changes from staging are being applied to the site.","unlock_url":""}/', $this->getSession()->getPage()->getContent());
     // Unlocking the stage becomes possible after 1 hour regardless of source.
     TestTime::setFakeTimeByOffset("+75 minutes");
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/awesome_module');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/awesome_module');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertMatchesRegularExpression('/{"message":"The install staging area was locked 1 hours, 15 minutes ago.","unlock_url":".*admin..modules..project_browser..install..unlock\?token=[a-zA-Z0-9_-]*"}/', $this->getSession()->getPage()->getContent());
   }
@@ -426,7 +454,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->doStart();
     // Try beginning another install while one is in progress, but not yet in
     // the applying stage.
-    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag');
+    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
@@ -450,7 +478,7 @@ class InstallerControllerTest extends BrowserTestBase {
   public function testCanBreakStageWithMissingProjectBrowserLock() {
     $this->doStart();
     $this->sharedTempStore->get('project_browser')->delete('requiring');
-    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag');
+    $content = $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag');
     $this->assertSession()->statusCodeEquals(418);
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
@@ -471,21 +499,25 @@ class InstallerControllerTest extends BrowserTestBase {
    *
    * @covers ::activateModule
    */
-  public function testCoreModuleActivate() {
+  public function testCoreModuleActivate(): void {
+    $assert_session = $this->assertSession();
+
+    // Since we are activating a core module, we need the source plugin that
+    // exposes core modules to be enabled.
+    $this->config('project_browser.admin_settings')
+      ->set('enabled_sources', ['drupal_core'])
+      ->save();
+
     $this->drupalGet('admin/modules');
-    $views_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-enable');
-    $views_ui_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-ui-enable');
-    $this->assertFalse($views_checkbox->isChecked());
-    $this->assertFalse($views_ui_checkbox->isChecked());
+    $assert_session->checkboxNotChecked('edit-modules-views-enable');
+    $assert_session->checkboxNotChecked('edit-modules-views-ui-enable');
 
-    $content = $this->drupalGet('admin/modules/project_browser/activate-module/views_ui');
+    $content = $this->drupalGet('admin/modules/project_browser/activate/drupal_core/views_ui');
     $this->assertSame('{"status":0}', $content);
     $this->rebuildContainer();
     $this->drupalGet('admin/modules');
-    $views_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-enable');
-    $views_ui_checkbox = $this->getSession()->getPage()->find('css', '#edit-modules-views-ui-enable');
-    $this->assertTrue($views_checkbox->isChecked());
-    $this->assertTrue($views_ui_checkbox->isChecked());
+    $assert_session->checkboxChecked('edit-modules-views-enable');
+    $assert_session->checkboxChecked('edit-modules-views-ui-enable');
   }
 
   /**
@@ -496,9 +528,9 @@ class InstallerControllerTest extends BrowserTestBase {
    */
   protected function assertInstallNotInProgress($module) {
     $this->assertProjectBrowserTempStatus(NULL, NULL);
-    $this->drupalGet("/admin/modules/project_browser/install_in_progress/$module");
+    $this->drupalGet("/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/$module");
     $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent());
-    $this->drupalGet('/admin/modules/project_browser/install_in_progress/metatag');
+    $this->drupalGet('/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/metatag');
     $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent());
   }
 
@@ -521,7 +553,7 @@ class InstallerControllerTest extends BrowserTestBase {
     $this->assertProjectBrowserTempStatus($expect_install, NULL);
     $this->drupalGet("/admin/modules/project_browser/install_in_progress/$module");
     $this->assertSame(sprintf('{"status":1,"phase":"%s"}', $phase), $this->getSession()->getPage()->getContent());
-    $this->drupalGet('/admin/modules/project_browser/install_in_progress/metatag');
+    $this->drupalGet('/admin/modules/project_browser/install_in_progress/drupalorg_mockapi/metatag');
     $this->assertSame('{"status":0}', $this->getSession()->getPage()->getContent());
   }
 
@@ -536,6 +568,18 @@ class InstallerControllerTest extends BrowserTestBase {
   protected function assertProjectBrowserTempStatus($expected_requiring, $expected_installing) {
     $project_browser_requiring = $this->sharedTempStore->get('project_browser')->get('requiring');
     $project_browser_installing = $this->sharedTempStore->get('project_browser')->get('installing');
+    if (is_array($expected_installing)) {
+      ksort($expected_installing);
+    }
+    if (is_array($expected_requiring)) {
+      ksort($expected_requiring);
+    }
+    if (is_array($project_browser_installing)) {
+      ksort($project_browser_installing);
+    }
+    if (is_array($project_browser_requiring)) {
+      ksort($project_browser_requiring);
+    }
     $this->assertSame($expected_requiring, $project_browser_requiring);
     $this->assertSame($expected_installing, $project_browser_installing);
   }
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 4d278eaf210a6f17979f0b2c30591f1f89b76dac..466f888e4c2b0621f2d7fc171e5f921bf1d161ff 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\project_browser\FunctionalJavascript;
 
+use Drupal\Core\State\StateInterface;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
 use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait;
 
@@ -69,8 +70,12 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $this->assertSame('Add and Install Cream cheese on a bagel', $download_button->getText());
     $download_button->click();
     $installed_action = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector .project_status-indicator", 30000);
-    $assert_session->waitForText('✓ Cream cheese on a bagel is Installed');
+    $this->assertTrue($assert_session->waitForText('✓ Cream cheese on a bagel is Installed'));
     $this->assertSame('✓ Cream cheese on a bagel is Installed', $installed_action->getText());
+
+    // The activator in project_browser_test should have logged a message.
+    // @see \Drupal\project_browser_test\TestActivator
+    $this->assertSame('Cream cheese on a bagel was activated!', $this->container->get(StateInterface::class)->get('test activator'));
   }
 
   /**
@@ -144,7 +149,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $page = $this->getSession()->getPage();
 
     // Start install begin.
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag');
     $this->sharedTempStore->get('project_browser')->delete('requiring');
     $this->drupalGet('admin/modules/browse');
     $this->svelteInitHelper('text', 'Cream cheese on a bagel');
@@ -180,7 +185,7 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $page = $this->getSession()->getPage();
 
     // Start install begin.
-    $this->drupalGet('admin/modules/project_browser/install-begin/drupal/metatag');
+    $this->drupalGet('admin/modules/project_browser/install-begin/drupalorg_mockapi/metatag');
     $this->drupalGet('admin/modules/browse');
     $this->svelteInitHelper('text', 'Cream cheese on a bagel');
     // Try beginning another install while one is in progress, but not yet in
@@ -198,7 +203,6 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
     $installed_action = $assert_session->waitForElementVisible('css', "$cream_cheese_module_selector .project_status-indicator", 30000);
     $assert_session->waitForText('✓ Cream cheese on a bagel is Installed');
     $this->assertSame('✓ Cream cheese on a bagel is Installed', $installed_action->getText());
-
   }
 
 }