diff --git a/composer.json b/composer.json
index 6932f9c2bb9a7aeb122b807ddc5cdb14a9eb87bb..a37fef9e3ec59f8287eba99ebbaf0cf50582e686 100644
--- a/composer.json
+++ b/composer.json
@@ -3,11 +3,10 @@
     "description": "This module adds a trash bin for all content entities.",
     "type": "drupal-module",
     "license": "GPL-2.0-or-later",
-    "extra": {
-        "drush": {
-            "services": {
-                "drush.services.yml": ">=9"
-            }
-        }
+    "suggest": {
+        "drush/drush": "^12 || ^13"
+    },
+    "conflict": {
+        "drush/drush": "<12.5.1"
     }
 }
diff --git a/config/install/trash.settings.yml b/config/install/trash.settings.yml
index dbc36e88826bc26288d68ec624ce3fd31fd6c73a..4dc4a311b655bc2da715f11b45d56e0fe69bc121 100644
--- a/config/install/trash.settings.yml
+++ b/config/install/trash.settings.yml
@@ -1,5 +1,5 @@
-enabled_entity_types:
-  node: {  }
+enabled_entity_types: {  }
 auto_purge:
   enabled: false
   after: '30 days'
+compact_overview: false
diff --git a/config/schema/trash.schema.yml b/config/schema/trash.schema.yml
index 1466373e9474db7b18e19a3091af1ca704aff0f2..8c80f7b2369cd7c8bfba095cdf59277484fe0fbb 100644
--- a/config/schema/trash.schema.yml
+++ b/config/schema/trash.schema.yml
@@ -23,3 +23,6 @@ trash.settings:
           nullable: true
           constraints:
             ValidAutoPurgePeriod: []
+    compact_overview:
+      label: 'Simplify the Trash overview page when there are many entity types enabled'
+      type: boolean
diff --git a/css/trash.admin.css b/css/trash.admin.css
new file mode 100644
index 0000000000000000000000000000000000000000..d803f9fb4f80af30cc7f0684a068eea6f78e5164
--- /dev/null
+++ b/css/trash.admin.css
@@ -0,0 +1,15 @@
+/**
+ * @file
+ * Visual styles for the Trash Admin UI.
+ */
+
+/**
+ * Indent bundle selection.
+ */
+.trash--bundles.fieldset {
+  margin-left: 2em; /* LTR */
+}
+[dir="rtl"] .trash--bundles.fieldset {
+  margin-right: 2em;
+  margin-left: 0;
+}
diff --git a/drush.services.yml b/drush.services.yml
deleted file mode 100644
index fd803c2d5fd6e4e80cdedeb832d722de3f073f63..0000000000000000000000000000000000000000
--- a/drush.services.yml
+++ /dev/null
@@ -1,7 +0,0 @@
-services:
-  trash.commands:
-    class: Drupal\trash\Commands\TrashCommands
-    arguments:
-      - '@entity_type.manager'
-      - '@trash.manager'
-    tags: [{ name: drush.command }]
diff --git a/js/trash.js b/js/trash.js
new file mode 100644
index 0000000000000000000000000000000000000000..a86b5fb1f214780378d7d7a52623d1f498b24f53
--- /dev/null
+++ b/js/trash.js
@@ -0,0 +1,22 @@
+/**
+ * @file
+ * Javascript functionality for the Trash overview page.
+ */
+
+(function (Drupal) {
+  /**
+   * Entity type selector on the Trash overview page.
+   */
+  Drupal.behaviors.trashSelectEntityType = {
+    attach(context) {
+      once("trash-select-entity-type", ".trash-entity-type", context).forEach(
+        (trigger) => {
+          trigger.addEventListener('change', (e) => {
+            window.location = Drupal.url('admin/content/trash/' + e.target.value);
+          });
+        },
+      );
+    },
+  };
+
+})(Drupal);
diff --git a/phpstan.neon b/phpstan.neon
index e8a1d7135359dd807f03f36bdd152cb4946fb966..01870d95f1dd366a1df2b358c8eecc6932eecc60 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -12,5 +12,9 @@ parameters:
   ignoreErrors:
     # new static() is a best practice in Drupal, so we cannot fix that.
     - "#^Unsafe usage of new static#"
+    # Ignore a missing event class until we can require Drupal 11.1.
+    -
+      message: '#^Parameter \$event of method Drupal\\trash\\EventSubscriber\\TrashIgnoreSubscriber\:\:onDefaultContentPreImport\(\) has invalid type Drupal\\Core\\DefaultContent\\PreImportEvent\.$#'
+      reportUnmatched: false
   scanDirectories:
     - ../../../../vendor/drush/drush/src-symfony-compatibility
diff --git a/src/Commands/TrashCommands.php b/src/Commands/TrashCommands.php
deleted file mode 100644
index 14af7e284300adcc9333787204b60d6d0efa0b7a..0000000000000000000000000000000000000000
--- a/src/Commands/TrashCommands.php
+++ /dev/null
@@ -1,75 +0,0 @@
-<?php
-
-namespace Drupal\trash\Commands;
-
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Site\Settings;
-use Drupal\trash\TrashManagerInterface;
-use Drush\Commands\DrushCommands;
-
-/**
- * Drush commands for the Trash module.
- */
-class TrashCommands extends DrushCommands {
-
-  public function __construct(
-    protected EntityTypeManagerInterface $entityTypeManager,
-    protected TrashManagerInterface $trashManager,
-  ) {
-    parent::__construct();
-  }
-
-  /**
-   * Purges all trashed entities.
-   *
-   * @param string|null $entityTypeId
-   *   The entity type to purge.
-   *
-   * @command trash:purge
-   * @aliases tp
-   */
-  public function purge(?string $entityTypeId = NULL): void {
-    if (is_string($entityTypeId)) {
-      $entityTypeIds = [$entityTypeId];
-    }
-    else {
-      $entityTypeIds = $this->trashManager->getEnabledEntityTypes();
-    }
-
-    $deleteCount = 0;
-    foreach ($entityTypeIds as $entityTypeId) {
-      $storage = $this->entityTypeManager->getStorage($entityTypeId);
-      $ids = $storage->getQuery()
-        ->accessCheck(FALSE)
-        ->addMetaData('trash', 'inactive')
-        ->exists('deleted')
-        ->execute();
-
-      if ($ids === []) {
-        continue;
-      }
-
-      $this->io()->progressStart(count($ids));
-      $chunkSize = Settings::get('entity_update_batch_size', 50);
-
-      foreach (array_chunk($ids, $chunkSize) as $chunk) {
-        $this->trashManager->executeInTrashContext('inactive', function () use (&$deleteCount, $storage, $chunk) {
-          $entities = $storage->loadMultiple($chunk);
-          $storage->delete($entities);
-          $deleteCount += count($entities);
-          $this->io()->progressAdvance(count($chunk));
-        });
-      }
-
-      $this->io()->progressFinish();
-    }
-
-    if ($deleteCount > 0) {
-      $this->io()->success(sprintf('Purged %d trashed entities.', $deleteCount));
-    }
-    else {
-      $this->io()->success('No trashed entities to purge.');
-    }
-  }
-
-}
diff --git a/src/Controller/TrashController.php b/src/Controller/TrashController.php
index 80070506f126ac32accef9e887c9126b2c66ed85..2489cf042cf533c25484f4b38c2f24ed61394361 100644
--- a/src/Controller/TrashController.php
+++ b/src/Controller/TrashController.php
@@ -1,10 +1,13 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\Controller;
 
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Datetime\DateFormatterInterface;
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityListBuilder;
 use Drupal\Core\Entity\EntityPublishedInterface;
 use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
@@ -27,16 +30,6 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf
    */
   protected bool $isMultilingual = FALSE;
 
-  /**
-   * Constructs the Trash controller.
-   *
-   * @param \Drupal\trash\TrashManagerInterface $trashManager
-   *   The trash manager.
-   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundleInfo
-   *   The entity type bundle info service.
-   * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter
-   *   The date formatter service.
-   */
   public function __construct(
     protected TrashManagerInterface $trashManager,
     protected EntityTypeBundleInfoInterface $bundleInfo,
@@ -48,7 +41,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf
   /**
    * {@inheritdoc}
    */
-  public static function create(ContainerInterface $container) {
+  public static function create(ContainerInterface $container): static {
     return new static(
       $container->get('trash.manager'),
       $container->get('entity_type.bundle.info'),
@@ -96,6 +89,22 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf
    *   \Drupal\Core\Render\RendererInterface::render().
    */
   protected function render(string $entity_type_id): array {
+    $options = [];
+    foreach ($this->trashManager->getEnabledEntityTypes() as $id) {
+      $options[$id] = (string) $this->entityTypeManager()->getDefinition($id)->getLabel();
+    }
+    $build['entity_type_id'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Entity type'),
+      '#options' => $options,
+      '#sort_options' => TRUE,
+      '#value' => $entity_type_id,
+      '#attributes' => [
+        'class' => ['trash-entity-type'],
+      ],
+      '#access' => (bool) $this->config('trash.settings')->get('compact_overview'),
+    ];
+
     $entity_type = $this->entityTypeManager()->getDefinition($entity_type_id);
     if ($this->isMultilingual && $entity_type->isTranslatable()) {
       $build['intro'] = [
@@ -145,6 +154,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf
     $build['pager'] = [
       '#type' => 'pager',
     ];
+    $build['#attached']['library'][] = 'trash/trash.admin';
 
     return $build;
   }
@@ -163,7 +173,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf
     $storage = $this->entityTypeManager()->getStorage($entity_type->id());
     $entity_ids = $storage->getQuery()
       ->accessCheck(TRUE)
-      ->sort($entity_type->getKey('id'))
+      ->sort('deleted', 'DESC')
       ->pager(50)
       ->execute();
     return $storage->loadMultiple($entity_ids);
@@ -212,7 +222,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf
    */
   protected function buildRow(FieldableEntityInterface $entity, array $url_options): array {
     $entity_type = $entity->getEntityType();
-    if ($entity_type->getLinkTemplate('canonical') != $entity_type->getLinkTemplate('edit-form')) {
+    if ($entity_type->getLinkTemplate('canonical') != $entity_type->getLinkTemplate('edit-form') && $entity->access('view')) {
       $row['label']['data'] = [
         '#type' => 'link',
         '#title' => $entity->label(),
@@ -266,62 +276,20 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf
       }
     }
 
-    $links = self::getOperations($entity, $url_options);
+    $list_builder = $this->entityTypeManager->hasHandler($entity_type->id(), 'list_builder')
+      ? $this->entityTypeManager->getListBuilder($entity_type->id())
+      : $this->entityTypeManager->createHandlerInstance(EntityListBuilder::class, $entity_type);
 
     $row['operations']['data'] = [
       '#type' => 'operations',
-      '#links' => $links,
+      '#links' => $list_builder->getOperations($entity) ?? [],
+      // Allow links to use modals.
+      '#attached' => [
+        'library' => ['core/drupal.dialog.ajax'],
+      ],
     ];
 
     return $row;
   }
 
-  /**
-   * Get operation links for a deleted entity.
-   *
-   * @param \Drupal\Core\Entity\FieldableEntityInterface $entity
-   *   An entity.
-   * @param array $url_options
-   *   URL options such as language or query parameter. Defaults to adding the
-   *   'language' parameter from the current interface language.
-   *
-   * @return array{string: array{
-   *   title: \Drupal\Core\StringTranslation\TranslatableMarkup,
-   *   url: \Drupal\Core\Url,
-   *   weight: int
-   *   }}
-   *   An associative array with the purge & restore operation links.
-   */
-  public static function getOperations(FieldableEntityInterface $entity, array $url_options = []): array {
-    if (!isset($url_options['language'])) {
-      $url_options['language'] = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE);
-    }
-    if ($entity instanceof TranslatableInterface && !$entity->isDefaultTranslation()) {
-      $restore_route = $entity->toUrl('restore-translation', $url_options)->setRouteParameter('language', $entity->language()->getId());
-      $purge_route = $entity->toUrl('purge-translation', $url_options)->setRouteParameter('language', $entity->language()->getId());
-    }
-    else {
-      $restore_route = $entity->toUrl('restore', $url_options);
-      $purge_route = $entity->toUrl('purge', $url_options);
-    }
-
-    $links = [];
-    // @todo Can remove access checks if #2473873 is committed.
-    if ($restore_route->access()) {
-      $links['restore'] = [
-        'title' => t('Restore'),
-        'url' => $restore_route,
-        'weight' => 0,
-      ];
-    }
-    if ($purge_route->access()) {
-      $links['purge'] = [
-        'title' => t('Purge'),
-        'url' => $purge_route,
-        'weight' => 5,
-      ];
-    }
-    return $links;
-  }
-
 }
diff --git a/src/Drush/Commands/TrashCommands.php b/src/Drush/Commands/TrashCommands.php
new file mode 100644
index 0000000000000000000000000000000000000000..12cf3b64ac3b2090792556a1eb9b31d28a771dcd
--- /dev/null
+++ b/src/Drush/Commands/TrashCommands.php
@@ -0,0 +1,223 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\Drush\Commands;
+
+use Consolidation\AnnotatedCommand\Hooks\HookManager;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Site\Settings;
+use Drupal\trash\TrashManagerInterface;
+use Drush\Attributes as CLI;
+use Drush\Commands\AutowireTrait;
+use Drush\Commands\DrushCommands;
+use Drush\Exceptions\CommandFailedException;
+use Drush\Exceptions\UserAbortException;
+use Drush\Utils\StringUtils;
+use Symfony\Component\Console\Input\Input;
+
+/**
+ * Drush commands for the Trash module.
+ */
+final class TrashCommands extends DrushCommands {
+
+  use AutowireTrait;
+
+  public function __construct(
+    protected EntityTypeManagerInterface $entityTypeManager,
+    protected TrashManagerInterface $trashManager,
+  ) {
+    parent::__construct();
+  }
+
+  /**
+   * Restores trashed entities.
+   */
+  #[CLI\Command(name: 'trash:restore', aliases: ['tr'])]
+  #[CLI\Argument(name: 'entity_type_id', description: 'The entity type to restore.')]
+  #[CLI\Argument(name: 'entity_ids', description: 'A comma-separated list of entity IDs to restore.')]
+  #[CLI\Option(name: 'all', description: 'Restore data for all entity types.')]
+  public function restore(?string $entity_type_id = NULL, $entity_ids = NULL, array $options = ['all' => FALSE]): void {
+    $entity_ids = StringUtils::csvToArray($entity_ids);
+    $this->getConfirmation('restore', $entity_type_id, $entity_ids, $options);
+
+    if ($options['all']) {
+      $entity_type_ids = $this->trashManager->getEnabledEntityTypes();
+      $entity_ids = NULL;
+    }
+    else {
+      $entity_type_ids = [$entity_type_id];
+    }
+
+    $count = $this->performOperation('restore', $entity_type_ids, $entity_ids);
+
+    if ($count > 0) {
+      $this->io()->success(dt('Restored @count trashed entities.', ['@count' => $count]));
+    }
+    else {
+      $this->io()->success(dt('No trashed entities to restore.'));
+    }
+  }
+
+  /**
+   * Purges trashed entities.
+   */
+  #[CLI\Command(name: 'trash:purge', aliases: ['tp'])]
+  #[CLI\Argument(name: 'entity_type_id', description: 'The entity type to purge.')]
+  #[CLI\Argument(name: 'entity_ids', description: 'A comma-separated list of entity IDs to purge.')]
+  #[CLI\Option(name: 'all', description: 'Purge data for all entity types.')]
+  public function purge(?string $entity_type_id = NULL, $entity_ids = NULL, array $options = ['all' => FALSE]): void {
+    $entity_ids = StringUtils::csvToArray($entity_ids);
+    $this->getConfirmation('purge', $entity_type_id, $entity_ids, $options);
+
+    if ($options['all']) {
+      $entity_type_ids = $this->trashManager->getEnabledEntityTypes();
+      $entity_ids = NULL;
+    }
+    else {
+      $entity_type_ids = [$entity_type_id];
+    }
+
+    $count = $this->performOperation('purge', $entity_type_ids, $entity_ids);
+
+    if ($count > 0) {
+      $this->io()->success(dt('Purged @count trashed entities.', ['@count' => $count]));
+    }
+    else {
+      $this->io()->success(dt('No trashed entities to purge.'));
+    }
+  }
+
+  /**
+   * Asks the user to select an entity type.
+   */
+  #[CLI\Hook(type: HookManager::INTERACT, target: 'trash:restore')]
+  public function hookInteractRestore(Input $input): void {
+    if (!$input->getArgument('entity_type_id') && !$input->getOption('all')) {
+      $entity_type_ids = $this->trashManager->getEnabledEntityTypes();
+
+      if ($entity_type_ids !== []) {
+        if (!$choice = $this->io()->select('Select the entity type you want to restore.', array_combine($entity_type_ids, $entity_type_ids))) {
+          throw new UserAbortException();
+        }
+
+        $input->setArgument('entity_type_id', $choice);
+      }
+      else {
+        throw new CommandFailedException(dt('No entity types enabled.'));
+      }
+    }
+  }
+
+  /**
+   * Asks the user to select an entity type.
+   */
+  #[CLI\Hook(type: HookManager::INTERACT, target: 'trash:purge')]
+  public function hookInteractPurge(Input $input): void {
+    if (!$input->getArgument('entity_type_id') && !$input->getOption('all')) {
+      $entity_type_ids = $this->trashManager->getEnabledEntityTypes();
+
+      if ($entity_type_ids !== []) {
+        if (!$choice = $this->io()->select('Select the entity type you want to purge.', array_combine($entity_type_ids, $entity_type_ids))) {
+          throw new UserAbortException();
+        }
+
+        $input->setArgument('entity_type_id', $choice);
+      }
+      else {
+        throw new CommandFailedException(dt('No entity types enabled.'));
+      }
+    }
+  }
+
+  /**
+   * Prompts the user to confirm the command arguments.
+   */
+  protected function getConfirmation($operation, ?string $entity_type_id = NULL, ?array $entity_ids = NULL, array $options = ['all' => FALSE]): void {
+    if ($options['all']) {
+      if (!$this->io()->confirm(dt('Are you sure you want to @operation all data for all entity types?', [
+        '@operation' => $operation,
+      ]))) {
+        throw new UserAbortException();
+      }
+    }
+    else {
+      if (!$entity_ids) {
+        if (!$this->io()->confirm(dt('Are you sure you want to @operation all data for the @entity_type_id entity type?', [
+          '@operation' => $operation,
+          '@entity_type_id' => $entity_type_id,
+        ]))) {
+          throw new UserAbortException();
+        }
+      }
+      else {
+        if (!$this->io()->confirm(dt('Are you sure you want to @operation @entity_type_id @entity_ids?', [
+          '@operation' => $operation,
+          '@entity_type_id' => $entity_type_id,
+          '@entity_ids' => implode(', ', $entity_ids),
+        ]))) {
+          throw new UserAbortException();
+        }
+      }
+    }
+  }
+
+  /**
+   * Performs a restore or purge operation on the given arguments.
+   *
+   * @param string $operation
+   *   The operation to perform, either 'restore' or 'purge'.
+   * @param array $entity_type_ids
+   *   An array of entity type IDs.
+   * @param array|null $entity_ids
+   *   An array of entity IDs.
+   *
+   * @return int
+   *   Returns the number of entities on which the operation was performed.
+   */
+  protected function performOperation(string $operation, array $entity_type_ids, ?array $entity_ids = NULL): int {
+    assert(in_array($operation, ['restore', 'purge'], TRUE));
+
+    $count = 0;
+    foreach ($entity_type_ids as $entity_type_id) {
+      $storage = $this->entityTypeManager->getStorage($entity_type_id);
+      $query = $storage->getQuery()
+        ->accessCheck(FALSE)
+        ->addMetaData('trash', 'inactive')
+        ->exists('deleted');
+
+      if (count($entity_type_ids) === 1 && $entity_ids) {
+        $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+        $query->condition($entity_type->getKey('id'), $entity_ids, 'IN');
+      }
+
+      $ids = $query->execute();
+      if ($ids === []) {
+        continue;
+      }
+
+      $this->io()->progressStart(count($ids));
+      $chunkSize = Settings::get('entity_update_batch_size', 50);
+
+      foreach (array_chunk($ids, $chunkSize) as $chunk) {
+        $this->trashManager->executeInTrashContext('inactive', function () use (&$count, $storage, $chunk, $operation) {
+          $entities = $storage->loadMultiple($chunk);
+          if ($operation === 'restore') {
+            // @phpstan-ignore-next-line
+            $storage->restoreFromTrash($entities);
+          }
+          elseif ($operation === 'purge') {
+            $storage->delete($entities);
+          }
+          $count += count($entities);
+          $this->io()->progressAdvance(count($chunk));
+        });
+      }
+
+      $this->io()->progressFinish();
+    }
+
+    return $count;
+  }
+
+}
diff --git a/src/EntityHandler/TrashNodeAccessControlHandler.php b/src/EntityHandler/TrashNodeAccessControlHandler.php
index 70ee993ad3de7b71b91286cea9c4f57fc3fd8152..b7e46b2ead5de40c3e9e1c4b5442371a3ee102ad 100644
--- a/src/EntityHandler/TrashNodeAccessControlHandler.php
+++ b/src/EntityHandler/TrashNodeAccessControlHandler.php
@@ -2,7 +2,6 @@
 
 namespace Drupal\trash\EntityHandler;
 
-use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\node\NodeAccessControlHandler;
@@ -24,16 +23,15 @@ class TrashNodeAccessControlHandler extends NodeAccessControlHandler {
 
     // Invoke the trash access checker before the 'bypass node access'
     // permission is checked by the parent implementation.
-    $trash_access = trash_entity_access($entity, $operation, $account);
-    if ($trash_access->isForbidden()) {
-      return $return_as_object ? $trash_access : $trash_access->isAllowed();
-    }
+    $return = trash_entity_access($entity, $operation, $account);
 
-    $result = parent::access($entity, $operation, $account, TRUE);
-    assert($result instanceof AccessResult);
-    $result->cachePerPermissions();
+    // Also execute the default access check except when the access result is
+    // already forbidden, as in that case, it can not be anything else.
+    if (!$return->isForbidden()) {
+      $return = $return->orIf(parent::access($entity, $operation, $account, TRUE));
+    }
 
-    return $return_as_object ? $result : $result->isAllowed();
+    return $return_as_object ? $return : $return->isAllowed();
   }
 
 }
diff --git a/src/EventSubscriber/TrashConfigSubscriber.php b/src/EventSubscriber/TrashConfigSubscriber.php
index 125534b46f20b0e4d66ebe1397c49d608cd0bd84..57839fcdf3e8dea8965ca35d96a3db3e14630d96 100644
--- a/src/EventSubscriber/TrashConfigSubscriber.php
+++ b/src/EventSubscriber/TrashConfigSubscriber.php
@@ -1,9 +1,12 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\EventSubscriber;
 
 use Drupal\Core\Config\ConfigCrudEvent;
 use Drupal\Core\Config\ConfigEvents;
+use Drupal\Core\DrupalKernelInterface;
 use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Routing\RouteBuilderInterface;
@@ -15,14 +18,12 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  */
 class TrashConfigSubscriber implements EventSubscriberInterface {
 
-  /**
-   * Constructor.
-   */
   public function __construct(
     protected EntityTypeManagerInterface $entityTypeManager,
     protected TrashManagerInterface $trashManager,
     protected EntityLastInstalledSchemaRepositoryInterface $entityLastInstalledSchemaRepository,
     protected RouteBuilderInterface $routeBuilder,
+    protected DrupalKernelInterface $kernel,
   ) {}
 
   /**
@@ -31,7 +32,7 @@ class TrashConfigSubscriber implements EventSubscriberInterface {
    * @param \Drupal\Core\Config\ConfigCrudEvent $event
    *   The ConfigCrudEvent to process.
    */
-  public function onSave(ConfigCrudEvent $event) {
+  public function onSave(ConfigCrudEvent $event): void {
     if ($event->getConfig()->getName() === 'trash.settings') {
       $supported_entity_types = array_filter($this->entityTypeManager->getDefinitions(), function ($entity_type) {
         return $this->trashManager->isEntityTypeSupported($entity_type);
@@ -60,6 +61,11 @@ class TrashConfigSubscriber implements EventSubscriberInterface {
       // When an entity type is enabled or disabled, the router needs to be
       // rebuilt to add the corresponding tabs in the trash UI.
       $this->routeBuilder->setRebuildNeeded();
+
+      // The container also needs to be rebuilt in order to update the trash
+      // handler services.
+      // @see \Drupal\trash\Handler\TrashHandlerPass::process()
+      $this->kernel->invalidateContainer();
     }
   }
 
diff --git a/src/EventSubscriber/TrashEntitySchemaSubscriber.php b/src/EventSubscriber/TrashEntitySchemaSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..3dff227dfec46b2f9bab05ea6eb6af836b2965ca
--- /dev/null
+++ b/src/EventSubscriber/TrashEntitySchemaSubscriber.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\EventSubscriber;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Entity\EntityTypeEventSubscriberTrait;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeListenerInterface;
+use Drupal\trash\TrashManagerInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Defines a class for listening to entity schema changes.
+ */
+class TrashEntitySchemaSubscriber implements EntityTypeListenerInterface, EventSubscriberInterface {
+
+  use EntityTypeEventSubscriberTrait;
+
+  public function __construct(
+    protected TrashManagerInterface $trashManager,
+    protected ConfigFactoryInterface $configFactory,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    return static::getEntityTypeEvents();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onEntityTypeDelete(EntityTypeInterface $entity_type): void {
+    // Remove the deleted entity type from the Trash settings.
+    if ($this->trashManager->isEntityTypeEnabled($entity_type)) {
+      $trash_settings = $this->configFactory->getEditable('trash.settings');
+      $enabled_entity_types = $trash_settings->get('enabled_entity_types');
+      unset($enabled_entity_types[$entity_type->id()]);
+      $trash_settings->set('enabled_entity_types', $enabled_entity_types)->save();
+    }
+  }
+
+}
diff --git a/src/EventSubscriber/TrashIgnoreSubscriber.php b/src/EventSubscriber/TrashIgnoreSubscriber.php
index 0534d610bcb7c2056980a58894bc5fc7ef9a6e79..6f53747fb091202edd03b322502d7c12f565260c 100644
--- a/src/EventSubscriber/TrashIgnoreSubscriber.php
+++ b/src/EventSubscriber/TrashIgnoreSubscriber.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 namespace Drupal\trash\EventSubscriber;
 
+use Drupal\Core\DefaultContent\PreImportEvent;
+use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Update\UpdateKernel;
 use Drupal\trash\TrashManagerInterface;
 use Drupal\workspaces\Event\WorkspacePostPublishEvent;
@@ -18,31 +20,60 @@ use Symfony\Component\HttpKernel\KernelEvents;
  */
 class TrashIgnoreSubscriber implements EventSubscriberInterface {
 
-  /**
-   * Constructor.
-   *
-   * @param \Drupal\trash\TrashManagerInterface $trashManager
-   *   The trash manager.
-   */
   public function __construct(
     protected TrashManagerInterface $trashManager,
+    protected RouteMatchInterface $routeMatch,
   ) {}
 
   /**
-   * Sets the trash context to ignore.
+   * Sets the trash context to ignore if needed.
    *
-   * This is required so upgrades affecting entities will affect all entities,
-   * no matter if they have been trashed.
+   * @param \Symfony\Component\HttpKernel\Event\KernelEvent $event
+   *   The KernelEvent to process.
+   */
+  public function onRequestPreRouting(KernelEvent $event): void {
+    if (!$event->isMainRequest()) {
+      return;
+    }
+
+    // This is needed so upgrades affecting entities will affect all entities,
+    // no matter if they have been trashed.
+    $is_update_kernel = $event->getKernel() instanceof UpdateKernel;
+
+    $has_trash_query = $event->getRequest()->query->has('in_trash');
+
+    if ($is_update_kernel || $has_trash_query) {
+      $this->trashManager->setTrashContext('ignore');
+    }
+  }
+
+  /**
+   * Sets the trash context to ignore if needed.
    *
    * @param \Symfony\Component\HttpKernel\Event\KernelEvent $event
    *   The KernelEvent to process.
    */
   public function onRequest(KernelEvent $event): void {
-    if ($event->getKernel() instanceof UpdateKernel) {
+    if (!$event->isMainRequest()) {
+      return;
+    }
+
+    // Allow trashed entities to be displayed on the workspace manage page.
+    if ($this->routeMatch->getRouteName() === 'entity.workspace.canonical') {
       $this->trashManager->setTrashContext('ignore');
     }
   }
 
+  /**
+   * Ignores the trash context when default_content imports content.
+   *
+   * @param \Drupal\Core\DefaultContent\PreImportEvent $event
+   *   The default_content pre-import event.
+   */
+  public function onDefaultContentPreImport(PreImportEvent $event): void {
+    $this->trashManager->setTrashContext('ignore');
+  }
+
   /**
    * Ignores the trash context when publishing a workspace.
    *
@@ -67,12 +98,22 @@ class TrashIgnoreSubscriber implements EventSubscriberInterface {
    * {@inheritdoc}
    */
   public static function getSubscribedEvents(): array {
+    // Our ignore subscriber needs to run before language negotiation (which has
+    // a priority of 255) in order to allow route enhancers (e.g. entity param
+    // converter) to load the deleted entity.
+    $events[KernelEvents::REQUEST][] = ['onRequestPreRouting', 256];
+
+    // Add another subscriber for setting the ignore trash context when the
+    // current route is known.
     $events[KernelEvents::REQUEST][] = ['onRequest'];
 
     if (class_exists(WorkspacePublishEvent::class)) {
       $events[WorkspacePrePublishEvent::class][] = ['onWorkspacePrePublish'];
       $events[WorkspacePostPublishEvent::class][] = ['onWorkspacePostPublish'];
     }
+    if (class_exists(PreImportEvent::class)) {
+      $events[PreImportEvent::class][] = ['onDefaultContentPreImport'];
+    }
 
     return $events;
   }
diff --git a/src/Form/EntityPurgeForm.php b/src/Form/EntityPurgeForm.php
index 7691a90fb47489380ae1dae861cd087fb8a10d93..fa0ba78dab3d6d298a882c481098816986fcb585 100644
--- a/src/Form/EntityPurgeForm.php
+++ b/src/Form/EntityPurgeForm.php
@@ -1,60 +1,34 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\Form;
 
-use Drupal\Component\Datetime\TimeInterface;
-use Drupal\Core\Database\Connection;
 use Drupal\Core\Entity\ContentEntityConfirmFormBase;
-use Drupal\Core\Entity\EntityRepositoryInterface;
-use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Form\WorkspaceSafeFormInterface;
 use Drupal\Core\Url;
-use Drupal\trash\TrashManagerInterface;
+use Drupal\trash\TrashManager;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * Provides a generic base class for a content entity purge form.
  */
-class EntityPurgeForm extends ContentEntityConfirmFormBase {
+class EntityPurgeForm extends ContentEntityConfirmFormBase implements WorkspaceSafeFormInterface {
 
   /**
-   * Constructs a ContentEntityForm object.
-   *
-   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
-   *   The entity repository service.
-   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
-   *   The entity type bundle service.
-   * @param \Drupal\Component\Datetime\TimeInterface $time
-   *   The time service.
-   * @param \Drupal\Core\Database\Connection $database
-   *   The database connection.
-   * @param \Drupal\trash\TrashManagerInterface $trashManager
-   *   The Trash Manager service.
+   * The trash manager.
    */
-  public function __construct(
-    EntityRepositoryInterface $entity_repository,
-    EntityTypeBundleInfoInterface $entity_type_bundle_info,
-    TimeInterface $time,
-    protected Connection $database,
-    protected TrashManagerInterface $trashManager,
-  ) {
-    parent::__construct($entity_repository, $entity_type_bundle_info, $time);
-    // Ensure form altering modules like Workspaces can load unchanged entities.
-    $this->trashManager->setTrashContext('inactive');
-  }
+  protected TrashManager $trashManager;
 
   /**
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container) {
-    return new static(
-      $container->get('entity.repository'),
-      $container->get('entity_type.bundle.info'),
-      $container->get('datetime.time'),
-      $container->get('database'),
-      $container->get('trash.manager'),
-    );
+    $instance = parent::create($container);
+    $instance->trashManager = $container->get('trash.manager');
+    return $instance;
   }
 
   /**
@@ -128,8 +102,7 @@ class EntityPurgeForm extends ContentEntityConfirmFormBase {
       $form['actions']['submit']['#value'] = $this->t('Delete @language translation', ['@language' => $entity->language()->getName()]);
     }
 
-    // Trash operations are allowed in a workspace.
-    $form_state->set('workspace_safe', TRUE);
+    $this->trashManager->getHandler($this->getEntity()->getEntityTypeId())?->purgeFormAlter($form, $form_state);
 
     return $form;
   }
@@ -162,6 +135,12 @@ class EntityPurgeForm extends ContentEntityConfirmFormBase {
     $form_state->setRedirectUrl($this->getRedirectUrl());
 
     $this->messenger()->addStatus($message);
+    // @todo Change log message if only a translation was purged.
+    $this->getLogger('trash')->info('@entity-type (@bundle): permanently deleted %label.', [
+      '@entity-type' => $entity->getEntityType()->getLabel(),
+      '@bundle' => $this->entityTypeBundleInfo->getBundleInfo($entity->getEntityTypeId())[$entity->bundle()]['label'],
+      '%label' => $entity->label() ?? $entity->id(),
+    ]);
   }
 
   /**
diff --git a/src/Form/EntityRestoreForm.php b/src/Form/EntityRestoreForm.php
index ba23aef2975e4c4d658ff9b3170cf4842a40edf9..c722556631ea684f515bb49d49d98e7dc8e08bc5 100644
--- a/src/Form/EntityRestoreForm.php
+++ b/src/Form/EntityRestoreForm.php
@@ -1,60 +1,34 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\Form;
 
-use Drupal\Component\Datetime\TimeInterface;
-use Drupal\Core\Database\Connection;
 use Drupal\Core\Entity\ContentEntityConfirmFormBase;
-use Drupal\Core\Entity\EntityRepositoryInterface;
-use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Form\WorkspaceSafeFormInterface;
 use Drupal\Core\Url;
-use Drupal\trash\TrashManagerInterface;
+use Drupal\trash\TrashManager;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * Provides a generic base class for a content entity restore form.
  */
-class EntityRestoreForm extends ContentEntityConfirmFormBase {
+class EntityRestoreForm extends ContentEntityConfirmFormBase implements WorkspaceSafeFormInterface {
 
   /**
-   * Constructs a ContentEntityForm object.
-   *
-   * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
-   *   The entity repository service.
-   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
-   *   The entity type bundle service.
-   * @param \Drupal\Component\Datetime\TimeInterface $time
-   *   The time service.
-   * @param \Drupal\Core\Database\Connection $database
-   *   The database connection.
-   * @param \Drupal\trash\TrashManagerInterface $trashManager
-   *   The Trash Manager service.
+   * The trash manager.
    */
-  public function __construct(
-    EntityRepositoryInterface $entity_repository,
-    EntityTypeBundleInfoInterface $entity_type_bundle_info,
-    TimeInterface $time,
-    protected Connection $database,
-    protected TrashManagerInterface $trashManager,
-  ) {
-    parent::__construct($entity_repository, $entity_type_bundle_info, $time);
-    // Ensure form altering modules like Workspaces can load unchanged entities.
-    $this->trashManager->setTrashContext('inactive');
-  }
+  protected TrashManager $trashManager;
 
   /**
    * {@inheritdoc}
    */
   public static function create(ContainerInterface $container) {
-    return new static(
-      $container->get('entity.repository'),
-      $container->get('entity_type.bundle.info'),
-      $container->get('datetime.time'),
-      $container->get('database'),
-      $container->get('trash.manager'),
-    );
+    $instance = parent::create($container);
+    $instance->trashManager = $container->get('trash.manager');
+    return $instance;
   }
 
   /**
@@ -135,10 +109,9 @@ class EntityRestoreForm extends ContentEntityConfirmFormBase {
       }
     }
 
-    // Trash operations are allowed in a workspace.
-    $form_state->set('workspace_safe', TRUE);
+    $this->trashManager->getHandler($this->getEntity()->getEntityTypeId())?->restoreFormAlter($form, $form_state);
 
-    return parent::buildForm($form, $form_state);
+    return $form;
   }
 
   /**
@@ -177,6 +150,13 @@ class EntityRestoreForm extends ContentEntityConfirmFormBase {
     trash_restore_entity($entity, $langcodes);
     $form_state->setRedirectUrl($this->getRedirectUrl());
     $this->messenger()->addStatus($message);
+
+    // @todo Change log message if only a translation was restored.
+    $this->getLogger('trash')->info('@entity-type (@bundle): restored %label.', [
+      '@entity-type' => $entity->getEntityType()->getLabel(),
+      '@bundle' => $this->entityTypeBundleInfo->getBundleInfo($entity->getEntityTypeId())[$entity->bundle()]['label'],
+      '%label' => $entity->label() ?? $entity->id(),
+    ]);
   }
 
   /**
diff --git a/src/Form/TrashSettingsForm.php b/src/Form/TrashSettingsForm.php
index 712621df59cde6af42e05c1ee3b69878b7069f2f..f9dd5a8ad359ca941fe73037aa6e7be562f23d1b 100644
--- a/src/Form/TrashSettingsForm.php
+++ b/src/Form/TrashSettingsForm.php
@@ -1,11 +1,17 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\Form;
 
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Entity\Sql\SqlEntityStorageInterface;
 use Drupal\Core\Form\ConfigFormBase;
 use Drupal\Core\Form\FormStateInterface;
-use Drupal\Core\TypedData\TypedDataTrait;
+use Drupal\Core\Url;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
@@ -13,33 +19,25 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  */
 class TrashSettingsForm extends ConfigFormBase {
 
-  use TypedDataTrait;
-
   /**
    * The entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
    */
-  protected $entityTypeManager;
+  protected EntityTypeManagerInterface $entityTypeManager;
 
   /**
    * The entity field manager.
-   *
-   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
    */
-  protected $entityFieldManager;
+  protected EntityFieldManagerInterface $entityFieldManager;
 
   /**
    * The entity type bundle info.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
    */
-  protected $entityTypeBundleInfo;
+  protected EntityTypeBundleInfoInterface $entityTypeBundleInfo;
 
   /**
    * {@inheritdoc}
    */
-  public static function create(ContainerInterface $container) {
+  public static function create(ContainerInterface $container): static {
     $instance = parent::create($container);
 
     $instance->entityTypeManager = $container->get('entity_type.manager');
@@ -69,6 +67,19 @@ class TrashSettingsForm extends ConfigFormBase {
   public function buildForm(array $form, FormStateInterface $form_state) {
     $config = $this->config('trash.settings');
     $enabled_entity_types = $config->get('enabled_entity_types') ?? [];
+    $unsupported_entity_types = $this->getUnsupportedEntityTypes();
+
+    // Get all applicable entity types.
+    $applicable_entity_types = array_map(
+      fn (EntityTypeInterface $entity_type): string => (string) $entity_type->getLabel(),
+      array_filter(
+        $this->entityTypeManager->getDefinitions(),
+        fn (EntityTypeInterface $entity_type): bool =>
+          is_subclass_of($entity_type->getStorageClass(), SqlEntityStorageInterface::class)
+          && !in_array($entity_type->id(), $unsupported_entity_types, TRUE),
+      )
+    );
+    asort($applicable_entity_types);
 
     $form['enabled_entity_types'] = [
       '#type' => 'details',
@@ -77,58 +88,36 @@ class TrashSettingsForm extends ConfigFormBase {
       '#tree' => TRUE,
     ];
 
-    // Disallow enabling trash on entity types that haven't been tested enough.
-    $disallowed_entity_types = [
-      'block_content',
-      'comment',
-      'taxonomy_term',
-      'path_alias',
-      'user',
-      'workspace',
-      'wse_menu_tree',
-    ];
-
-    if ($this->entityTypeManager->hasDefinition('menu_link_content')) {
-      // Custom menu links can be deleted if there's a module which allows
-      // changing the hierarchy in pending revisions (e.g. wse_menu).
-      $menu_link_content = $this->entityTypeManager->getDefinition('menu_link_content');
-      $constraints = $menu_link_content->getConstraints();
-      if (isset($constraints['MenuTreeHierarchy'])) {
-        $disallowed_entity_types = array_merge($disallowed_entity_types, ['menu_link_content']);
-      }
-    }
-    // Get all applicable entity types.
-    foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
-      if (is_subclass_of($entity_type->getStorageClass(), SqlEntityStorageInterface::class)
-        && !\in_array($entity_type_id, $disallowed_entity_types, TRUE)) {
-        /** @var \Drupal\Core\Field\BaseFieldDefinition[] $field_definitions */
-        $field_definitions = $this->entityFieldManager->getBaseFieldDefinitions($entity_type_id);
-        $form['enabled_entity_types'][$entity_type_id]['enabled'] = [
-          '#type' => 'checkbox',
-          '#title' => $entity_type->getLabel(),
-          '#default_value' => isset($field_definitions['deleted']) && isset($enabled_entity_types[$entity_type_id]),
-          '#disabled' => isset($field_definitions['deleted']) && ($field_definitions['deleted']->getProvider() !== 'trash'),
-        ];
-        if ($entity_type->getBundleEntityType()) {
-          $bundles = array_map(
-            fn (array $bundle): string => $bundle['label'],
-            $this->entityTypeBundleInfo->getBundleInfo($entity_type_id)
-          );
-          asort($bundles);
-
-          $form['enabled_entity_types'][$entity_type_id]['bundles'] = [
-            '#type' => 'checkboxes',
-            '#title' => $this->t('Bundles'),
-            '#description' => $this->t('If none are selected, all are allowed.'),
-            '#options' => $bundles,
-            '#default_value' => $enabled_entity_types[$entity_type_id] ?? [],
-            '#states' => [
-              'visible' => [
-                ':input[name="enabled_entity_types[' . $entity_type_id . '][enabled]"]' => ['checked' => TRUE],
-              ],
+    foreach ($applicable_entity_types as $entity_type_id => $entity_type_label) {
+      /** @var \Drupal\Core\Field\BaseFieldDefinition[] $field_definitions */
+      $field_definitions = $this->entityFieldManager->getBaseFieldDefinitions($entity_type_id);
+      $form['enabled_entity_types'][$entity_type_id]['enabled'] = [
+        '#type' => 'checkbox',
+        '#title' => $entity_type_label,
+        '#default_value' => isset($field_definitions['deleted']) && isset($enabled_entity_types[$entity_type_id]),
+        '#disabled' => isset($field_definitions['deleted']) && ($field_definitions['deleted']->getProvider() !== 'trash'),
+      ];
+
+      $bundles = array_map(
+        fn (array $bundle): string => (string) $bundle['label'],
+        $this->entityTypeBundleInfo->getBundleInfo($entity_type_id)
+      );
+
+      if (count($bundles) > 1) {
+        asort($bundles);
+        $form['enabled_entity_types'][$entity_type_id]['bundles'] = [
+          '#type' => 'checkboxes',
+          '#title' => $this->t('Bundles'),
+          '#description' => $this->t('If none are selected, all are allowed.'),
+          '#options' => $bundles,
+          '#default_value' => $enabled_entity_types[$entity_type_id] ?? [],
+          '#states' => [
+            'visible' => [
+              ':input[name="enabled_entity_types[' . $entity_type_id . '][enabled]"]' => ['checked' => TRUE],
             ],
-          ];
-        }
+          ],
+          '#attributes' => ['class' => ['trash--bundles']],
+        ];
       }
     }
 
@@ -154,12 +143,41 @@ class TrashSettingsForm extends ConfigFormBase {
         'visible' => [
           ':input[name="auto_purge[enabled]"]' => ['checked' => TRUE],
         ],
+        'required' => [
+          ':input[name="auto_purge[enabled]"]' => ['checked' => TRUE],
+        ],
       ],
     ];
 
+    $form['compact_overview'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Compact overview'),
+      '#config_target' => 'trash.settings:compact_overview',
+      '#description' => $this->t('Simplify the <a href=":url">Trash overview page</a> when there are many entity types enabled.', [
+        ':url' => Url::fromRoute('trash.admin_content_trash')->toString(),
+      ]),
+    ];
+
+    $form['#attached']['library'][] = 'trash/trash.admin';
+
     return parent::buildForm($form, $form_state);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    parent::validateForm($form, $form_state);
+
+    $auto_purge = $form_state->getValue('auto_purge');
+    if (!empty($auto_purge['enabled']) && empty($auto_purge['after'])) {
+      $form_state->setErrorByName('auto_purge][after', $this->t('Auto-purge time period is required.'));
+    }
+    elseif (empty($auto_purge['enabled'])) {
+      $form_state->unsetValue(['auto_purge', 'after']);
+    }
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -189,4 +207,30 @@ class TrashSettingsForm extends ConfigFormBase {
     parent::submitForm($form, $form_state);
   }
 
+  /**
+   * Returns an array of entity types that are not supported by Trash.
+   */
+  protected function getUnsupportedEntityTypes(): array {
+    // Disallow enabling trash on entity types that haven't been tested enough.
+    $unsupported_entity_types = [
+      'comment',
+      'taxonomy_term',
+      'path_alias',
+      'user',
+      'workspace',
+    ];
+
+    if ($this->entityTypeManager->hasDefinition('menu_link_content')) {
+      // Custom menu links can be deleted if there's a module which allows
+      // changing the hierarchy in pending revisions (e.g. wse_menu).
+      $menu_link_content = $this->entityTypeManager->getDefinition('menu_link_content');
+      $constraints = $menu_link_content->getConstraints();
+      if (isset($constraints['MenuTreeHierarchy'])) {
+        $unsupported_entity_types = array_merge($unsupported_entity_types, ['menu_link_content']);
+      }
+    }
+
+    return $unsupported_entity_types;
+  }
+
 }
diff --git a/src/Handler/DefaultTrashHandler.php b/src/Handler/DefaultTrashHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..1d5607c8307ae4a5c66ba05c4c79526e3c0120ea
--- /dev/null
+++ b/src/Handler/DefaultTrashHandler.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\Handler;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\trash\TrashManagerInterface;
+
+/**
+ * Provides the default trash handler.
+ */
+class DefaultTrashHandler implements TrashHandlerInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The ID of the entity type managed by this handler.
+   */
+  protected string $entityTypeId;
+
+  /**
+   * The entity type manager.
+   */
+  protected EntityTypeManagerInterface $entityTypeManager;
+
+  /**
+   * The trash manager.
+   */
+  protected TrashManagerInterface $trashManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function preTrashDelete(EntityInterface $entity): void {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postTrashDelete(EntityInterface $entity): void {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function preTrashRestore(EntityInterface $entity): void {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postTrashRestore(EntityInterface $entity): void {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteFormAlter(array &$form, FormStateInterface $form_state, bool $multiple = FALSE): void {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function restoreFormAlter(array &$form, FormStateInterface $form_state): void {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function purgeFormAlter(array &$form, FormStateInterface $form_state): void {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setEntityTypeId(string $entity_type_id): static {
+    $this->entityTypeId = $entity_type_id;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager): static {
+    $this->entityTypeManager = $entity_type_manager;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setTrashManager(TrashManagerInterface $trash_manager): static {
+    $this->trashManager = $trash_manager;
+    return $this;
+  }
+
+}
diff --git a/src/Handler/TrashHandlerConfigurator.php b/src/Handler/TrashHandlerConfigurator.php
new file mode 100644
index 0000000000000000000000000000000000000000..a683fe77b6b35a66d60a7a9aa79ba4cd56bb93ae
--- /dev/null
+++ b/src/Handler/TrashHandlerConfigurator.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\Handler;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\trash\TrashManagerInterface;
+
+/**
+ * Provides a configurator for trash handler services.
+ */
+class TrashHandlerConfigurator {
+
+  public function __construct(
+    protected EntityTypeManagerInterface $entityTypeManager,
+    protected TrashManagerInterface $trashManager,
+  ) {}
+
+  /**
+   * Configures a trash handler service.
+   *
+   * @param \Drupal\trash\Handler\TrashHandlerInterface $trashHandler
+   *   A trash handler service.
+   */
+  public function __invoke(TrashHandlerInterface $trashHandler): void {
+    $trashHandler->setEntityTypeManager($this->entityTypeManager);
+    $trashHandler->setTrashManager($this->trashManager);
+  }
+
+}
diff --git a/src/Handler/TrashHandlerInterface.php b/src/Handler/TrashHandlerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..83b023de2c9efece66f0f165ecf1aacc4db1bb43
--- /dev/null
+++ b/src/Handler/TrashHandlerInterface.php
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\Handler;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\trash\TrashManagerInterface;
+
+/**
+ * Provides an interface for trash handlers.
+ */
+interface TrashHandlerInterface {
+
+  /**
+   * Acts before an entity is soft-deleted.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   An entity object.
+   */
+  public function preTrashDelete(EntityInterface $entity): void;
+
+  /**
+   * Acts after an entity is soft-deleted.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   An entity object.
+   */
+  public function postTrashDelete(EntityInterface $entity): void;
+
+  /**
+   * Acts before an entity is restored.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   An entity object.
+   */
+  public function preTrashRestore(EntityInterface $entity): void;
+
+  /**
+   * Acts after an entity is restored.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   An entity object.
+   */
+  public function postTrashRestore(EntityInterface $entity): void;
+
+  /**
+   * Alters the entity delete form to provide additional information if needed.
+   *
+   * @param array $form
+   *   The entity form to be altered.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   * @param bool $multiple
+   *   (optional) Whether multiple entities are being deleted by the form.
+   *   Defaults to FALSE.
+   */
+  public function deleteFormAlter(array &$form, FormStateInterface $form_state, bool $multiple = FALSE): void;
+
+  /**
+   * Alters the entity restore form to provide additional information if needed.
+   *
+   * @param array $form
+   *   The entity form to be altered.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  public function restoreFormAlter(array &$form, FormStateInterface $form_state): void;
+
+  /**
+   * Alters the entity purge form to provide additional information if needed.
+   *
+   * @param array $form
+   *   The entity form to be altered.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  public function purgeFormAlter(array &$form, FormStateInterface $form_state): void;
+
+  /**
+   * Sets the ID of the entity type managed by this handler.
+   */
+  public function setEntityTypeId(string $entity_type_id): static;
+
+  /**
+   * Sets the entity type manager service.
+   */
+  public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager): static;
+
+  /**
+   * Sets the trash manager service.
+   */
+  public function setTrashManager(TrashManagerInterface $trash_manager): static;
+
+}
diff --git a/src/Handler/TrashHandlerPass.php b/src/Handler/TrashHandlerPass.php
new file mode 100644
index 0000000000000000000000000000000000000000..c19a8ca06c4859a32099593e6922f13b08fb9f00
--- /dev/null
+++ b/src/Handler/TrashHandlerPass.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\Handler;
+
+use Drupal\Core\Config\BootstrapConfigStorageFactory;
+use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * Defines a compiler pass to register and configure trash handlers.
+ */
+class TrashHandlerPass implements CompilerPassInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process(ContainerBuilder $container): void {
+    // The config factory might not be ready yet, so we bypass the container by
+    // using the bootstrap factory.
+    $config_storage = BootstrapConfigStorageFactory::get();
+    // This config ignores overrides, so trash config from settings.php won't be
+    // taken into account.
+    /** @var \Drupal\Core\Config\ImmutableConfig $trash_settings */
+    $trash_settings = $config_storage->read('trash.settings');
+
+    $enabled_entity_types = $trash_settings['enabled_entity_types'] ?? [];
+    $trash_handlers = $container->findTaggedServiceIds('trash_handler');
+
+    foreach ($trash_handlers as $id => $attributes) {
+      $entity_type_id = $attributes[0]['entity_type_id'];
+
+      // Remove trash handlers for entity types that aren't enabled.
+      if (!isset($enabled_entity_types[$entity_type_id])) {
+        $container->removeDefinition($id);
+      }
+      else {
+        // Keep track of entity types without a dedicated trash handler so we
+        // can create one for them automatically.
+        unset($enabled_entity_types[$entity_type_id]);
+
+        $container->getDefinition($id)
+          ->addMethodCall('setEntityTypeId', [$entity_type_id])
+          ->setConfigurator(new Reference('trash.handler_configurator'));
+      }
+    }
+
+    // Register a trash handler for entity types without a dedicated one.
+    foreach (array_keys($enabled_entity_types) as $entity_type_id) {
+      $container->register('trash.default_handler.' . $entity_type_id, DefaultTrashHandler::class)
+        ->addTag('trash_handler', ['entity_type_id' => $entity_type_id])
+        ->addMethodCall('setEntityTypeId', [$entity_type_id])
+        ->setConfigurator(new Reference('trash.handler_configurator'));
+    }
+  }
+
+}
diff --git a/src/Hook/TrashHandler/MenuLinkContentTrashHandler.php b/src/Hook/TrashHandler/MenuLinkContentTrashHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..281e8a28ed04f9b971627a14d05dd29ad8278ec8
--- /dev/null
+++ b/src/Hook/TrashHandler/MenuLinkContentTrashHandler.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\Hook\TrashHandler;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\Core\Menu\MenuLinkManagerInterface;
+use Drupal\menu_link_content\MenuLinkContentInterface;
+use Drupal\trash\Handler\DefaultTrashHandler;
+
+/**
+ * Provides a trash handler for the 'menu_link_content' entity type.
+ */
+class MenuLinkContentTrashHandler extends DefaultTrashHandler {
+
+  public function __construct(
+    protected MenuLinkManagerInterface $menuLinkManager,
+  ) {}
+
+  /**
+   * Implements hook_ENTITY_TYPE_update() for 'menu_link_content'.
+   */
+  #[Hook('menu_link_content_update')]
+  public function entityUpdate(EntityInterface $entity): void {
+    assert($entity instanceof MenuLinkContentInterface);
+
+    // Handle removing menu link definitions. It's essential that this is done
+    // in an update hook rather than a presave one because it needs to run after
+    // \Drupal\menu_link_content\Entity\MenuLinkContent::postSave(). That method
+    // might add a deleted menu link to the Live menu tree when a workspace is
+    // published, so this code needs to run afterward in order to remove it
+    // again.
+    if (trash_entity_is_deleted($entity)) {
+      $this->menuLinkManager->removeDefinition($entity->getPluginId(), FALSE);
+    }
+  }
+
+}
diff --git a/src/Hook/TrashHandler/NodeTrashHandler.php b/src/Hook/TrashHandler/NodeTrashHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..ee3a333361d794b5eafd6c34373f2cb1f557ea8e
--- /dev/null
+++ b/src/Hook/TrashHandler/NodeTrashHandler.php
@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\Hook\TrashHandler;
+
+use Drupal\Core\Database\Query\AlterableInterface;
+use Drupal\Core\Hook\Attribute\Hook;
+use Drupal\trash\Handler\DefaultTrashHandler;
+
+/**
+ * Provides a trash handler for the 'node' entity type.
+ */
+class NodeTrashHandler extends DefaultTrashHandler {
+
+  /**
+   * Implements hook_query_TAG_alter() for the 'search_node_search' tag.
+   */
+  #[Hook('query_search_node_search_alter')]
+  public function querySearchNodeSearchAlter(AlterableInterface $query): void {
+    // The core Search module is not using an entity query, so we need to alter
+    // its query manually.
+    // @see \Drupal\node\Plugin\Search\NodeSearch::findResults()
+    /** @var \Drupal\Core\Database\Query\SelectInterface $query */
+    $query->isNull('n.deleted');
+  }
+
+}
diff --git a/src/LayoutBuilder/TrashInlineBlockUsage.php b/src/LayoutBuilder/TrashInlineBlockUsage.php
new file mode 100644
index 0000000000000000000000000000000000000000..2a89148b75e79042eba046c84424cd6c4ca7b7e7
--- /dev/null
+++ b/src/LayoutBuilder/TrashInlineBlockUsage.php
@@ -0,0 +1,75 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\trash\LayoutBuilder;
+
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\layout_builder\InlineBlockUsageInterface;
+use Drupal\trash\TrashManagerInterface;
+
+/**
+ * Decorates Layout Builder's inline block usage service.
+ */
+class TrashInlineBlockUsage implements InlineBlockUsageInterface {
+
+  public function __construct(
+    protected InlineBlockUsageInterface $inner,
+    protected TrashManagerInterface $trashManager,
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addUsage($block_content_id, EntityInterface $entity) {
+    $this->inner->addUsage($block_content_id, $entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getUnused($limit = 100) {
+    return $this->inner->getUnused($limit);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function removeByLayoutEntity(EntityInterface $entity) {
+    $this->inner->removeByLayoutEntity($entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function deleteUsage(array $block_content_ids) {
+    // Inline blocks are (permanently) deleted when they are no longer used in
+    // any parent/referencing entity. With Trash, this can only happen when the
+    // parent entity is purged, which means we have to ensure that its inline
+    // blocks are purged as well instead of being trashed (soft-deleted).
+    // Layout Builders handles this in
+    // InlineBlockEntityOperations::deleteBlocksAndUsage(), but that method is
+    // not public API, and it could be changed at any time, so the only good
+    // option for Trash is to handle it in this service decorator.
+    if ($this->trashManager->isEntityTypeEnabled('block_content')) {
+      $this->trashManager->executeInTrashContext('inactive', function () use ($block_content_ids) {
+        foreach ($block_content_ids as $block_content_id) {
+          if ($block = BlockContent::load($block_content_id)) {
+            $block->delete();
+          }
+        }
+      });
+    }
+
+    $this->inner->deleteUsage($block_content_ids);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getUsage($block_content_id) {
+    return $this->inner->getUsage($block_content_id);
+  }
+
+}
diff --git a/src/Plugin/Derivative/TrashLocalTasks.php b/src/Plugin/Derivative/TrashLocalTasks.php
index 79e31184a334d2e53a9d03e35b0267a2a7e0fef4..a65331f5d2466d93a3564ba39449ee59c703861c 100644
--- a/src/Plugin/Derivative/TrashLocalTasks.php
+++ b/src/Plugin/Derivative/TrashLocalTasks.php
@@ -1,8 +1,11 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\Plugin\Derivative;
 
 use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
 use Drupal\trash\TrashManagerInterface;
@@ -13,32 +16,11 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  */
 class TrashLocalTasks extends DeriverBase implements ContainerDeriverInterface {
 
-  /**
-   * The entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
-   */
-  protected $entityTypeManager;
-
-  /**
-   * The trash manager.
-   *
-   * @var \Drupal\trash\TrashManagerInterface
-   */
-  protected $trashManager;
-
-  /**
-   * Creates a TrashLocalTasks object.
-   *
-   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
-   *   The entity type manager.
-   * @param \Drupal\trash\TrashManagerInterface $trash_manager
-   *   The trash manager.
-   */
-  public function __construct(EntityTypeManagerInterface $entity_type_manager, TrashManagerInterface $trash_manager) {
-    $this->entityTypeManager = $entity_type_manager;
-    $this->trashManager = $trash_manager;
-  }
+  public function __construct(
+    protected EntityTypeManagerInterface $entityTypeManager,
+    protected TrashManagerInterface $trashManager,
+    protected ConfigFactoryInterface $configFactory,
+  ) {}
 
   /**
    * {@inheritdoc}
@@ -46,7 +28,8 @@ class TrashLocalTasks extends DeriverBase implements ContainerDeriverInterface {
   public static function create(ContainerInterface $container, $base_plugin_id) {
     return new static(
       $container->get('entity_type.manager'),
-      $container->get('trash.manager')
+      $container->get('trash.manager'),
+      $container->get('config.factory')
     );
   }
 
@@ -54,6 +37,10 @@ class TrashLocalTasks extends DeriverBase implements ContainerDeriverInterface {
    * {@inheritdoc}
    */
   public function getDerivativeDefinitions($base_plugin_definition) {
+    if ($this->configFactory->get('trash.settings')->get('compact_overview')) {
+      return ['trash' => $base_plugin_definition];
+    }
+
     $this->derivatives = [];
     $enabled_entity_types = $this->trashManager->getEnabledEntityTypes();
 
diff --git a/src/Plugin/QueueWorker/TrashEntityPurgeWorker.php b/src/Plugin/QueueWorker/TrashEntityPurgeWorker.php
index 220447c2bdd9e15d3a742d2cb2c2c707a0ffbc85..cfc6163004918dfd3eff80f7281024bbe96819c8 100644
--- a/src/Plugin/QueueWorker/TrashEntityPurgeWorker.php
+++ b/src/Plugin/QueueWorker/TrashEntityPurgeWorker.php
@@ -1,25 +1,28 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\Plugin\QueueWorker;
 
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Logger\LoggerChannelFactoryInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Queue\Attribute\QueueWorker;
 use Drupal\Core\Queue\QueueWorkerBase;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\trash\TrashManagerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * A queue worker for purging the trash bin.
- *
- * @QueueWorker(
- *   id = "trash_entity_purge",
- *   title = @Translation("Trash Entity Purge Worker"),
- *   cron = {"time" = 60}
- * )
  */
+#[QueueWorker(
+  id: 'trash_entity_purge',
+  title: new TranslatableMarkup('Trash Entity Purge Worker'),
+  cron: ['time' => 60]
+)]
 class TrashEntityPurgeWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface {
 
   use StringTranslationTrait;
diff --git a/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraint.php b/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraint.php
index 4ba75c2501b0eecdb8d8b3e802b39136f08976ab..6956d82e6edaa8bd6e529f2eb606c4f41581f05e 100644
--- a/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraint.php
+++ b/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraint.php
@@ -4,22 +4,21 @@ declare(strict_types=1);
 
 namespace Drupal\trash\Plugin\Validation\Constraint;
 
-use Symfony\Component\Validator\Constraint;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Validation\Attribute\Constraint;
+use Symfony\Component\Validator\Constraint as SymfonyConstraint;
 
 /**
  * Checks that the value is a valid auto-purge period when calling strtotime().
- *
- * @Constraint(
- *   id = "ValidAutoPurgePeriod",
- *   label = @Translation("Auto-purge period", context = "Validation")
- * )
  */
-class ValidAutoPurgePeriodConstraint extends Constraint {
+#[Constraint(
+  id: 'ValidAutoPurgePeriod',
+  label: new TranslatableMarkup('Auto-purge period', [], ['context' => 'Validation'])
+)]
+class ValidAutoPurgePeriodConstraint extends SymfonyConstraint {
 
   /**
    * The error message.
-   *
-   * @var string
    */
   public string $message = "The time period '@value' is not valid. Some valid values would be '1 month, 10 days', '15 days', '3 hours, 15 minutes'.";
 
diff --git a/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraintValidator.php
index 9a3f16c57cb579b47944285245e57ba0d9d88733..d63186bdd7e58165251529a043f5fbaa41fd3c38 100644
--- a/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraintValidator.php
+++ b/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraintValidator.php
@@ -22,7 +22,7 @@ class ValidAutoPurgePeriodConstraintValidator extends ConstraintValidator implem
   /**
    * {@inheritdoc}
    */
-  public static function create(ContainerInterface $container) {
+  public static function create(ContainerInterface $container): static {
     return new static(
       $container->get('datetime.time'),
     );
@@ -32,6 +32,10 @@ class ValidAutoPurgePeriodConstraintValidator extends ConstraintValidator implem
    * {@inheritdoc}
    */
   public function validate(mixed $value, Constraint $constraint): void {
+    if (empty($value)) {
+      return;
+    }
+
     assert($constraint instanceof ValidAutoPurgePeriodConstraint);
     $timestamp = strtotime(sprintf("-%s", $value));
     if (!$timestamp || $timestamp >= $this->time->getCurrentTime()) {
diff --git a/src/Plugin/search_api/processor/TrashStatus.php b/src/Plugin/search_api/processor/TrashStatus.php
index 695a299aa570ad1ee0f46ad69fd2e71c8550fb7b..9d0b5f59e7738a25ea2d1222f97f42aa42d83123 100644
--- a/src/Plugin/search_api/processor/TrashStatus.php
+++ b/src/Plugin/search_api/processor/TrashStatus.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\Plugin\search_api\processor;
 
 use Drupal\Core\Entity\EntityInterface;
diff --git a/src/RouteProcessor/TrashRouteProcessor.php b/src/RouteProcessor/TrashRouteProcessor.php
index a6cc3cfea541be030d10fc21cf0198afa902486a..45a5dd3c55c44e2847c43f76c2c82a9f0b3536d9 100644
--- a/src/RouteProcessor/TrashRouteProcessor.php
+++ b/src/RouteProcessor/TrashRouteProcessor.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash\RouteProcessor;
 
 use Drupal\Core\Render\BubbleableMetadata;
@@ -21,12 +23,24 @@ class TrashRouteProcessor implements OutboundRouteProcessorInterface {
   /**
    * {@inheritdoc}
    */
-  public function processOutbound($route_name, Route $route, array &$parameters, ?BubbleableMetadata $bubbleable_metadata = NULL) {
+  public function processOutbound($route_name, Route $route, array &$parameters, ?BubbleableMetadata $bubbleable_metadata = NULL): void {
+    // Add our 'in_trash' parameter to routes that need it.
+    if ($route->hasOption('_trash_route')) {
+      $parameters['in_trash'] = TRUE;
+    }
+
     // Check if we're viewing a deleted entity and ensure that any other links
     // displayed on the page (e.g. local tasks) have the proper trash context.
     $request = $this->requestStack->getCurrentRequest();
-    if ($request->query->has('in_trash')) {
-      $parts = explode('.', $this->routeMatch->getRouteName());
+    $current_route = $this->routeMatch->getRouteObject();
+    if (!$request || $current_route) {
+      return;
+    }
+
+    // Ensure that the code can also be executed when there is no active route
+    // match, like on exception responses.
+    if (($route_name = $this->routeMatch->getRouteName()) && $request->query->has('in_trash')) {
+      $parts = explode('.', $route_name);
       if ($parts[0] === 'entity') {
         $entity_type_id = $parts[1];
         $entity_id = $this->routeMatch->getRawParameter($entity_type_id);
diff --git a/src/Routing/RouteEnhancer.php b/src/Routing/RouteEnhancer.php
deleted file mode 100644
index 01b62e1b353940d6a11c034991ac77f4979c34f4..0000000000000000000000000000000000000000
--- a/src/Routing/RouteEnhancer.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Drupal\trash\Routing;
-
-use Drupal\Core\Routing\EnhancerInterface;
-use Drupal\Core\Routing\RouteObjectInterface;
-use Drupal\Core\Session\AccountInterface;
-use Drupal\trash\TrashManagerInterface;
-use Symfony\Component\HttpFoundation\Request;
-use Symfony\Component\Routing\Route;
-
-/**
- * Sets the trash context for entity routes.
- */
-class RouteEnhancer implements EnhancerInterface {
-
-  public function __construct(
-    protected AccountInterface $currentUser,
-    protected TrashManagerInterface $trashManager,
-  ) {}
-
-  /**
-   * {@inheritdoc}
-   */
-  public function enhance(array $defaults, Request $request): array {
-    if ($this->applies($defaults[RouteObjectInterface::ROUTE_OBJECT], $request)) {
-      $this->trashManager->setTrashContext('inactive');
-    }
-
-    return $defaults;
-  }
-
-  /**
-   * Determines whether the enhancer should run on the current route.
-   *
-   * @param \Symfony\Component\Routing\Route $route
-   *   The current route.
-   * @param \Symfony\Component\HttpFoundation\Request $request
-   *   The Request instance.
-   *
-   * @return bool
-   *   TRUE if the enhancer should run, FALSE otherwise.
-   */
-  protected function applies(Route $route, Request $request): bool {
-    $is_trash_route = (bool) $route->getOption('_trash_route');
-    $has_trash_query = $request->query->has('in_trash');
-
-    return ($is_trash_route || $has_trash_query) && $this->currentUser->hasPermission('view deleted entities');
-  }
-
-}
diff --git a/src/Routing/RouteSubscriber.php b/src/Routing/RouteSubscriber.php
index b4a56174b59579254ea65952e8d1dee7e408ea55..fc7c9aeea8d99d6c6051fb48ba9d2f43b105af29 100644
--- a/src/Routing/RouteSubscriber.php
+++ b/src/Routing/RouteSubscriber.php
@@ -108,11 +108,6 @@ class RouteSubscriber extends RouteSubscriberBase {
         }
       }
     }
-
-    // Allow trashed entities to be displayed on the workspace manage page.
-    if ($route = $collection->get('entity.workspace.canonical')) {
-      $route->setOption('_trash_route', TRUE);
-    }
   }
 
   /**
diff --git a/src/TrashManager.php b/src/TrashManager.php
index 21fa0ba20ffa83043451e8e6e32a047093c49e0d..9535cbeb412413c618df0c28ccafd4aca585926a 100644
--- a/src/TrashManager.php
+++ b/src/TrashManager.php
@@ -10,6 +10,8 @@ use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\Core\Entity\Sql\SqlEntityStorageInterface;
 use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\trash\Handler\TrashHandlerInterface;
+use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
 
 /**
  * Provides the Trash manager.
@@ -27,6 +29,8 @@ class TrashManager implements TrashManagerInterface {
     protected EntityDefinitionUpdateManagerInterface $entityDefinitionUpdateManager,
     protected EntityLastInstalledSchemaRepositoryInterface $entityLastInstalledSchemaRepository,
     protected ConfigFactoryInterface $configFactory,
+    #[AutowireIterator(tag: 'trash_handler', indexAttribute: 'entity_type_id')]
+    protected iterable $trashHandlers = [],
   ) {}
 
   /**
@@ -139,4 +143,16 @@ class TrashManager implements TrashManagerInterface {
     return $result;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getHandler(string $entity_type_id): ?TrashHandlerInterface {
+    $handlers = iterator_to_array($this->trashHandlers);
+    if (isset($handlers[$entity_type_id])) {
+      return $handlers[$entity_type_id];
+    }
+
+    return NULL;
+  }
+
 }
diff --git a/src/TrashManagerInterface.php b/src/TrashManagerInterface.php
index d6fcbbe244c2322df70222a47c5aaeee1519b0e7..ab246ae6fc8b039e683286e5b8d42028ffb6f710 100644
--- a/src/TrashManagerInterface.php
+++ b/src/TrashManagerInterface.php
@@ -1,8 +1,11 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash;
 
 use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\trash\Handler\TrashHandlerInterface;
 
 /**
  * Provides an interface for the Trash manager.
@@ -100,4 +103,18 @@ interface TrashManagerInterface {
    */
   public function executeInTrashContext($context, callable $function): mixed;
 
+  /**
+   * Gets the trash handler for a given entity type.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID.
+   *
+   * @return \Drupal\trash\Handler\TrashHandlerInterface|null
+   *   The trash handler for the given entity type, or NULL if the entity type
+   *   is not enabled.
+   *
+   * @see \Drupal\trash\Handler\TrashHandlerPass
+   */
+  public function getHandler(string $entity_type_id): ?TrashHandlerInterface;
+
 }
diff --git a/src/TrashPermissions.php b/src/TrashPermissions.php
index 222e6bf3de6b5917b75932594181e3e3c8f5cc2c..0cccc581068831afb37919634832b1721df74280 100644
--- a/src/TrashPermissions.php
+++ b/src/TrashPermissions.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash;
 
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
@@ -14,9 +16,6 @@ class TrashPermissions implements ContainerInjectionInterface {
 
   use StringTranslationTrait;
 
-  /**
-   * Constructor.
-   */
   public function __construct(
     protected EntityTypeManagerInterface $entityTypeManager,
     protected TrashManagerInterface $trashManager,
@@ -25,7 +24,7 @@ class TrashPermissions implements ContainerInjectionInterface {
   /**
    * {@inheritdoc}
    */
-  public static function create(ContainerInterface $container) {
+  public static function create(ContainerInterface $container): static {
     return new static(
       $container->get('entity_type.manager'),
       $container->get('trash.manager')
diff --git a/src/TrashServiceProvider.php b/src/TrashServiceProvider.php
index 51cb917eedd5be7d02a786285a3fc36048383f7f..6ffbbc65acbc6a14feeae85df4310f9f9bb6df46 100644
--- a/src/TrashServiceProvider.php
+++ b/src/TrashServiceProvider.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash;
 
 use Drupal\Core\DependencyInjection\ContainerBuilder;
@@ -8,6 +10,8 @@ use Drupal\trash\EntityQuery\Sql\PgsqlQueryFactory as CorePgsqlQueryFactory;
 use Drupal\trash\EntityQuery\Sql\QueryFactory as CoreQueryFactory;
 use Drupal\trash\EntityQuery\Workspaces\PgsqlQueryFactory as WorkspacesPgsqlQueryFactory;
 use Drupal\trash\EntityQuery\Workspaces\QueryFactory as WorkspacesQueryFactory;
+use Drupal\trash\Handler\TrashHandlerPass;
+use Drupal\trash\LayoutBuilder\TrashInlineBlockUsage;
 use Symfony\Component\DependencyInjection\ChildDefinition;
 use Symfony\Component\DependencyInjection\Reference;
 
@@ -16,6 +20,13 @@ use Symfony\Component\DependencyInjection\Reference;
  */
 class TrashServiceProvider extends ServiceProviderBase {
 
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    $container->addCompilerPass(new TrashHandlerPass());
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -72,6 +83,14 @@ class TrashServiceProvider extends ServiceProviderBase {
         ->addArgument(new Reference('trash.workspaces.manager.inner'))
         ->addArgument(new Reference('trash.manager'));
     }
+
+    if ($container->hasDefinition('inline_block.usage')) {
+      $container->register('trash.inline_block.usage', TrashInlineBlockUsage::class)
+        ->setPublic(FALSE)
+        ->setDecoratedService('inline_block.usage')
+        ->addArgument(new Reference('trash.inline_block.usage.inner'))
+        ->addArgument(new Reference('trash.manager'));
+    }
   }
 
 }
diff --git a/src/TrashStorageTrait.php b/src/TrashStorageTrait.php
index 8682f39b31e432f9fbbb5c677743e91f0ad52caa..9774c1f5faf8fc945acadbfd3545d115808ad03a 100644
--- a/src/TrashStorageTrait.php
+++ b/src/TrashStorageTrait.php
@@ -9,6 +9,8 @@ use Drupal\Core\Entity\TranslatableInterface;
 use Drupal\Core\Language\LanguageManagerInterface;
 use Drupal\Core\TypedData\TranslationStatusInterface;
 use Drupal\Core\Utility\Error;
+use Drupal\workspaces\WorkspaceInformationInterface;
+use Drupal\workspaces\WorkspaceManagerInterface;
 
 /**
  * Provides the ability to soft-delete entities at the storage level.
@@ -40,15 +42,15 @@ trait TrashStorageTrait {
     parent::delete($to_delete);
 
     $field_name = 'deleted';
+    $request_time = \Drupal::time()->getRequestTime();
     $revisionable = $this->getEntityType()->isRevisionable();
 
     try {
       $transaction = $this->database->startTransaction();
-      $field_name = 'deleted';
-      $request_time = \Drupal::time()->getRequestTime();
 
       foreach ($to_trash as $entity) {
         // Allow code to run before soft-deleting.
+        $this->getTrashManager()->getHandler($this->entityTypeId)->preTrashDelete($entity);
         $this->invokeHook('pre_trash_delete', $entity);
 
         foreach (array_keys($entity->getTranslationLanguages()) as $langcode) {
@@ -69,6 +71,7 @@ trait TrashStorageTrait {
         $entity->setSyncing(TRUE)->save();
 
         // Allow code to run after soft-deleting.
+        $this->getTrashManager()->getHandler($this->entityTypeId)->postTrashDelete($entity);
         $this->invokeHook('trash_delete', $entity);
       }
     }
@@ -101,6 +104,7 @@ trait TrashStorageTrait {
           $field_name = 'deleted';
           foreach ($entities as $entity) {
             // Allow code to run before restoring from trash.
+            $this->getTrashManager()->getHandler($this->entityTypeId)->preTrashRestore($entity);
             $this->invokeHook('pre_trash_restore', $entity);
 
             $translation_langcodes = $langcodes ?: array_keys($entity->getTranslationLanguages());
@@ -117,6 +121,7 @@ trait TrashStorageTrait {
             $entity->setSyncing(TRUE)->save();
 
             // Allow code to run after restoring from trash.
+            $this->getTrashManager()->getHandler($this->entityTypeId)->postTrashRestore($entity);
             $this->invokeHook('trash_restore', $entity);
           }
         });
@@ -170,15 +175,36 @@ trait TrashStorageTrait {
       return $query;
     }
 
+    if (!$revision_ids
+      && $this->getWorkspaceInformation()?->isEntityTypeSupported($this->entityType)
+      && ($active_workspace = $this->getWorkspaceManager()?->getActiveWorkspace())
+    ) {
+      // Join the workspace_association table so we can select possible
+      // workspace-specific revisions.
+      $wa_join = $query->leftJoin('workspace_association', NULL, "[%alias].[target_entity_type_id] = '{$this->entityTypeId}' AND [%alias].[target_entity_id] = [base].[{$this->idKey}] AND [%alias].[workspace] = :active_workspace_id", [
+        ':active_workspace_id' => $active_workspace->id(),
+      ]);
+
+      // Joins must be in order. i.e, any tables you mention in the ON clause of
+      // a JOIN must appear prior to that JOIN. So we must ensure that the new
+      // 'workspace_association' table appears prior to the 'revision' one.
+      $tables =& $query->getTables();
+      $revision = $tables['revision'];
+      unset($tables['revision']);
+      $tables['revision'] = $revision;
+
+      $tables['revision']['condition'] = "[revision].[{$this->revisionKey}] = COALESCE([$wa_join].[target_entity_revision_id], [base].[{$this->revisionKey}])";
+    }
+
     $table_mapping = $this->getTableMapping();
     $deleted_column = $table_mapping->getFieldColumnName($this->fieldStorageDefinitions['deleted'], 'value');
 
     // Ensure that entity_load excludes deleted entities.
-    if ($revision_ids && ($revision_data = $this->getRevisionDataTable())) {
+    if ($revision_data = $this->getRevisionDataTable()) {
       $query->join($revision_data, 'revision_data', "[revision_data].[{$this->revisionKey}] = [revision].[{$this->revisionKey}]");
       $query->condition("revision_data.$deleted_column", NULL, 'IS NULL');
     }
-    elseif ($revision_ids) {
+    elseif ($this->getRevisionTable()) {
       $query->condition("revision.$deleted_column", NULL, 'IS NULL');
     }
     elseif ($data_table = $this->getDataTable()) {
@@ -206,6 +232,9 @@ trait TrashStorageTrait {
         unset($entities[$id]);
       }
     }
+    if (empty($entities)) {
+      return;
+    }
 
     parent::setPersistentCache($entities);
   }
@@ -239,4 +268,18 @@ trait TrashStorageTrait {
     return \Drupal::service('language_manager');
   }
 
+  /**
+   * Gets the workspace manager service.
+   */
+  private function getWorkspaceManager(): ?WorkspaceManagerInterface {
+    return \Drupal::hasService('workspaces.manager') ? \Drupal::service('workspaces.manager') : NULL;
+  }
+
+  /**
+   * Gets the workspace information service.
+   */
+  private function getWorkspaceInformation(): ?WorkspaceInformationInterface {
+    return \Drupal::hasService('workspaces.information') ? \Drupal::service('workspaces.information') : NULL;
+  }
+
 }
diff --git a/src/TrashWorkspaceInformation.php b/src/TrashWorkspaceInformation.php
index 3b77ca68f00852b84f020a916af2164bab5a8d3d..d7b1e7f4ba868d6ad7972a5ecf9b2bba21d40973 100644
--- a/src/TrashWorkspaceInformation.php
+++ b/src/TrashWorkspaceInformation.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash;
 
 use Drupal\Core\Entity\EntityInterface;
@@ -7,16 +9,11 @@ use Drupal\Core\Entity\EntityTypeInterface;
 use Drupal\workspaces\WorkspaceInformationInterface;
 use Drupal\workspaces\WorkspaceInterface;
 
-@class_alias('Drupal\wse\Core\WorkspaceInformationInterface', 'Drupal\workspaces\WorkspaceInformationInterface');
-
 /**
- * Provides an override for core's workspace association service.
+ * Provides a decorator for core's workspace information service.
  */
 class TrashWorkspaceInformation implements WorkspaceInformationInterface {
 
-  /**
-   * Constructor.
-   */
   public function __construct(
     protected WorkspaceInformationInterface $inner,
     protected TrashManagerInterface $trashManager,
@@ -61,7 +58,7 @@ class TrashWorkspaceInformation implements WorkspaceInformationInterface {
    * {@inheritdoc}
    */
   public function isEntityDeletable(EntityInterface $entity, WorkspaceInterface $workspace): bool {
-    if ($this->trashManager->isEntityTypeEnabled($entity->getEntityType(), $entity->bundle())) {
+    if ($this->trashManager->isEntityTypeEnabled($entity->getEntityType(), $entity->bundle()) && !trash_entity_is_deleted($entity)) {
       return TRUE;
     }
 
diff --git a/src/TrashWorkspaceManager.php b/src/TrashWorkspaceManager.php
index eca5963afb9967ef2dd3333cbbe1eb81340c4e52..ad5917f47b18750460dcd3f0f888addd60b79c45 100644
--- a/src/TrashWorkspaceManager.php
+++ b/src/TrashWorkspaceManager.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash;
 
 use Drupal\Core\Entity\EntityTypeInterface;
@@ -7,13 +9,10 @@ use Drupal\workspaces\WorkspaceInterface;
 use Drupal\workspaces\WorkspaceManagerInterface;
 
 /**
- * Provides an override for core's workspace manager service.
+ * Provides a decorator for core's workspace manager service.
  */
 class TrashWorkspaceManager implements WorkspaceManagerInterface {
 
-  /**
-   * Constructs a TrashWorkspaceManager object.
-   */
   public function __construct(
     protected WorkspaceManagerInterface $inner,
     protected TrashManagerInterface $trashManager,
diff --git a/src/ViewsQueryAlter.php b/src/ViewsQueryAlter.php
index 3dcb4f3ffc34afa34f231277c17786e0722fb64d..c8e41c676bf9fb11b491d22c01bd684743ec22f9 100644
--- a/src/ViewsQueryAlter.php
+++ b/src/ViewsQueryAlter.php
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Drupal\trash;
 
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
@@ -35,7 +37,7 @@ class ViewsQueryAlter implements ContainerInjectionInterface {
   /**
    * {@inheritdoc}
    */
-  public static function create(ContainerInterface $container) {
+  public static function create(ContainerInterface $container): static {
     return new static(
       $container->get('entity_type.manager'),
       $container->get('entity_field.manager'),
@@ -56,6 +58,11 @@ class ViewsQueryAlter implements ContainerInjectionInterface {
       return;
     }
 
+    // Bail out early if the query has already been altered.
+    if (in_array('trash_altered', $query->tags, TRUE)) {
+      return;
+    }
+
     // Find out what entity types are represented in this query.
     $entity_type_definitions = $this->entityTypeManager->getDefinitions();
     foreach ($query->relationships as $relationship_table => $info) {
@@ -115,34 +122,19 @@ class ViewsQueryAlter implements ContainerInjectionInterface {
     assert($table_mapping instanceof DefaultTableMapping);
     $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type_id);
 
-    $has_delete_condition = FALSE;
-
     // Try to find out whether any filter (normal or conditional filter) filters
     // by the delete column. In case it does opt out of adding a specific
     // delete column.
     $deleted_table_names = $table_mapping->getAllFieldTableNames('deleted');
     $deleted_table_column = $table_mapping->getFieldColumnName($field_storage_definitions['deleted'], 'value');
-    foreach ($query->where as $group) {
-      foreach ($group['conditions'] as $condition) {
-        // Look through all the tables involved in the query, and check for
-        // those that might contain the 'deleted' column, either the data or
-        // revision data table.
-        foreach ($query->getTableQueue() as $alias => $table_info) {
-          if (in_array($table_info['table'], $deleted_table_names, TRUE)) {
-            // Note: We use strpos because views for some reason has a field
-            // looking like "trash_test.Deleted > 0".
-            if (!empty($condition['field']) && strpos($condition['field'], "{$alias}.{$deleted_table_column}") !== FALSE) {
-              $has_delete_condition = TRUE;
-            }
-          }
-        }
-      }
-    }
+
+    $has_delete_condition = $this->hasDeleteCondition($query, $deleted_table_names, $deleted_table_column);
 
     // If we couldn't find any condition that filters out explicitly on deleted,
     // ensure that we just return not deleted entities.
     if (!$has_delete_condition) {
       $query->addWhere(0, "{$deleted_table_name}.{$deleted_table_column}", NULL, 'IS NULL');
+      $query->addTag('trash_altered');
     }
     // Otherwise ignore trash for the duration of this view, so it can load and
     // display deleted entities.
@@ -152,6 +144,53 @@ class ViewsQueryAlter implements ContainerInjectionInterface {
     }
   }
 
+  /**
+   * Check if any filter of the query contains a delete condition.
+   *
+   * @param \Drupal\views\Plugin\views\query\Sql $query
+   *   The query plugin object for the query.
+   * @param array $deleted_table_names
+   *   List of table names with delete column.
+   * @param string $deleted_table_column
+   *   Name of delete column.
+   *
+   * @return bool
+   *   <code>TRUE</code> if the query has a delete condition, <code>FALSE</code>
+   *   otherwise.
+   */
+  protected function hasDeleteCondition(Sql $query, array $deleted_table_names, string $deleted_table_column): bool {
+    if (count($deleted_table_names) === 0) {
+      return FALSE;
+    }
+    // Get aliases of all tables involved in the query having the "deleted"
+    // field.
+    $aliases = array_keys(array_filter($query->getTableQueue(), function ($table_info) use ($deleted_table_names) {
+      return in_array($table_info['table'], $deleted_table_names, TRUE);
+    }));
+    if (count($aliases) === 0) {
+      return FALSE;
+    }
+    foreach ($query->where as $group) {
+      foreach ($group['conditions'] as $condition) {
+        if (!isset($condition['field']) || !is_string($condition['field'])) {
+          continue;
+        }
+        // Look through all the tables involved in the query, and check for
+        // those that might contain the 'deleted' column, either the data or
+        // revision data table.
+        foreach ($aliases as $alias) {
+          // Note: We use strpos because views for some reason has a field
+          // looking like "trash_test.Deleted > 0".
+          if (strpos($condition['field'], "{$alias}.{$deleted_table_column}") !== FALSE) {
+            return TRUE;
+          }
+        }
+      }
+    }
+
+    return FALSE;
+  }
+
   /**
    * Implements a hook bridge for hook_views_post_render().
    *
diff --git a/templates/TrashStorageSchema.php.twig b/templates/TrashStorageSchema.php.twig
index 404d40b36a7cfacb26e86cc1a85c6f33ccdf196a..bdb0d4eed8ee3e7ec94a11ba59e3ea41ee7e63ec 100644
--- a/templates/TrashStorageSchema.php.twig
+++ b/templates/TrashStorageSchema.php.twig
@@ -10,7 +10,7 @@ class {{ trash_class }} extends \{{ original_class }} {
   /**
    * {@inheritdoc}
    */
-  protected function getSharedTableFieldSchema(FieldStorageDefinitionInterface $storage_definition, $table_name, array $column_mapping) {
+  protected function getSharedTableFieldSchema(FieldStorageDefinitionInterface $storage_definition, $table_name, array $column_mapping): array {
     $schema = parent::getSharedTableFieldSchema($storage_definition, $table_name, $column_mapping);
 
     // @todo Add the 'deleted' field to the required indexes.
diff --git a/tests/modules/trash_test/src/Entity/TrashTestEntity.php b/tests/modules/trash_test/src/Entity/TrashTestEntity.php
index b0aa97a7e1202baf7845553cb1bf4731da0f937c..9e9a11d5b564549b84ffab141fd9e128f452e252 100644
--- a/tests/modules/trash_test/src/Entity/TrashTestEntity.php
+++ b/tests/modules/trash_test/src/Entity/TrashTestEntity.php
@@ -13,11 +13,11 @@ use Drupal\Core\Field\BaseFieldDefinition;
  *   id = "trash_test_entity",
  *   label = @Translation("Trash test"),
  *   label_collection = @Translation("Trash test"),
- *   label_singular = @Translation("Trash test"),
- *   label_plural = @Translation("Trash tests"),
+ *   label_singular = @Translation("Trash test entity"),
+ *   label_plural = @Translation("Trash test entities"),
  *   label_count = @PluralTranslation(
- *     singular = "@count trash test",
- *     plural = "@count trash tests"
+ *     singular = "@count trash test entity",
+ *     plural = "@count trash test entities",
  *   ),
  *   handlers = {
  *     "route_provider" = {
diff --git a/tests/src/Functional/TrashNodeTest.php b/tests/src/Functional/TrashNodeTest.php
index 098e12be6ba62265db6ecff10ed116591ae5622a..f8b8d62fb29ccf02df0ec5ca6001edd49620a36e 100644
--- a/tests/src/Functional/TrashNodeTest.php
+++ b/tests/src/Functional/TrashNodeTest.php
@@ -28,7 +28,7 @@ class TrashNodeTest extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected static $modules = ['block', 'node', 'trash'];
+  protected static $modules = ['block', 'node', 'trash', 'trash_test'];
 
   /**
    * {@inheritdoc}
@@ -41,6 +41,12 @@ class TrashNodeTest extends BrowserTestBase {
   protected function setUp(): void {
     parent::setUp();
 
+    // Trash support for nodes is added in trash_modules_installed(), which runs
+    // after the module installer rebuilds the router, so we have to do it
+    // manually here.
+    // @todo Revisit after https://www.drupal.org/i/3496588
+    \Drupal::service('router.builder')->rebuild();
+
     // Create Basic page node type.
     if ($this->profile != 'standard') {
       $this->drupalCreateContentType([
@@ -69,6 +75,7 @@ class TrashNodeTest extends BrowserTestBase {
       'view deleted entities',
       'purge node entities',
       'restore node entities',
+      'administer trash',
     ]);
     $this->drupalPlaceBlock('local_tasks_block', ['id' => 'page_tabs_block']);
     $this->drupalPlaceBlock('local_actions_block', ['id' => 'page_actions_block']);
@@ -128,6 +135,33 @@ class TrashNodeTest extends BrowserTestBase {
     $this->assertSession()->statusMessageContains('The content item ' . $node->getTitle() . ' has been restored from trash.', 'status');
   }
 
+  /**
+   * Tests that the trash confirmation text varies as expected.
+   */
+  public function testTrashConfirmationTextVariesAppropriately(): void {
+    // Login as a privileged user.
+    $this->drupalLogin($this->adminUser);
+
+    $node = $this->drupalCreateNode([
+      'title' => $this->randomMachineName(8),
+    ]);
+
+    $this->drupalGet('node/' . $node->id() . '/delete');
+    $this->assertSession()->pageTextContains('Deleting this content item will move it to the trash. You can restore it from the trash at a later date if necessary.');
+
+    // Enable auto purge.
+    $this->drupalGet('admin/config/content/trash');
+    $edit = [
+      'auto_purge[enabled]' => TRUE,
+      'auto_purge[after]' => '30 days',
+    ];
+    $this->submitForm($edit, 'Save configuration');
+    $this->assertSession()->statusCodeEquals(200);
+
+    $this->drupalGet('node/' . $node->id() . '/delete');
+    $this->assertSession()->pageTextContains('Deleting this content item will move it to the trash. You can restore it from the trash for a limited period of time (');
+  }
+
   /**
    * Test moving a node to the trash bin and purging it.
    */
@@ -156,6 +190,48 @@ class TrashNodeTest extends BrowserTestBase {
     $this->assertSession()->pageTextContains('This action cannot be undone.');
     $this->submitForm([], 'Confirm');
     $this->assertSession()->statusMessageContains('The content item ' . $node->getTitle() . ' has been permanently deleted.', 'status');
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertSession()->pageTextContains('There are no deleted content items.');
+  }
+
+  /**
+   * Test uninstalling a trash-enabled entity type.
+   */
+  public function testUninstallNode(): void {
+    // Login as a privileged user.
+    $this->drupalLogin($this->drupalCreateUser([
+      'administer modules',
+      'administer trash',
+    ]));
+
+    // Enable trash for one other entity type.
+    $this->drupalGet('admin/config/content/trash');
+    $this->assertSession()->statusCodeEquals(200);
+
+    $this->assertSession()->checkboxChecked('enabled_entity_types[node][enabled]');
+    $this->assertSession()->checkboxNotChecked('enabled_entity_types[trash_test_entity][enabled]');
+
+    $edit = [
+      'enabled_entity_types[node][enabled]' => TRUE,
+      'enabled_entity_types[trash_test_entity][enabled]' => TRUE,
+    ];
+    $this->submitForm($edit, 'Save configuration');
+    $this->assertSession()->statusCodeEquals(200);
+
+    $this->drupalGet('/admin/content/trash/trash_test_entity');
+    $this->assertSession()->statusCodeEquals(200);
+
+    // Uninstall the node module.
+    $edit = [
+      'uninstall[node]' => TRUE,
+    ];
+    $this->drupalGet('admin/modules/uninstall');
+    $this->submitForm($edit, 'Uninstall');
+    $this->submitForm([], 'Uninstall');
+
+    $this->drupalGet('/admin/content/trash');
+    $this->assertSession()->statusCodeEquals(200);
+    $this->assertSession()->pageTextContains('There are no deleted Trash test entities.');
   }
 
 }
diff --git a/tests/src/Kernel/EntityAccessTest.php b/tests/src/Kernel/EntityAccessTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1a9142576bac2a8339760eb9cc0eac648b6c8391
--- /dev/null
+++ b/tests/src/Kernel/EntityAccessTest.php
@@ -0,0 +1,114 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\trash\Kernel;
+
+/**
+ * Tests entity access for trashed entities.
+ *
+ * @group trash
+ */
+class EntityAccessTest extends TrashKernelTestBase {
+
+  /**
+   * Tests entity access for trashed entities.
+   *
+   * @dataProvider providerTestEntityAccess
+   */
+  public function testEntityAccess(string $permission, array $access_map): void {
+    $account = $this->createUser([
+      'access content',
+      $permission,
+    ]);
+
+    $node = $this->createNode(['type' => 'article', 'uid' => $account->id()]);
+    $node->delete();
+
+    foreach ($access_map as $operation => $access_result) {
+      $this->assertSame($access_result, $node->access($operation, $account));
+    }
+  }
+
+  /**
+   * Data provider for self::testConstructor()
+   */
+  public static function providerTestEntityAccess() {
+    return [
+      [
+        'bypass node access',
+        [
+          'view' => FALSE,
+          'update' => FALSE,
+          'delete' => FALSE,
+          'restore' => FALSE,
+          'purge' => FALSE,
+        ],
+      ],
+      [
+        'edit any article content',
+        [
+          'view' => FALSE,
+          'update' => FALSE,
+          'delete' => FALSE,
+          'restore' => FALSE,
+          'purge' => FALSE,
+        ],
+      ],
+      [
+        'delete any article content',
+        [
+          'view' => FALSE,
+          'update' => FALSE,
+          'delete' => FALSE,
+          'restore' => FALSE,
+          'purge' => FALSE,
+        ],
+      ],
+      [
+        'view deleted entities',
+        [
+          'view' => TRUE,
+          'update' => FALSE,
+          'delete' => FALSE,
+          'restore' => FALSE,
+          'purge' => FALSE,
+        ],
+      ],
+      [
+        'restore node entities',
+        [
+          'view' => FALSE,
+          'update' => FALSE,
+          'delete' => FALSE,
+          'restore' => TRUE,
+          'purge' => FALSE,
+        ],
+      ],
+      [
+        'purge node entities',
+        [
+          'view' => FALSE,
+          'update' => FALSE,
+          'delete' => FALSE,
+          'restore' => FALSE,
+          'purge' => TRUE,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests entity access for entity types that are not enabled.
+   */
+  public function testEntityAccessForNotDeletedEntity(): void {
+    $account = $this->createUser([
+      'administer users',
+    ]);
+
+    $this->assertTrue($account->access('view', $account));
+    $this->assertFalse($account->access('restore', $account));
+    $this->assertFalse($account->access('purge', $account));
+  }
+
+}
diff --git a/tests/src/Kernel/TrashKernelTest.php b/tests/src/Kernel/TrashKernelTest.php
index 554521ae06992058b24bdbde12da0eb84c4186d8..bcb82072c1ac95ee8dd8709d0ef3a41d178d3eae 100644
--- a/tests/src/Kernel/TrashKernelTest.php
+++ b/tests/src/Kernel/TrashKernelTest.php
@@ -4,7 +4,9 @@ namespace Drupal\Tests\trash\Kernel;
 
 use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\node\Entity\Node;
 use Drupal\trash_test\Entity\TrashTestEntity;
+use Drupal\user\Entity\User;
 
 /**
  * Tests basic trash functionality.
@@ -23,6 +25,7 @@ class TrashKernelTest extends TrashKernelTestBase {
     $entity_id = $entity->id();
 
     $this->assertNotNull(TrashTestEntity::load($entity_id));
+    $this->assertFalse(trash_entity_is_deleted($entity));
     $this->assertTrue($entity->get('deleted')->isEmpty());
     $this->assertNull($entity->get('deleted')->value);
 
@@ -38,6 +41,7 @@ class TrashKernelTest extends TrashKernelTestBase {
     });
     assert($entity instanceof ContentEntityInterface);
     $this->assertNotNull($entity, 'Deleted entities can still be loaded in the "ignore" trash context.');
+    $this->assertTrue(trash_entity_is_deleted($entity));
     $this->assertEquals(\Drupal::time()->getRequestTime(), $entity->get('deleted')->value);
 
     $second_entity = TrashTestEntity::create();
@@ -58,6 +62,29 @@ class TrashKernelTest extends TrashKernelTestBase {
     $this->assertEquals($second_entity_id, $entities[$second_entity_id]->id());
   }
 
+  /**
+   * @covers ::trash_entity_is_deleted
+   */
+  public function testDisabledEntityType(): void {
+    // Check a node bundle that's not trash-enabled.
+    $nonDeletableNode = $this->createNode(['type' => 'page']);
+    $nonDeletableNode->save();
+    $this->assertFalse(trash_entity_is_deleted($nonDeletableNode));
+
+    $nonDeletableNode->delete();
+    $nonDeletableNode = Node::load($nonDeletableNode->id());
+    $this->assertNull($nonDeletableNode);
+
+    // Check an entity type that's not trash-enabled.
+    $nonDeletableEntity = $this->createUser();
+    $nonDeletableEntity->save();
+    $this->assertFalse(trash_entity_is_deleted($nonDeletableEntity));
+
+    $nonDeletableEntity->delete();
+    $nonDeletableEntity = User::load($nonDeletableEntity->id());
+    $this->assertNull($nonDeletableEntity);
+  }
+
   /**
    * @covers \Drupal\trash\TrashManager::isEntityTypeEnabled
    *
diff --git a/tests/src/Kernel/TrashKernelTestBase.php b/tests/src/Kernel/TrashKernelTestBase.php
index 33564e8852bb074fb5ebcb0a8f34781083e63718..42240221af0c30746f25725273a59767304b4e21 100644
--- a/tests/src/Kernel/TrashKernelTestBase.php
+++ b/tests/src/Kernel/TrashKernelTestBase.php
@@ -3,6 +3,9 @@
 namespace Drupal\Tests\trash\Kernel;
 
 use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
 use Drupal\trash\TrashManagerInterface;
 
 /**
@@ -10,32 +13,49 @@ use Drupal\trash\TrashManagerInterface;
  */
 abstract class TrashKernelTestBase extends KernelTestBase {
 
+  use ContentTypeCreationTrait;
+  use NodeCreationTrait;
+  use UserCreationTrait;
+
   /**
    * {@inheritdoc}
    */
   protected static $modules = [
+    'field',
     'file',
+    'filter',
     'image',
     'node',
     'media',
+    'text',
     'trash',
     'trash_test',
     'user',
     'system',
   ];
 
+  /**
+   * {@inheritdoc}
+   */
+  protected bool $usesSuperUserAccessPolicy = FALSE;
+
   /**
    * {@inheritdoc}
    */
   protected function setUp(): void {
     parent::setUp();
 
-    $this->installConfig(['trash_test']);
     $this->installEntitySchema('file');
     $this->installEntitySchema('node');
     $this->installEntitySchema('media');
     $this->installEntitySchema('user');
     $this->installEntitySchema('trash_test_entity');
+    $this->installSchema('node', ['node_access']);
+    $this->installSchema('user', ['users_data']);
+    $this->installConfig(['node', 'filter', 'trash_test']);
+
+    $this->createContentType(['type' => 'article']);
+    $this->createContentType(['type' => 'page']);
 
     $config = \Drupal::configFactory()->getEditable('trash.settings');
     $enabled_entity_types = $config->get('enabled_entity_types');
@@ -43,6 +63,10 @@ abstract class TrashKernelTestBase extends KernelTestBase {
     $enabled_entity_types['node'] = ['article'];
     $config->set('enabled_entity_types', $enabled_entity_types);
     $config->save();
+
+    // Rebuild the container so trash handlers are available for the enabled
+    // entity types.
+    $this->container->get('kernel')->rebuildContainer();
   }
 
   /**
diff --git a/tests/src/Kernel/TrashWorkspacesTest.php b/tests/src/Kernel/TrashWorkspacesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1dcc4fcf08706e2566c634ef7bb44f30f5980acf
--- /dev/null
+++ b/tests/src/Kernel/TrashWorkspacesTest.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\Tests\trash\Kernel;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Tests\workspaces\Kernel\WorkspaceTestTrait;
+use Drupal\workspaces\Entity\Workspace;
+
+/**
+ * Tests Trash integration with Workspaces.
+ *
+ * @group trash
+ */
+class TrashWorkspacesTest extends TrashKernelTestBase {
+
+  use WorkspaceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'workspaces',
+  ];
+
+  /**
+   * The entity type manager.
+   */
+  protected EntityTypeManagerInterface $entityTypeManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->entityTypeManager = \Drupal::entityTypeManager();
+    $this->workspaceManager = \Drupal::service('workspaces.manager');
+
+    $this->installSchema('workspaces', ['workspace_association']);
+    $this->installEntitySchema('workspace');
+
+    $this->workspaces['stage'] = Workspace::create(['id' => 'stage', 'label' => 'Stage']);
+    $this->workspaces['stage']->save();
+
+    $this->setCurrentUser($this->createUser([
+      'view any workspace',
+    ]));
+  }
+
+  /**
+   * Test trashing entities in a workspace.
+   */
+  public function testDeletion(): void {
+    $live_node = $this->createNode(['type' => 'article']);
+    $live_node->save();
+
+    // Activate a workspace and delete the node.
+    $this->switchToWorkspace('stage');
+
+    $ws_node = $this->createNode(['type' => 'article']);
+    $ws_node->save();
+
+    $live_node->delete();
+    $ws_node->delete();
+
+    $this->assertTrue(trash_entity_is_deleted($live_node));
+    $this->assertTrue(trash_entity_is_deleted($ws_node));
+
+    // Check loading the deleted nodes in a workspace.
+    $storage = $this->entityTypeManager->getStorage('node');
+
+    $this->assertNull($storage->load($live_node->id()));
+    $this->assertNull($storage->loadRevision($live_node->getRevisionId()));
+
+    $this->assertNull($storage->load($ws_node->id()));
+    $this->assertNull($storage->loadRevision($ws_node->getRevisionId()));
+
+    // Switch back to Live and check that the nodes are not marked as deleted.
+    $this->switchToLive();
+
+    $live_node = $storage->load($live_node->id());
+    $this->assertNotNull($live_node);
+    $this->assertTrue($live_node->isPublished());
+    $this->assertNotNull($storage->loadRevision($live_node->getRevisionId()));
+    $this->assertFalse(trash_entity_is_deleted($live_node));
+
+    $ws_node = $storage->load($ws_node->id());
+    $this->assertNotNull($ws_node);
+    $this->assertFalse($ws_node->isPublished());
+    $this->assertNotNull($storage->loadRevision($ws_node->getRevisionId()));
+    $this->assertFalse(trash_entity_is_deleted($ws_node));
+
+    // Publish the workspace and check that both nodes are now deleted in Live.
+    $this->workspaces['stage']->publish();
+
+    $this->assertNull($storage->load($live_node->id()));
+    $this->assertNull($storage->load($ws_node->id()));
+  }
+
+  /**
+   * Test 'purge' entity access in a workspace.
+   */
+  public function testPurgeAccess(): void {
+    $this->setCurrentUser($this->createUser([
+      'access content',
+      'view any workspace',
+      'purge node entities',
+    ]));
+
+    $live_node = $this->createNode(['type' => 'article']);
+    $live_node->save();
+
+    // Activate a workspace and delete the node.
+    $this->switchToWorkspace('stage');
+
+    $ws_node = $this->createNode(['type' => 'article']);
+    $ws_node->save();
+
+    $live_node->delete();
+    $ws_node->delete();
+
+    $this->assertFalse($live_node->access('purge'));
+    $this->assertTrue($ws_node->access('purge'));
+  }
+
+}
diff --git a/trash.install b/trash.install
index 94d0e6a021e1484e2f80a97ce64f9c1f2097a9b7..592149de3c41c7eb305c9fef9a71525aa87baa9d 100644
--- a/trash.install
+++ b/trash.install
@@ -25,9 +25,17 @@ function trash_update_9301(): void {
 }
 
 /**
- * Add translation support to the Trash module.
+ * Add the compact_overview configuration.
  */
 function trash_update_10301(): void {
+  $config = \Drupal::configFactory()->getEditable('trash.settings');
+  $config->set('compact_overview', FALSE)->save();
+}
+
+/**
+ * Add translation support to the Trash module.
+ */
+function trash_update_10302(): void {
   $update_manager = \Drupal::entityDefinitionUpdateManager();
   $enabled_entity_types = \Drupal::service('trash.manager')->getEnabledEntityTypes();
   foreach ($enabled_entity_types as $entity_type_id) {
diff --git a/trash.libraries.yml b/trash.libraries.yml
index 8b04a3c9579095044c11e71890215ce7f55e8df8..57e19198ef5a96f2b900498d615ac322e1173ad9 100644
--- a/trash.libraries.yml
+++ b/trash.libraries.yml
@@ -1,5 +1,14 @@
 trash:
-  version: VERSION
   css:
     base:
       css/trash.css: {}
+
+trash.admin:
+  js:
+    js/trash.js: {}
+  css:
+    base:
+      css/trash.admin.css: {}
+  dependencies:
+    - core/drupal
+    - core/once
diff --git a/trash.module b/trash.module
index 2912b213bb33603a6bba5c5ef23e0366cb660c9d..5267d9d260b5743a770fa712b9916dbf76614953 100644
--- a/trash.module
+++ b/trash.module
@@ -5,28 +5,31 @@
  * Module implementation file.
  */
 
+use Drupal\Component\Serialization\Json;
 use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Database\Query\AlterableInterface;
 use Drupal\Core\Entity\ContentEntityDeleteForm;
 use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
 use Drupal\Core\Entity\Form\DeleteMultipleForm;
 use Drupal\Core\Entity\Query\QueryInterface;
 use Drupal\Core\Entity\TranslatableInterface;
 use Drupal\Core\Field\BaseFieldDefinition;
 use Drupal\Core\Form\FormStateInterface;
-use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Hook\Attribute\LegacyHook;
 use Drupal\Core\PhpStorage\PhpStorageFactory;
 use Drupal\Core\Render\Element;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Url;
 use Drupal\menu_link_content\MenuLinkContentInterface;
-use Drupal\trash\Controller\TrashController;
 use Drupal\trash\EntityHandler\TrashNodeAccessControlHandler;
 use Drupal\trash\Form\EntityPurgeForm;
 use Drupal\trash\Form\EntityRestoreForm;
+use Drupal\trash\Handler\TrashHandlerInterface;
 use Drupal\trash\ViewsQueryAlter;
 use Drupal\views\Plugin\views\query\QueryPluginBase;
 use Drupal\views\ViewExecutable;
@@ -41,8 +44,11 @@ use Drupal\views\ViewExecutable;
  *   TRUE if the entity is deleted, FALSE otherwise.
  */
 function trash_entity_is_deleted(EntityInterface $entity): bool {
-  /** @var \Drupal\Core\Entity\FieldableEntityInterface $entity */
-  return \Drupal::service('trash.manager')->isEntityTypeEnabled($entity->getEntityType(), $entity->bundle())
+  if (!$entity instanceof FieldableEntityInterface) {
+    return FALSE;
+  }
+
+  return $entity->getFieldDefinition('deleted')?->getFieldStorageDefinition()->getProvider() === 'trash'
     && !$entity->get('deleted')->isEmpty();
 }
 
@@ -94,14 +100,41 @@ function trash_entity_access(EntityInterface $entity, $operation, AccountInterfa
     return AccessResult::neutral();
   }
 
-  $entity_is_deleted = trash_entity_is_deleted($entity);
+  $cacheability = new CacheableMetadata();
+  $cacheability->addCacheContexts(['user.permissions']);
+  $cacheability->addCacheableDependency($entity);
+
+  if (trash_entity_is_deleted($entity)) {
+    // Check if users can view, restore or purge deleted entities.
+    if ($operation === 'view' && $account->hasPermission('view deleted entities')) {
+      return AccessResult::allowed()->addCacheableDependency($cacheability);
+    }
+    elseif ($operation === 'restore' && $account->hasPermission('restore ' . $entity->getEntityTypeId() . ' entities')) {
+      return AccessResult::allowed()->addCacheableDependency($cacheability);
+    }
+    elseif ($operation === 'purge' && $account->hasPermission('purge ' . $entity->getEntityTypeId() . ' entities')) {
+      // Ensure that trashed entities can only be purged in the workspace they
+      // were created in or in Live.
+      if (\Drupal::hasService('workspaces.manager')
+        && \Drupal::service('workspaces.information')->isEntitySupported($entity)
+        && ($active_workspace = \Drupal::service('workspaces.manager')->getActiveWorkspace())
+        && !\Drupal::service('workspaces.information')->isEntityDeletable($entity, $active_workspace)
+      ) {
+        $cacheability->addCacheableDependency($active_workspace);
+        return AccessResult::forbidden()->addCacheableDependency($cacheability);
+      }
 
-  // Check if users can view deleted entities.
-  if ($operation === 'view' && $entity_is_deleted && $account->hasPermission('view deleted entities')) {
-    return AccessResult::allowed()->cachePerPermissions()->addCacheableDependency($entity);
+      return AccessResult::allowed()->addCacheableDependency($cacheability);
+    }
+    else {
+      return AccessResult::forbidden()->addCacheableDependency($cacheability);
+    }
   }
 
-  return AccessResult::forbiddenIf($entity_is_deleted)->addCacheableDependency($entity);
+  // If the entity is not deleted, the 'restore' and 'purge' operations should
+  // not be allowed.
+  return AccessResult::forbiddenIf($operation === 'restore' || $operation === 'purge')
+    ->addCacheableDependency($cacheability);
 }
 
 /**
@@ -142,15 +175,10 @@ function trash_entity_query_alter(QueryInterface $query): void {
 /**
  * Implements hook_query_TAG_alter() for the 'search_node_search' tag.
  */
+#[LegacyHook]
 function trash_query_search_node_search_alter(AlterableInterface $query) {
-  /** @var \Drupal\Core\Database\Query\SelectInterface $query */
-  $entity_type = \Drupal::entityTypeManager()->getDefinition('node');
-  if (\Drupal::service('trash.manager')->isEntityTypeEnabled($entity_type)) {
-    // The core Search module is not using an entity query, so we need to alter
-    // its query manually.
-    // @see \Drupal\node\Plugin\Search\NodeSearch::findResults()
-    $query->isNull('n.deleted');
-  }
+  // @phpstan-ignore-next-line
+  \Drupal::service('trash.manager')->getHandler('node')?->querySearchNodeSearchAlter($query);
 }
 
 /**
@@ -220,6 +248,7 @@ function _trash_generate_storage_class($original_class, $type = 'storage') {
  */
 function trash_entity_type_alter(array &$entity_types) {
   $is_multilingual = \Drupal::languageManager()->isMultilingual();
+
   /** @var \Drupal\Core\Entity\EntityTypeInterface[] $entity_types */
   foreach ($entity_types as $entity_type_id => $entity_type) {
     if (\Drupal::service('trash.manager')->isEntityTypeEnabled($entity_type)) {
@@ -239,6 +268,7 @@ function trash_entity_type_alter(array &$entity_types) {
       }
       $entity_type->setLinkTemplate('restore', $base_path . '/restore');
       $entity_type->setLinkTemplate('purge', $base_path . '/purge');
+
       if ($is_multilingual && $entity_type->isTranslatable()) {
         $entity_type->setLinkTemplate('restore-translation', $base_path . '/restore/{language}');
         $entity_type->setLinkTemplate('purge-translation', $base_path . '/purge/{language}');
@@ -260,10 +290,13 @@ function trash_form_alter(&$form, FormStateInterface $form_state, $form_id) {
   $form_object = $form_state->getFormObject();
   $is_entity_delete_form = $form_object instanceof ContentEntityDeleteForm;
   $is_entity_multiple_delete_form = $form_object instanceof DeleteMultipleForm;
-  if (!($is_entity_delete_form || $is_entity_multiple_delete_form)) {
+  $is_vbo_confirm_action_form = $form_id === 'views_bulk_operations_confirm_action';
+
+  if (!($is_entity_delete_form || $is_entity_multiple_delete_form || $is_vbo_confirm_action_form)) {
     return;
   }
 
+  $entity_type = $bundle = $entity = NULL;
   if ($is_entity_delete_form) {
     assert($form_object instanceof ContentEntityDeleteForm);
     $entity = $form_object->getEntity();
@@ -272,64 +305,152 @@ function trash_form_alter(&$form, FormStateInterface $form_state, $form_id) {
   }
   elseif ($is_entity_multiple_delete_form) {
     assert($form_object instanceof DeleteMultipleForm);
-    $entity = NULL;
     $entity_type_call = function () {
       // @phpstan-ignore-next-line
       return $this->entityType;
     };
     $entity_type = $entity_type_call->call($form_object);
-    $bundle = NULL;
   }
-  if (!\Drupal::service('trash.manager')->isEntityTypeEnabled($entity_type, $bundle)) {
+  elseif ($is_vbo_confirm_action_form) {
+    $vbo_form_data = $form_state->getStorage()['views_bulk_operations'];
+    if ($vbo_form_data['action_id'] !== 'views_bulk_operations_delete_entity') {
+      return;
+    }
+
+    // Get the first item in the VBO form data to check its entity type.
+    $first_item = reset($vbo_form_data['list']);
+    // @see \Drupal\views_bulk_operations\Form\ViewsBulkOperationsFormTrait::calculateEntityBulkFormKey()
+    $entity_type = \Drupal::entityTypeManager()->getDefinition($first_item[2]);
+  }
+
+  if ($entity_type && !\Drupal::service('trash.manager')->isEntityTypeEnabled($entity_type, $bundle)) {
     return;
   }
 
   // Change the message of the delete confirmation form to mention the actual
   // action that is about to happen.
-  if (isset($form['description']['#markup']) && $form['description']['#markup'] instanceof TranslatableMarkup) {
-    $params = [
-      '@label' => $is_entity_delete_form ? $entity_type->getSingularLabel() : $entity_type->getPluralLabel(),
-      ':link' => Url::fromRoute('trash.admin_content_trash_entity_type', [
-        'entity_type_id' => $entity_type->id(),
-      ])->toString(),
-    ];
+  $params = [
+    '@label' => !$is_entity_delete_form ? $entity_type->getPluralLabel() : $entity_type->getSingularLabel(),
+    ':link' => Url::fromRoute('trash.admin_content_trash_entity_type', [
+      'entity_type_id' => $entity_type->id(),
+    ])->toString(),
+  ];
 
-    // Use different messages based on the user's access level.
-    if (\Drupal::currentUser()->hasPermission('restore ' . $entity_type->id() . ' entities')) {
-      $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>. You can restore it from the trash at a later date if necessary.', $params);
-      $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>. You can restore them from the trash at a later date if necessary.', $params);
-    }
-    elseif (\Drupal::currentUser()->hasPermission('access trash')) {
-      $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>.', $params);
-      $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>.', $params);
+  // Use different messages based on the user's access level.
+  if (\Drupal::currentUser()->hasPermission('restore ' . $entity_type->id() . ' entities')) {
+    $trash_settings = \Drupal::configFactory()->get('trash.settings');
+    if ($trash_settings->get('auto_purge.enabled')) {
+      $timestamp = strtotime(sprintf('+%s', $trash_settings->get('auto_purge.after')));
+      $params['@time_period'] = \Drupal::service('date.formatter')->formatDiff(\Drupal::time()->getCurrentTime(), $timestamp);
+
+      $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>. You can restore it from the trash for a limited period of time (@time_period) if necessary.', $params);
+      $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>. You can restore them from the trash for a limited period of time (@time_period) if necessary.', $params);
     }
     else {
-      $entity_delete_label = t('Deleting this @label will move it to the trash.', $params);
-      $entity_multiple_delete_label = t('Deleting these @label will move them to the trash.', $params);
+      $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>. You can restore it from the trash at a later date if necessary.', $params);
+      $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>. You can restore them from the trash at a later date if necessary.', $params);
     }
+  }
+  elseif (\Drupal::currentUser()->hasPermission('access trash')) {
+    $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>.', $params);
+    $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>.', $params);
+  }
+  else {
+    $entity_delete_label = t('Deleting this @label will move it to the trash.', $params);
+    $entity_multiple_delete_label = t('Deleting these @label will move them to the trash.', $params);
+  }
 
+  $trash_handler = \Drupal::service('trash.manager')->getHandler($entity_type->id());
+  assert($trash_handler instanceof TrashHandlerInterface);
+  if (isset($form['description']['#markup']) && $form['description']['#markup'] instanceof TranslatableMarkup) {
     if ($form['description']['#markup']->getUntranslatedString() === 'This action cannot be undone.') {
       if ($is_entity_delete_form) {
         $form['description']['#markup'] = $entity_delete_label;
+        $trash_handler->deleteFormAlter($form, $form_state);
       }
       elseif ($is_entity_multiple_delete_form) {
         $form['description']['#markup'] = $entity_multiple_delete_label;
+        $trash_handler->deleteFormAlter($form, $form_state, TRUE);
       }
     }
   }
+  elseif ($is_vbo_confirm_action_form) {
+    $form['description'] = [
+      '#markup' => $entity_multiple_delete_label,
+      '#weight' => -10,
+    ];
+    $trash_handler->deleteFormAlter($form, $form_state, TRUE);
+  }
 }
 
 /**
  * Implements hook_entity_operation_alter().
  */
-function trash_entity_operation_alter(array &$operations, EntityInterface $entity) {
-  if (\Drupal::service('trash.manager')->isEntityTypeEnabled($entity->getEntityType()) && trash_entity_is_deleted($entity)) {
-    // Operations are intentionally overwritten.
-    $url_options = [
-      'language' => \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE),
-      'query' => \Drupal::destination()->getAsArray(),
+function trash_entity_operation_alter(array &$operations, EntityInterface $entity): void {
+  // Skip access checks for non-deleted entities.
+  if (!trash_entity_is_deleted($entity)) {
+    return;
+  }
+
+  $url_options = [
+    'language' => \Drupal::languageManager()->getCurrentLanguage(),
+  ];
+
+  // Remove all other operations for deleted entities.
+  $operations = [];
+  if ($entity->access('restore')) {
+    $url_options['attributes']['aria-label'] = t('Restore @label', [
+      '@label' => $entity->label() ?? $entity->id(),
+    ]);
+
+    if ($entity instanceof TranslatableInterface && !$entity->isDefaultTranslation()) {
+      $restore_url = $entity->toUrl('restore-translation')
+        ->mergeOptions($url_options)
+        ->setRouteParameter('language', $entity->language()->getId());
+    }
+    else {
+      $restore_url = $entity->toUrl('restore')->mergeOptions($url_options);
+    }
+
+    $operations['restore'] = [
+      'title' => t('Restore'),
+      'url' => $restore_url,
+      'weight' => 0,
+      'attributes' => [
+        'class' => ['use-ajax'],
+        'data-dialog-type' => 'modal',
+        'data-dialog-options' => Json::encode([
+          'width' => 880,
+        ]),
+      ],
+    ];
+  }
+  if ($entity->access('purge')) {
+    $url_options['attributes']['aria-label'] = t('Purge @label', [
+      '@label' => $entity->label() ?? $entity->id(),
+    ]);
+
+    if ($entity instanceof TranslatableInterface && !$entity->isDefaultTranslation()) {
+      $purge_url = $entity->toUrl('purge-translation')
+        ->mergeOptions($url_options)
+        ->setRouteParameter('language', $entity->language()->getId());
+    }
+    else {
+      $purge_url = $entity->toUrl('purge')->mergeOptions($url_options);
+    }
+
+    $operations['purge'] = [
+      'title' => t('Purge'),
+      'url' => $purge_url,
+      'weight' => 5,
+      'attributes' => [
+        'class' => ['use-ajax'],
+        'data-dialog-type' => 'modal',
+        'data-dialog-options' => Json::encode([
+          'width' => 880,
+        ]),
+      ],
     ];
-    $operations = TrashController::getOperations($entity, $url_options);
   }
 }
 
@@ -358,17 +479,10 @@ function trash_entity_view(array &$build, EntityInterface $entity, EntityViewDis
 /**
  * Implements hook_ENTITY_TYPE_update() for 'menu_link_content'.
  */
-function trash_menu_link_content_update(MenuLinkContentInterface $entity) {
-  // Trash doesn't invoke hooks when an entity is deleted, so it needs to handle
-  // deleting menu link definitions.
-  // Additionally, it's essential that this is implemented in an update hook
-  // rather than a presave hook because it needs to run after
-  // \Drupal\menu_link_content\Entity\MenuLinkContent::postSave(). That method
-  // might add a deleted menu link to the Live menu tree when a workspace is
-  // published, so this code needs to run afterward in order to remove it again.
-  if (trash_entity_is_deleted($entity)) {
-    \Drupal::service('plugin.manager.menu.link')->removeDefinition($entity->getPluginId(), FALSE);
-  }
+#[LegacyHook]
+function trash_menu_link_content_update(MenuLinkContentInterface $entity): void {
+  // @phpstan-ignore-next-line
+  \Drupal::service('trash.manager')->getHandler('menu_link_content')?->entityUpdate($entity);
 }
 
 /**
@@ -415,3 +529,18 @@ function trash_configurable_language_insert(EntityInterface $entity) {
     $router_builder->setRebuildNeeded();
   }
 }
+
+/**
+ * Implements hook_modules_installed().
+ */
+function trash_modules_installed($modules, $is_syncing) {
+  if (!$is_syncing && (
+    in_array('node', $modules, TRUE) ||
+    (in_array('trash', $modules, TRUE) && \Drupal::moduleHandler()->moduleExists('node'))
+  )) {
+    $trash_settings = \Drupal::configFactory()->getEditable('trash.settings');
+    $enabled_entity_types = $trash_settings->get('enabled_entity_types');
+    $enabled_entity_types['node'] = [];
+    $trash_settings->set('enabled_entity_types', $enabled_entity_types)->save();
+  }
+}
diff --git a/trash.post_update.php b/trash.post_update.php
index 8bb55cf1c4771e5aa975ad27ece8f07f1964924d..5570d5127a9bacfd54603874e69445b940b5d0a0 100644
--- a/trash.post_update.php
+++ b/trash.post_update.php
@@ -26,3 +26,10 @@ function trash_post_update_fix_missing_auto_purge(): void {
     $config->save(TRUE);
   }
 }
+
+/**
+ * Rebuild the container to register trash handlers.
+ */
+function trash_post_update_add_trash_handlers(): void {
+  // Empty update to trigger a container rebuild.
+}
diff --git a/trash.routing.yml b/trash.routing.yml
index a882d0a8e5cdd9065fc803b1250bdfc82532e4ec..2ca45c44c212a70fedefadc6105ce18c6503870d 100644
--- a/trash.routing.yml
+++ b/trash.routing.yml
@@ -12,7 +12,7 @@ trash.admin_content_trash:
     _controller: '\Drupal\trash\Controller\TrashController::listing'
     _title: 'Trash'
   requirements:
-    _permission: 'access trash'
+    _permission: 'access trash+administer trash'
 
 trash.admin_content_trash_entity_type:
   path: '/admin/content/trash/{entity_type_id}'
@@ -20,4 +20,4 @@ trash.admin_content_trash_entity_type:
     _controller: '\Drupal\trash\Controller\TrashController::listing'
     _title: 'Trash'
   requirements:
-    _permission: 'access trash'
+    _permission: 'access trash+administer trash'
diff --git a/trash.services.yml b/trash.services.yml
index fa163030778e8ca8454a2396095e409d1b092d0c..6594f4d46c5fe44a65d5032e6288bf9072924d78 100644
--- a/trash.services.yml
+++ b/trash.services.yml
@@ -1,7 +1,8 @@
 services:
   trash.manager:
     class: Drupal\trash\TrashManager
-    arguments: ['@entity.definition_update_manager', '@entity.last_installed_schema.repository', '@config.factory']
+    autowire: true
+  Drupal\trash\TrashManagerInterface: '@trash.manager'
 
   trash.entity_purger:
     class: Drupal\trash\TrashEntityPurger
@@ -9,7 +10,13 @@ services:
 
   trash.config_subscriber:
     class: Drupal\trash\EventSubscriber\TrashConfigSubscriber
-    arguments: ['@entity_type.manager', '@trash.manager', '@entity.last_installed_schema.repository', '@router.builder']
+    arguments: ['@entity_type.manager', '@trash.manager', '@entity.last_installed_schema.repository', '@router.builder', '@kernel']
+    tags:
+      - { name: event_subscriber }
+
+  trash.entity_schema_subscriber:
+    class: Drupal\trash\EventSubscriber\TrashEntitySchemaSubscriber
+    arguments: ['@trash.manager', '@config.factory']
     tags:
       - { name: event_subscriber }
 
@@ -21,7 +28,7 @@ services:
 
   trash.ignore_subscriber:
     class: Drupal\trash\EventSubscriber\TrashIgnoreSubscriber
-    arguments: ['@trash.manager']
+    arguments: ['@trash.manager', '@current_route_match']
     tags:
       - { name: event_subscriber }
 
@@ -31,14 +38,6 @@ services:
     tags:
       - { name: event_subscriber }
 
-  trash.route_enhancer:
-    class: Drupal\trash\Routing\RouteEnhancer
-    arguments: ['@current_user', '@trash.manager']
-    tags:
-      # Use a higher priority than route_enhancer.param_conversion, so trashed
-      # entities can be loaded by the entity converter.
-      - { name: route_enhancer, priority: 6000 }
-
   trash.access_check:
     class: Drupal\trash\Access\TrashAccessCheck
     arguments: ['@entity_type.manager', '@language_manager', '@trash.manager']
@@ -54,3 +53,17 @@ services:
   trash.uninstall_validator:
     class: Drupal\trash\TrashUninstallValidator
     arguments: ['@entity_type.manager', '@trash.manager']
+
+  # Trash handlers.
+  trash.handler_configurator:
+    class: Drupal\trash\Handler\TrashHandlerConfigurator
+    public: false
+    autowire: true
+
+  Drupal\trash\Hook\TrashHandler\NodeTrashHandler:
+    tags:
+      - { name: trash_handler, entity_type_id: node }
+  Drupal\trash\Hook\TrashHandler\MenuLinkContentTrashHandler:
+    autowire: true
+    tags:
+      - { name: trash_handler, entity_type_id: menu_link_content }