diff --git a/phpstan.neon b/phpstan.neon
index 76dde2088bba117a8d58282f01024deb0a8e92a3..48d0f50e7e870d64ea84a77f6ed66ab64f7af554 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -73,20 +73,20 @@ parameters:
       message: "#^Access to constant COMPOSER_PROJECT_TYPE on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe\\.$#"
       paths:
         - src/Plugin/ProjectBrowserSource/Recipes.php
-        - src/RecipeActivator.php
+        - src/Activator/RecipeActivator.php
         - tests/src/Kernel/RecipesSourceTest.php
       reportUnmatched: false
     -
       message: "#^Call to static method [a-zA-Z]+\\(\\) on an unknown class Drupal\\\\Core\\\\Recipe\\\\Recipe[a-zA-Z]*\\.$#"
-      path: src/RecipeActivator.php
+      path: src/Activator/RecipeActivator.php
       reportUnmatched: false
     -
       message: "#^Class Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent not found\\.$#"
-      path: src/RecipeActivator.php
+      path: src/Activator/RecipeActivator.php
       reportUnmatched: false
     -
-      message: "#^Parameter \\$event of method Drupal\\\\project_browser\\\\RecipeActivator\\:\\:onApply\\(\\) has invalid type Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent\\.$#"
-      path: src/RecipeActivator.php
+      message: "#^Parameter \\$event of method Drupal\\\\project_browser\\\\Activator\\\\RecipeActivator\\:\\:onApply\\(\\) has invalid type Drupal\\\\Core\\\\Recipe\\\\RecipeAppliedEvent\\.$#"
+      path: src/Activator/RecipeActivator.php
       reportUnmatched: false
 
     -
diff --git a/project_browser.services.yml b/project_browser.services.yml
index 53c79727684ad2d0755d098ba07610b2a535245e..aa19f8e83418255af608b33dc7c23c9cb9b50209 100644
--- a/project_browser.services.yml
+++ b/project_browser.services.yml
@@ -20,11 +20,10 @@ services:
       - { name: cache.bin }
     factory: cache_factory:get
     arguments: [project_browser]
-  Drupal\project_browser\Activator:
+  Drupal\project_browser\ActivationManager:
     tags:
       - { name: service_collector, tag: project_browser.activator, call: addActivator }
-  Drupal\project_browser\ActivatorInterface: '@Drupal\project_browser\Activator'
-  Drupal\project_browser\ModuleActivator:
+  Drupal\project_browser\Activator\ModuleActivator:
     public: false
     tags:
       - { name: project_browser.activator }
diff --git a/src/ActivationManager.php b/src/ActivationManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..5c6bf932dd0fd69851b3858b570b2cada1855e11
--- /dev/null
+++ b/src/ActivationManager.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser;
+
+use Drupal\Component\Utility\Xss;
+use Drupal\project_browser\Activator\ActivationStatus;
+use Drupal\project_browser\Activator\ActivatorInterface;
+use Drupal\project_browser\Activator\InstructionsInterface;
+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 ActivationManager {
+
+  /**
+   * The registered activators.
+   *
+   * @var \Drupal\project_browser\Activator\ActivatorInterface[]
+   */
+  private array $activators = [];
+
+  /**
+   * Registers an activator.
+   *
+   * @param \Drupal\project_browser\Activator\ActivatorInterface $activator
+   *   The activator to register.
+   */
+  public function addActivator(ActivatorInterface $activator): void {
+    if (in_array($activator, $this->activators, TRUE)) {
+      return;
+    }
+    $this->activators[] = $activator;
+  }
+
+  /**
+   * Determines if a particular project is activated on the current site.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project to check.
+   *
+   * @return \Drupal\project_browser\Activator\ActivationStatus
+   *   The state of the project on the current site.
+   */
+  public function getStatus(Project $project): ActivationStatus {
+    return $this->getActivatorForProject($project)->getStatus($project);
+  }
+
+  /**
+   * Returns the registered activator to handle a given project.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project object.
+   *
+   * @return \Drupal\project_browser\Activator\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.");
+  }
+
+  /**
+   * Gets activation information for a project, for delivery to the front-end.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   A project object.
+   *
+   * @return array
+   *   An array of activation information. Will consist of:
+   *   - `status`: The activation status of the project on the current site.
+   *     Will be the lowercase name of the one of the cases of
+   *     \Drupal\project_browser\Activator\ActivationStatus.
+   *   - `commands`: The instructions a human can take to activate the project
+   *     manually, or a URL where they can do so. Will be NULL if the registered
+   *     activator which supports the given project is not capable of generating
+   *     instructions.
+   *
+   * @see \Drupal\project_browser\ProjectBrowser\Project::toArray()
+   */
+  public function getActivationInfo(Project $project): array {
+    $data = [];
+
+    $activator = $this->getActivatorForProject($project);
+    $data['status'] = strtolower($activator->getStatus($project)->name);
+
+    if ($activator instanceof InstructionsInterface) {
+      $data['commands'] = Xss::filter(
+        $activator->getInstructions($project),
+        [...Xss::getAdminTagList(), 'textarea', 'button'],
+      );
+    }
+    else {
+      $data['commands'] = NULL;
+    }
+
+    return $data;
+  }
+
+  /**
+   * Activates a project on the current site.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   The project to activate.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response|null
+   *   The response, or lack thereof, returned by the first registered activator
+   *   that supports the given project.
+   */
+  public function activate(Project $project): ?Response {
+    return $this->getActivatorForProject($project)->activate($project);
+  }
+
+}
diff --git a/src/Activator.php b/src/Activator.php
deleted file mode 100644
index 0e363bf816a19a8c3f9768dfdb88d1c8d5532246..0000000000000000000000000000000000000000
--- a/src/Activator.php
+++ /dev/null
@@ -1,94 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\project_browser;
-
-use Drupal\Core\Url;
-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 getStatus(Project $project): ActivationStatus {
-    return $this->getActivatorForProject($project)->getStatus($project);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function supports(Project $project): bool {
-    try {
-      $this->getActivatorForProject($project);
-      return TRUE;
-    }
-    catch (\InvalidArgumentException) {
-      return FALSE;
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function activate(Project $project): ?Response {
-    return $this->getActivatorForProject($project)->activate($project);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getInstructions(Project $project): string|Url|null {
-    return $this->getActivatorForProject($project)->getInstructions($project);
-  }
-
-}
diff --git a/src/ActivationStatus.php b/src/Activator/ActivationStatus.php
similarity index 88%
rename from src/ActivationStatus.php
rename to src/Activator/ActivationStatus.php
index 12ba43a43a894bac66fe3a9ebec196b8aa5dd86c..81c786a19d2bf489c1cc2d03f23437e00b80ae50 100644
--- a/src/ActivationStatus.php
+++ b/src/Activator/ActivationStatus.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace Drupal\project_browser;
+namespace Drupal\project_browser\Activator;
 
 /**
  * Defines the possible states of a project in the current site.
diff --git a/src/ActivatorInterface.php b/src/Activator/ActivatorInterface.php
similarity index 67%
rename from src/ActivatorInterface.php
rename to src/Activator/ActivatorInterface.php
index b174ae34b7a8950f4beb6019b3fa93feb5d5cfeb..5fc5a86395611680cb7f7450612972a359d71346 100644
--- a/src/ActivatorInterface.php
+++ b/src/Activator/ActivatorInterface.php
@@ -2,9 +2,8 @@
 
 declare(strict_types=1);
 
-namespace Drupal\project_browser;
+namespace Drupal\project_browser\Activator;
 
-use Drupal\Core\Url;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Symfony\Component\HttpFoundation\Response;
 
@@ -23,7 +22,7 @@ interface ActivatorInterface {
    * @param \Drupal\project_browser\ProjectBrowser\Project $project
    *   A project to check.
    *
-   * @return \Drupal\project_browser\ActivationStatus
+   * @return \Drupal\project_browser\Activator\ActivationStatus
    *   The state of the project on the current site.
    */
   public function getStatus(Project $project): ActivationStatus;
@@ -56,21 +55,4 @@ interface ActivatorInterface {
    */
   public function activate(Project $project): ?Response;
 
-  /**
-   * Returns instructions, if applicable, for how to activate a project.
-   *
-   * @param \Drupal\project_browser\ProjectBrowser\Project $project
-   *   The project to activate.
-   *
-   * @return string|\Drupal\Core\Url|null
-   *   One of:
-   *   - A translated string containing human-readable instructions for how to
-   *     activate the given project. The UI will display these instructions in
-   *     a modal dialog.
-   *   - A URL which this project's "Install" button should link to in the UI.
-   *   - NULL if instructions are unavailable or unnecessary (for example, if
-   *     the project is a module that's already installed).
-   */
-  public function getInstructions(Project $project): string|Url|null;
-
 }
diff --git a/src/Activator/InstructionsInterface.php b/src/Activator/InstructionsInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..6d10167b0430f58adfb851e72021841ee566f197
--- /dev/null
+++ b/src/Activator/InstructionsInterface.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\project_browser\Activator;
+
+use Drupal\project_browser\ProjectBrowser\Project;
+
+/**
+ * An interface for activators that can generate activation instructions.
+ */
+interface InstructionsInterface extends ActivatorInterface {
+
+  /**
+   * Returns instructions for how to activate a project.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   The project to activate.
+   *
+   * @return string
+   *   One of:
+   *   - A translated string containing human-readable instructions for how to
+   *     activate the given project. The UI will display these instructions in
+   *     a modal dialog.
+   *   - An absolute URL which this project's "Install" button should link to in
+   *     the UI.
+   */
+  public function getInstructions(Project $project): string;
+
+}
diff --git a/src/ActivationInstructionsTrait.php b/src/Activator/InstructionsTrait.php
similarity index 96%
rename from src/ActivationInstructionsTrait.php
rename to src/Activator/InstructionsTrait.php
index ac5a57582b98380a92a68be91713af7724f2e16e..2deb87e303c02eb0a07df55baf31e3f17297f2c6 100644
--- a/src/ActivationInstructionsTrait.php
+++ b/src/Activator/InstructionsTrait.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace Drupal\project_browser;
+namespace Drupal\project_browser\Activator;
 
 use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\Core\File\FileUrlGeneratorInterface;
@@ -12,7 +12,7 @@ use Drupal\Core\StringTranslation\TranslatableMarkup;
 /**
  * Provides helper methods for activators which generate instructions.
  */
-trait ActivationInstructionsTrait {
+trait InstructionsTrait {
 
   use StringTranslationTrait;
 
diff --git a/src/ModuleActivator.php b/src/Activator/ModuleActivator.php
similarity index 89%
rename from src/ModuleActivator.php
rename to src/Activator/ModuleActivator.php
index f329fe3ab0a74f00181ad3be4b0e10ff177b813d..26a4a5e72246c53c0b95efe8e9772b32d12d6307 100644
--- a/src/ModuleActivator.php
+++ b/src/Activator/ModuleActivator.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace Drupal\project_browser;
+namespace Drupal\project_browser\Activator;
 
 use Composer\InstalledVersions;
 use Drupal\Core\Extension\ModuleExtensionList;
@@ -10,14 +10,15 @@ use Drupal\Core\Extension\ModuleInstallerInterface;
 use Drupal\Core\File\FileUrlGeneratorInterface;
 use Drupal\Core\Url;
 use Drupal\project_browser\ProjectBrowser\Project;
+use Drupal\project_browser\ProjectType;
 use Symfony\Component\HttpFoundation\Response;
 
 /**
  * An activator for Drupal modules.
  */
-final class ModuleActivator implements ActivatorInterface {
+final class ModuleActivator implements InstructionsInterface {
 
-  use ActivationInstructionsTrait;
+  use InstructionsTrait;
 
   public function __construct(
     private readonly ModuleInstallerInterface $moduleInstaller,
@@ -57,11 +58,12 @@ final class ModuleActivator implements ActivatorInterface {
   /**
    * {@inheritdoc}
    */
-  public function getInstructions(Project $project): string|Url {
+  public function getInstructions(Project $project): string {
     if ($this->getStatus($project) === ActivationStatus::Present) {
-      return Url::fromRoute('system.modules_list', options: [
-        'fragment' => 'module-' . str_replace('_', '-', $project->machineName),
-      ]);
+      return Url::fromRoute('system.modules_list')
+        ->setOption('fragment', 'module-' . str_replace('_', '-', $project->machineName))
+        ->setAbsolute()
+        ->toString();
     }
 
     $commands = '<h3>' . $this->t('1. Download') . '</h3>';
diff --git a/src/RecipeActivator.php b/src/Activator/RecipeActivator.php
similarity index 93%
rename from src/RecipeActivator.php
rename to src/Activator/RecipeActivator.php
index 72df6d4afc7dab83bb16425fd59e9235d0c9d64e..9198df13cbfaba60f20cb76ae61c845829c5d0a8 100644
--- a/src/RecipeActivator.php
+++ b/src/Activator/RecipeActivator.php
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace Drupal\project_browser;
+namespace Drupal\project_browser\Activator;
 
 use Composer\InstalledVersions;
 use Drupal\Core\Extension\ModuleExtensionList;
@@ -15,6 +15,7 @@ use Drupal\Core\Recipe\RecipeRunner;
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\Url;
 use Drupal\project_browser\ProjectBrowser\Project;
+use Drupal\project_browser\ProjectType;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Response;
@@ -22,9 +23,9 @@ use Symfony\Component\HttpFoundation\Response;
 /**
  * Applies locally installed recipes.
  */
-final class RecipeActivator implements ActivatorInterface, EventSubscriberInterface {
+final class RecipeActivator implements InstructionsInterface, EventSubscriberInterface {
 
-  use ActivationInstructionsTrait;
+  use InstructionsTrait;
 
   /**
    * The state key that stores the record of all applied recipes.
@@ -115,7 +116,7 @@ final class RecipeActivator implements ActivatorInterface, EventSubscriberInterf
 
       // The `redirect` key is not meaningful to JsonResponse; this is handled
       // specially by the Svelte app.
-      // @see sveltejs/src/ProcessQueueButton.svelte
+      // @see sveltejs/src/ProcessInstallListButton.svelte
       return new JsonResponse([
         'redirect' => $url->setAbsolute()->toString(),
       ]);
@@ -166,7 +167,7 @@ final class RecipeActivator implements ActivatorInterface, EventSubscriberInterf
       // If this is a test recipe, its package name will have a specific
       // prefix.
       if (str_starts_with($project->packageName, 'project-browser-test/')) {
-        $path = __DIR__ . '/../tests/fixtures/' . $project->machineName;
+        $path = $this->moduleList->getPath('project_browser') . '/tests/fixtures/' . $project->machineName;
       }
       else {
         // The package isn't installed, so we can't get the path.
diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index 2682848b20e0419c826d5250d6337872f96da701..1e717e4ed89543b4b61de34396c966e0058006f1 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -9,7 +9,7 @@ use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Url;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\StatusCheckTrait;
-use Drupal\project_browser\ActivatorInterface;
+use Drupal\project_browser\ActivationManager;
 use Drupal\project_browser\ComposerInstaller\Installer;
 use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\InstallState;
@@ -41,7 +41,7 @@ final class InstallerController extends ControllerBase {
     private readonly EnabledSourceHandler $enabledSourceHandler,
     private readonly TimeInterface $time,
     private readonly LoggerInterface $logger,
-    private readonly ActivatorInterface $activator,
+    private readonly ActivationManager $activationManager,
     private readonly InstallState $installState,
     private readonly EventDispatcherInterface $eventDispatcher,
   ) {}
@@ -59,7 +59,7 @@ final class InstallerController extends ControllerBase {
       $container->get(EnabledSourceHandler::class),
       $container->get(TimeInterface::class),
       $container->get('logger.channel.project_browser'),
-      $container->get(ActivatorInterface::class),
+      $container->get(ActivationManager::class),
       $container->get(InstallState::class),
       $container->get(EventDispatcherInterface::class),
     );
@@ -413,7 +413,7 @@ final class InstallerController extends ControllerBase {
       $this->installState->setState($project_id, 'activating');
       try {
         $project = $this->enabledSourceHandler->getStoredProject($project_id);
-        $response = $this->activator->activate($project);
+        $response = $this->activationManager->activate($project);
         $this->installState->setState($project_id, 'installed');
       }
       catch (\Throwable $e) {
diff --git a/src/Controller/ProjectBrowserEndpointController.php b/src/Controller/ProjectBrowserEndpointController.php
index 5b1c4299cf284b0ab3af6fb2e1687829fc189806..c5e58fb2f5f93f52bebc249a9fc60afaf0d6aa34 100644
--- a/src/Controller/ProjectBrowserEndpointController.php
+++ b/src/Controller/ProjectBrowserEndpointController.php
@@ -3,8 +3,10 @@
 namespace Drupal\project_browser\Controller;
 
 use Drupal\Core\Controller\ControllerBase;
-use Drupal\project_browser\ActivatorInterface;
+use Drupal\project_browser\ActivationManager;
 use Drupal\project_browser\EnabledSourceHandler;
+use Drupal\project_browser\ProjectBrowser\Project;
+use Drupal\project_browser\ProjectBrowser\ProjectsResultsPage;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
@@ -17,7 +19,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
 
   public function __construct(
     private readonly EnabledSourceHandler $enabledSource,
-    private readonly ActivatorInterface $activator,
+    private readonly ActivationManager $activationManager,
   ) {}
 
   /**
@@ -26,7 +28,7 @@ final class ProjectBrowserEndpointController extends ControllerBase {
   public static function create(ContainerInterface $container): static {
     return new static(
       $container->get(EnabledSourceHandler::class),
-      $container->get(ActivatorInterface::class),
+      $container->get(ActivationManager::class),
     );
   }
 
@@ -48,15 +50,34 @@ final class ProjectBrowserEndpointController extends ControllerBase {
       return new JsonResponse([], Response::HTTP_ACCEPTED);
     }
 
-    // The activator is the source of truth about the status of the project with
-    // respect to the current site, and it is responsible for generating
-    // the activation instructions or commands.
-    $result = $this->enabledSource->getProjects($query['source'], $query);
-    foreach ($result->list as $project) {
-      $project->status = $this->activator->getStatus($project);
-      $project->commands = $this->activator->getInstructions($project);
-    }
-    return new JsonResponse($result);
+    $results = $this->enabledSource->getProjects($query['source'], $query);
+    return new JsonResponse($this->prepareResults($results));
+  }
+
+  /**
+   * Prepares a set of results to be delivered to the front end.
+   *
+   * @param \Drupal\project_browser\ProjectBrowser\ProjectsResultsPage $results
+   *   A page of query results.
+   *
+   * @return array
+   *   The query results, with activation info and fully qualified IDs added to
+   *   all projects.
+   */
+  private function prepareResults(ProjectsResultsPage $results): array {
+    $data = $results->toArray();
+
+    // Add activation info to all the projects in the result set, and fully
+    // qualify the project IDs by prefixing them with the source plugin ID.
+    $mapper = function (Project $project) use ($results): array {
+      $data = $this->activationManager->getActivationInfo($project) + $project->toArray();
+      // Always send a fully qualified project ID to the front end.
+      $data['id'] = $results->pluginId . '/' . $project->id;
+      return $data;
+    };
+    $data['list'] = array_map($mapper, $data['list']);
+
+    return $data;
   }
 
   /**
diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php
index 46b6b93461e197a7489d852a6b24f39d20ad1a1d..1e27703d4cae65a70b601871e780510fd5918688 100644
--- a/src/ProjectBrowser/Project.php
+++ b/src/ProjectBrowser/Project.php
@@ -5,16 +5,14 @@ namespace Drupal\project_browser\ProjectBrowser;
 use Drupal\Component\Assertion\Inspector;
 use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\Unicode;
-use Drupal\Component\Utility\Xss;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Url;
-use Drupal\project_browser\ActivationStatus;
 use Drupal\project_browser\ProjectType;
 
 /**
  * Defines a single Project.
  */
-class Project implements \JsonSerializable {
+class Project {
 
   /**
    * A persistent ID for this project in non-volatile storage.
@@ -23,26 +21,6 @@ class Project implements \JsonSerializable {
    */
   public readonly string $id;
 
-  /**
-   * The status of this project in the current site.
-   *
-   * This property is internal and should be ignored by source plugins.
-   *
-   * @var \Drupal\project_browser\ActivationStatus
-   */
-  public ActivationStatus $status;
-
-  /**
-   * The instructions, if any, to activate this project.
-   *
-   * This property is internal and should be ignored by source plugins.
-   *
-   * @var string|\Drupal\Core\Url|null
-   *
-   * @see \Drupal\project_browser\ActivatorInterface::getInstructions()
-   */
-  public string|Url|null $commands = NULL;
-
   /**
    * The project type (e.g., module, theme, recipe, or something else).
    *
@@ -158,27 +136,12 @@ class Project implements \JsonSerializable {
   }
 
   /**
-   * Returns the selector id of the project.
+   * Returns a JSON-serializable array representation of this object.
    *
-   * @return string
-   *   Selector id of the project.
+   * @return array
+   *   This project, represented as a JSON-serializable array.
    */
-  public function getSelectorId(): string {
-    return str_replace('_', '-', $this->machineName);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function jsonSerialize(): array {
-    $commands = $this->commands;
-    if ($commands instanceof Url) {
-      $commands = $commands->setAbsolute()->toString();
-    }
-    elseif (is_string($commands)) {
-      $commands = Xss::filter($commands, [...Xss::getAdminTagList(), 'textarea', 'button']);
-    }
-
+  public function toArray(): array {
     if ($this->logo) {
       $logo = [
         'file' => $this->logo->setAbsolute()->toString(),
@@ -209,13 +172,6 @@ class Project implements \JsonSerializable {
       'package_name' => $this->packageName,
       'is_maintained' => $this->isMaintained,
       'url' => $this->url?->setAbsolute()->toString(),
-      'status' => match ($this->status) {
-        ActivationStatus::Absent => 'absent',
-        ActivationStatus::Present => 'present',
-        ActivationStatus::Active => 'active',
-      },
-      'selector_id' => $this->getSelectorId(),
-      'commands' => $commands,
       'id' => $this->id,
     ];
   }
diff --git a/src/ProjectBrowser/ProjectsResultsPage.php b/src/ProjectBrowser/ProjectsResultsPage.php
index 7e89a60d5118a6abab3c24f1dd33f4e54278aa4e..cf8ac24d3c0cac49ba1cd548255cabf5ec758a74 100644
--- a/src/ProjectBrowser/ProjectsResultsPage.php
+++ b/src/ProjectBrowser/ProjectsResultsPage.php
@@ -7,7 +7,7 @@ use Drupal\Component\Assertion\Inspector;
 /**
  * One page of search results from a query.
  */
-class ProjectsResultsPage implements \JsonSerializable {
+class ProjectsResultsPage {
 
   /**
    * Constructor for project browser results page.
@@ -20,7 +20,7 @@ class ProjectsResultsPage implements \JsonSerializable {
    *   The source plugin's label.
    * @param string $pluginId
    *   The source plugin's ID.
-   * @param string $error
+   * @param string|null $error
    *   (optional) Error to pass along, if any.
    */
   public function __construct(
@@ -35,19 +35,13 @@ class ProjectsResultsPage implements \JsonSerializable {
   }
 
   /**
-   * {@inheritdoc}
+   * Returns the contents of this object as an array.
+   *
+   * @return array
+   *   The contents of this object, as an array.
    */
-  public function jsonSerialize(): array {
-    // Fully qualify the project IDs before sending them to the front end.
-    $map = function (Project $project): array {
-      return [
-        'id' => $this->pluginId . '/' . $project->id,
-      ] + $project->jsonSerialize();
-    };
-
-    return [
-      'list' => array_map($map, $this->list),
-    ] + get_object_vars($this);
+  public function toArray(): array {
+    return get_object_vars($this);
   }
 
 }
diff --git a/src/ProjectBrowserServiceProvider.php b/src/ProjectBrowserServiceProvider.php
index 1dcd23e1d11eff1b86d7a8d5da97ccabec0fa513..c833a3c1fb5138cb47f4ca3d0bfdf790fbb4e641 100644
--- a/src/ProjectBrowserServiceProvider.php
+++ b/src/ProjectBrowserServiceProvider.php
@@ -15,6 +15,7 @@ use Drupal\Core\Queue\QueueInterface;
 use Drupal\Core\Recipe\Recipe;
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
+use Drupal\project_browser\Activator\RecipeActivator;
 use Drupal\project_browser\ComposerInstaller\Installer;
 use Drupal\project_browser\ComposerInstaller\Validator\CoreNotUpdatedValidator;
 use Drupal\project_browser\ComposerInstaller\Validator\PackageNotInstalledValidator;
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 5656c28daa3457edcd294fd28336dd471e92adb7..0b6118d0c128908300561b037ce95acec768d642 100644
--- a/tests/modules/project_browser_test/project_browser_test.services.yml
+++ b/tests/modules/project_browser_test/project_browser_test.services.yml
@@ -11,9 +11,8 @@ services:
   Drupal\project_browser_test\TestActivator:
     autowire: true
     public: false
-    decorates: 'Drupal\project_browser\ActivatorInterface'
-    arguments:
-      - '@.inner'
+    tags:
+      - { name: project_browser.activator, priority: 1000 }
   Drupal\project_browser_test\Extension\TestModuleInstaller:
     decorates: 'module_installer'
     public: false
diff --git a/tests/modules/project_browser_test/src/TestActivator.php b/tests/modules/project_browser_test/src/TestActivator.php
index 4cebb7ef6e2c0f3a8de97730a867b7a113b554a3..f10a5e9cde147c10e39c5550de02c1c643598af2 100644
--- a/tests/modules/project_browser_test/src/TestActivator.php
+++ b/tests/modules/project_browser_test/src/TestActivator.php
@@ -5,11 +5,9 @@ declare(strict_types=1);
 namespace Drupal\project_browser_test;
 
 use Drupal\Core\State\StateInterface;
-use Drupal\Core\Url;
-use Drupal\project_browser\ActivationStatus;
-use Drupal\project_browser\ActivatorInterface;
+use Drupal\project_browser\Activator\ActivationStatus;
+use Drupal\project_browser\Activator\ActivatorInterface;
 use Drupal\project_browser\ProjectBrowser\Project;
-use Symfony\Component\HttpFoundation\Response;
 
 /**
  * A test activator that simply logs a state message.
@@ -17,15 +15,25 @@ use Symfony\Component\HttpFoundation\Response;
 class TestActivator implements ActivatorInterface {
 
   public function __construct(
-    private readonly ActivatorInterface $decorated,
     private readonly StateInterface $state,
   ) {}
 
+  /**
+   * Sets the projects which will be handled this by activator.
+   *
+   * @param string ...$projects
+   *   The Composer package names of the projects to handle.
+   */
+  public static function handle(string ...$projects): void {
+    \Drupal::state()->set('test activator will handle', $projects);
+  }
+
   /**
    * {@inheritdoc}
    */
   public function supports(Project $project): bool {
-    return $this->decorated->supports($project);
+    $will_handle = $this->state->get('test activator will handle', []);
+    return in_array($project->packageName, $will_handle, TRUE);
   }
 
   /**
@@ -35,24 +43,17 @@ class TestActivator implements ActivatorInterface {
     if ($project->machineName === 'pinky_brain') {
       return ActivationStatus::Present;
     }
-    return $this->decorated->getStatus($project);
+    return ActivationStatus::Absent;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function activate(Project $project): ?Response {
+  public function activate(Project $project): null {
     $log_message = $this->state->get("test activator", []);
     $log_message[] = "$project->title was activated!";
     $this->state->set("test activator", $log_message);
-    return $this->decorated->activate($project);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getInstructions(Project $project): string|Url|null {
-    return $this->decorated->getInstructions($project);
+    return NULL;
   }
 
 }
diff --git a/tests/src/Functional/EnabledSourceHandlerTest.php b/tests/src/Functional/EnabledSourceHandlerTest.php
index 6942a41c71ad2ab497bfe0d4c79a96fceefc307d..f0b9ce8b49c9d5bb7f0fb04dd55e6b6e04ac86c9 100644
--- a/tests/src/Functional/EnabledSourceHandlerTest.php
+++ b/tests/src/Functional/EnabledSourceHandlerTest.php
@@ -4,9 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\Tests\project_browser\Functional;
 
-use Drupal\project_browser\ActivationStatus;
 use Drupal\project_browser\EnabledSourceHandler;
-use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser_test\Plugin\ProjectBrowserSource\ProjectBrowserTestMock;
 use Drupal\Tests\BrowserTestBase;
 
@@ -58,14 +56,7 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
 
     $project_again = $handler->getStoredProject('project_browser_test_mock/' . $project->id);
     $this->assertNotSame($project, $project_again);
-    // Project::$status is a typed property and therefore must be initialized
-    // before it is accessed by jsonSerialize().
-    $project->status = ActivationStatus::Active;
-    $project_again->status = ActivationStatus::Active;
-    $this->assertSame($project->jsonSerialize(), $project_again->jsonSerialize());
-
-    // The activation status and commands should be set.
-    $this->assertTrue(self::hasActivationData($project_again));
+    $this->assertSame($project->toArray(), $project_again->toArray());
   }
 
   /**
@@ -91,22 +82,6 @@ class EnabledSourceHandlerTest extends BrowserTestBase {
     $this->assertFalse($storage->has($query_cache_key));
   }
 
-  /**
-   * Checks if a project object is carrying activation data.
-   *
-   * @param \Drupal\project_browser\ProjectBrowser\Project $project
-   *   The project object.
-   *
-   * @return bool
-   *   TRUE if the project has its activation status and commands set, FALSE
-   *   otherwise.
-   */
-  private static function hasActivationData(Project $project): bool {
-    $status = new \ReflectionProperty(Project::class, 'status');
-    $commands = new \ReflectionProperty(Project::class, 'commands');
-    return $status->isInitialized($project) && $commands->isInitialized($project);
-  }
-
   /**
    * Tests that the install profile is ignored by the drupal_core source.
    */
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
index 2f2416272b5c29d9fd60c9723a75fe51ba9cf094..78cef31cc9e9a8289982d5687fc39a081c5baa2c 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserInstallerUiTest.php
@@ -10,6 +10,7 @@ use Drupal\Core\State\StateInterface;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
 use Drupal\project_browser\EnabledSourceHandler;
 use Drupal\project_browser\InstallState;
+use Drupal\project_browser_test\TestActivator;
 use Drupal\system\SystemManager;
 use Drupal\Tests\project_browser\Traits\PackageManagerFixtureUtilityTrait;
 
@@ -76,6 +77,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * Tests the "select" button functionality.
    */
   public function testSingleModuleAddAndInstall(): void {
+    TestActivator::handle('drupal/cream_cheese');
+
     $assert_session = $this->assertSession();
     $page = $this->getSession()->getPage();
     $this->drupalGet('admin/modules/browse/project_browser_test_mock');
@@ -365,6 +368,8 @@ class ProjectBrowserInstallerUiTest extends WebDriverTestBase {
    * Tests the "Install selected projects" button functionality.
    */
   public function testMultipleModuleAddAndInstall(): void {
+    TestActivator::handle('drupal/cream_cheese', 'drupal/kangaroo');
+
     $page = $this->getSession()->getPage();
     $assert_session = $this->assertSession();
     $this->drupalGet('project-browser/project_browser_test_mock');
diff --git a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
index d8b23783aefb92d437a25a1ae21da69d66c71ce6..805c0cb107860e01cd8b9f270c6f076b67b34e59 100644
--- a/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
+++ b/tests/src/FunctionalJavascript/ProjectBrowserUiTest.php
@@ -1038,7 +1038,7 @@ class ProjectBrowserUiTest extends WebDriverTestBase {
 
     $href = $install_link?->getAttribute('href');
     $this->assertIsString($href);
-    $this->assertStringContainsString('admin/modules#module-inline-form-errors', $href);
+    $this->assertStringEndsWith('/admin/modules#module-inline-form-errors', $href);
     $this->drupalGet($href);
     $assert_session->waitForElementVisible('css', "#edit-modules-inline-form-errors-enable");
     $assert_session->assertVisibleInViewport('css', '#edit-modules-inline-form-errors-enable');
diff --git a/tests/src/Kernel/RecipeActivatorTest.php b/tests/src/Kernel/RecipeActivatorTest.php
index cf14f1a57ebb7b61935e7c66bcf94555b9ffd0ea..bc9c12547c39187cb4d6d0b942d6096cb558f5c0 100644
--- a/tests/src/Kernel/RecipeActivatorTest.php
+++ b/tests/src/Kernel/RecipeActivatorTest.php
@@ -8,8 +8,8 @@ use Drupal\Core\Recipe\Recipe;
 use Drupal\Core\Recipe\RecipeRunner;
 use Drupal\Core\State\StateInterface;
 use Drupal\KernelTests\KernelTestBase;
-use Drupal\project_browser\ActivationStatus;
-use Drupal\project_browser\ActivatorInterface;
+use Drupal\project_browser\ActivationManager;
+use Drupal\project_browser\Activator\ActivationStatus;
 use Drupal\project_browser\ProjectBrowser\Project;
 use Drupal\project_browser\ProjectType;
 
@@ -17,7 +17,7 @@ use Drupal\project_browser\ProjectType;
  * Tests the recipe activator. Obviously.
  *
  * @group project_browser
- * @covers \Drupal\project_browser\RecipeActivator
+ * @covers \Drupal\project_browser\Activator\RecipeActivator
  */
 class RecipeActivatorTest extends KernelTestBase {
 
@@ -55,13 +55,13 @@ class RecipeActivatorTest extends KernelTestBase {
       packageName: 'My Project',
       type: ProjectType::Recipe,
     );
-    /** @var \Drupal\project_browser\ActivatorInterface $activator */
-    $activator = $this->container->get(ActivatorInterface::class);
+    /** @var \Drupal\project_browser\ActivationManager $activation_manager */
+    $activation_manager = $this->container->get(ActivationManager::class);
     // As this project is not installed the RecipeActivator::getPath() will
     // return NULL in RecipeActivator::getStatus() and it will return the
     // status as Absent.
-    // @see \Drupal\project_browser\RecipeActivator::getStatus()
-    $this->assertSame(ActivationStatus::Absent, $activator->getStatus($project));
+    // @see \Drupal\project_browser\Activator\RecipeActivator::getStatus()
+    $this->assertSame(ActivationStatus::Absent, $activation_manager->getStatus($project));
   }
 
 }