diff --git a/src/Controller/InstallerController.php b/src/Controller/InstallerController.php
index e17aebd360f30e0b23b75fb7577de41bc76701c0..6650d7d1c89202116cbb73d7a35b2862695c0e6b 100644
--- a/src/Controller/InstallerController.php
+++ b/src/Controller/InstallerController.php
@@ -361,8 +361,8 @@ final class InstallerController extends ControllerBase {
    *   Status message.
    */
   public function apply(string $stage_id): JsonResponse {
-    foreach (array_keys($this->installState->toArray()) as $project_id) {
-      $this->installState->setState($project_id, 'applying');
+    foreach ($this->installState->toArray() as $project) {
+      $this->installState->setState($project['project_id'], 'applying');
     }
     try {
       $this->installer->claim($stage_id)->apply();
diff --git a/src/EnabledSourceHandler.php b/src/EnabledSourceHandler.php
index 68a121ff321c2e94c714ffb31a754c9939c67bec..500f880bdc1526ec1a43ae76329e559fccfff218 100644
--- a/src/EnabledSourceHandler.php
+++ b/src/EnabledSourceHandler.php
@@ -143,14 +143,14 @@ final class EnabledSourceHandler implements LoggerAwareInterface, EventSubscribe
     // Cache all the projects individually so they can be loaded by
     // ::getStoredProject().
     foreach ($results->list as $project) {
-      $storage->setIfNotExists($project->id, $project);
+      $this->storeProject($source_id, $project);
     }
     // If there were no query errors, store the results as a set of arguments
     // to ProjectsResultsPage.
     if (empty($results->error)) {
       $storage->set($cache_key, [
         $results->totalResults,
-        array_column($results->list, 'id'),
+        array_map(Project::normalizeId(...), array_column($results->list, 'id')),
         $results->pluginLabel,
         $source_id,
         $results->error,
@@ -201,6 +201,19 @@ final class EnabledSourceHandler implements LoggerAwareInterface, EventSubscribe
     return $enabled_sources[$source_id]->getProjects($query);
   }
 
+  /**
+   * Store a project in the non-volatile data store.
+   *
+   * @param string $source_id
+   *   The ID of the source plugin to store the project in.
+   * @param \Drupal\project_browser\ProjectBrowser\Project $project
+   *   Project to store.
+   */
+  private function storeProject(string $source_id, Project $project): void {
+    $id = Project::normalizeId($project->id);
+    $this->keyValue($source_id)->setIfNotExists($id, $project);
+  }
+
   /**
    * Looks up a previously stored project by its ID.
    *
@@ -215,6 +228,7 @@ final class EnabledSourceHandler implements LoggerAwareInterface, EventSubscribe
    */
   public function getStoredProject(string $id): Project {
     [$source_id, $local_id] = explode('/', $id, 2);
+    $local_id = Project::normalizeId($local_id);
     return $this->keyValue($source_id)->get($local_id) ?? throw new \RuntimeException("Project '$id' was not found in non-volatile storage.");
   }
 
diff --git a/src/InstallState.php b/src/InstallState.php
index 9b0059b458f73dfccc9e1342d9dcbd810a9a6984..4f98b422e1b197725d91165a8a09c895b5e8bb40 100644
--- a/src/InstallState.php
+++ b/src/InstallState.php
@@ -75,25 +75,29 @@ final class InstallState {
    */
   public function setState(string $project_id, ?string $status): void {
     $this->keyValue->setIfNotExists('__timestamp', $this->time->getRequestTime());
+    $normalized_id = Project::normalizeId($project_id);
     if (is_string($status)) {
-      $this->keyValue->set($project_id, ['status' => $status]);
+      $this->keyValue->set($normalized_id, [
+        'status' => $status,
+        'project_id' => $project_id,
+      ]);
     }
     else {
-      $this->keyValue->delete($project_id);
+      $this->keyValue->delete($normalized_id);
     }
   }
 
   /**
    * Retrieves the install state of a project.
    *
-   * @param \Drupal\project_browser\ProjectBrowser\Project $project
-   *   The project object for which to retrieve the install state.
+   * @param string $project_id
+   *   The project ID to retrieve.
    *
    * @return string|null
    *   The current install status of the project, or NULL if not found.
    */
-  public function getStatus(Project $project): ?string {
-    $project_data = $this->keyValue->get($project->id);
+  public function getStatus(string $project_id): ?string {
+    $project_data = $this->keyValue->get(Project::normalizeId($project_id));
     return $project_data['status'] ?? NULL;
   }
 
diff --git a/src/ProjectBrowser/Project.php b/src/ProjectBrowser/Project.php
index 5931f3c32eae2c28767bb2d60aaed1b20891f593..8da2231b9a4a7fbcf21ebb5570019c36601ad490 100644
--- a/src/ProjectBrowser/Project.php
+++ b/src/ProjectBrowser/Project.php
@@ -121,6 +121,23 @@ final class Project {
     $this->id = $id;
   }
 
+  /**
+   * Normalizes a project ID for database storage.
+   *
+   * Ensures the ID is no more than 104 characters long, so that it can fit into
+   * a `varchar` database column. We append a partial hash of the original,
+   * full-length ID in order to guarantee uniqueness.
+   *
+   * @param string $id
+   *   A project ID to normalize.
+   *
+   * @return string
+   *   The normalized project ID.
+   */
+  public static function normalizeId(string $id): string {
+    return substr($id, 0, 96) . '-' . substr(sha1($id), 0, 8);
+  }
+
   /**
    * Set the project short description.
    *
diff --git a/tests/src/Functional/InstallerControllerTest.php b/tests/src/Functional/InstallerControllerTest.php
index 462c2d58563fe0a5e03558ced323355e112f1025..a5551f28d766cf41afc3396a2a3d0acce48c1f2b 100644
--- a/tests/src/Functional/InstallerControllerTest.php
+++ b/tests/src/Functional/InstallerControllerTest.php
@@ -444,13 +444,7 @@ final class InstallerControllerTest extends BrowserTestBase {
     $response = $this->drupalGet('admin/modules/project_browser/install-begin', $request_options);
     $this->assertSession()->statusCodeEquals(418);
     $assert_unlock_response($response, "The process for adding the project that was locked less than a minute ago might still be in progress. Consider waiting a few more minutes before using [+unlock link].");
-    $expected = [
-      'project_browser_test_mock/awesome_module' => [
-        'status' => 'requiring',
-      ],
-    ];
-    $install_state = $this->container->get(InstallState::class)->toArray();
-    $this->assertSame($expected, $install_state);
+    $this->assertInstallInProgress('project_browser_test_mock/awesome_module', 'requiring');
     $this->assertFalse($this->installer->isAvailable());
     $this->assertFalse($this->installer->isApplying());
 
@@ -591,16 +585,14 @@ final class InstallerControllerTest extends BrowserTestBase {
    *
    * @param string $project_id
    *   The ID of the project being enabled.
-   * @param string|null $status
+   * @param string|null $expected_status
    *   The install state.
    */
-  protected function assertInstallInProgress(string $project_id, ?string $status = NULL): void {
-    $expect_install[$project_id] = [
-      'status' => $status,
-    ];
-    $install_state = $this->container->get(InstallState::class)
-      ->toArray();
-    $this->assertSame($expect_install, $install_state);
+  protected function assertInstallInProgress(string $project_id, ?string $expected_status = NULL): void {
+    $this->assertSame(
+      $expected_status,
+      $this->container->get(InstallState::class)->getStatus($project_id),
+    );
   }
 
   /**