diff --git a/core/composer.json b/core/composer.json
index 1f60805..1f3a895 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -153,7 +153,8 @@
         "drupal/user": "self.version",
         "drupal/views": "self.version",
         "drupal/views_ui": "self.version",
-        "drupal/workflows": "self.version"
+        "drupal/workflows": "self.version",
+        "drupal/workspace": "self.version"
     },
     "extra": {
         "merge-plugin": {
diff --git a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
index 4823e6b..f2201ea 100644
--- a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
+++ b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php
@@ -8,6 +8,7 @@
 use Drupal\taxonomy\Entity\Term;
 use Drupal\Tests\SchemaCheckTestTrait;
 use Drupal\Tests\system\Functional\Module\ModuleTestBase;
+use Drupal\workspace\Entity\Workspace;
 
 /**
  * Tests the largest configuration import possible with all available modules.
@@ -93,6 +94,10 @@ public function testInstallUninstall() {
     $shortcuts = Shortcut::loadMultiple();
     entity_delete_multiple('shortcut', array_keys($shortcuts));
 
+    // Delete any workspaces so the workspace module can be uninstalled.
+    $workspaces = Workspace::loadMultiple();
+    \Drupal::entityTypeManager()->getStorage('workspace')->delete($workspaces);
+
     system_list_reset();
     $all_modules = system_rebuild_module_data();
 
diff --git a/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php b/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
index 4d8f30d..aacce4f 100644
--- a/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
+++ b/core/modules/system/tests/src/Functional/Module/InstallUninstallTest.php
@@ -4,6 +4,7 @@
 
 use Drupal\Component\Render\FormattableMarkup;
 use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\workspace\Entity\Workspace;
 
 /**
  * Install/uninstall core module and confirm table creation/deletion.
@@ -147,6 +148,12 @@ public function testInstallUninstall() {
         $this->preUninstallForum();
       }
 
+      // Delete all workspaces before uninstall.
+      if ($name == 'workspace') {
+        $workspaces = Workspace::loadMultiple();
+        \Drupal::entityTypeManager()->getStorage('workspace')->delete($workspaces);
+      }
+
       $now_installed_list = \Drupal::moduleHandler()->getModuleList();
       $added_modules = array_diff(array_keys($now_installed_list), array_keys($was_installed_list));
       while ($added_modules) {
diff --git a/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml b/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml
new file mode 100644
index 0000000..271e185
--- /dev/null
+++ b/core/modules/workspace/config/install/core.entity_form_display.workspace.workspace.deploy.yml
@@ -0,0 +1,14 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_form_mode.workspace.deploy
+  module:
+    - workspace
+id: workspace.workspace.deploy
+targetEntityType: workspace
+bundle: workspace
+mode: deploy
+content: {  }
+hidden:
+  uid: true
diff --git a/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml b/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml
new file mode 100644
index 0000000..1e02853
--- /dev/null
+++ b/core/modules/workspace/config/install/core.entity_form_mode.workspace.deploy.yml
@@ -0,0 +1,9 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - workspace
+id: workspace.deploy
+label: Deploy
+targetEntityType: workspace
+cache: true
diff --git a/core/modules/workspace/css/workspace.toolbar.css b/core/modules/workspace/css/workspace.toolbar.css
new file mode 100644
index 0000000..81ee1f9
--- /dev/null
+++ b/core/modules/workspace/css/workspace.toolbar.css
@@ -0,0 +1,54 @@
+/**
+ * @file
+ * Styling for Workspace module's toolbar tab.
+ */
+
+/* Tab appearance. */
+.toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab {
+  float: right; /* LTR */
+  background-color: #e09600;
+}
+[dir="rtl"] .toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab {
+  float: left;
+}
+.toolbar .toolbar-bar .workspace-toolbar-tab.toolbar-tab.is-live {
+  background-color: #77b259;
+}
+
+.toolbar .toolbar-bar .workspace-toolbar-tab .toolbar-item {
+  margin: 0;
+}
+
+.toolbar .toolbar-icon-workspace:before {
+  background-image: url("../icons/ffffff/workspace.svg");
+}
+
+/* Manage workspaces link */
+.toolbar .toolbar-tray-vertical .manage-workspaces {
+  text-align: right; /* LTR */
+  padding: 1em;
+}
+[dir="rtl"] .toolbar .toolbar-tray-vertical .manage-workspaces {
+  text-align: left;
+}
+.toolbar .toolbar-tray-horizontal .manage-workspaces {
+  float: right; /* LTR */
+}
+[dir="rtl"] .toolbar .toolbar-tray-horizontal .manage-workspaces {
+  float: left;
+}
+
+/* Individual workspace links */
+.toolbar-horizontal .toolbar-tray .toolbar-menu li + li {
+  border-left: 1px solid #dddddd; /* LTR */
+}
+[dir="rtl"] .toolbar-horizontal .toolbar-tray .toolbar-menu li + li {
+  border-left: 0 none;
+  border-right: 1px solid #dddddd;
+}
+.toolbar-horizontal .toolbar-tray .toolbar-menu li:last-child {
+  border-right: 1px solid #dddddd; /* LTR */
+}
+[dir="rtl"] .toolbar-horizontal .toolbar-tray .toolbar-menu li:last-child {
+  border-left: 1px solid #dddddd;
+}
diff --git a/core/modules/workspace/icons/ffffff/workspace.svg b/core/modules/workspace/icons/ffffff/workspace.svg
new file mode 100644
index 0000000..299ff26
--- /dev/null
+++ b/core/modules/workspace/icons/ffffff/workspace.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><g fill="none"><g fill="#FFF"><path d="M14 12L16 12 16 0 4 0 4 2 14 2 14 12ZM0 4L12 4 12 16 0 16 0 4Z"/></g></g></svg>
diff --git a/core/modules/workspace/src/Annotation/RepositoryHandler.php b/core/modules/workspace/src/Annotation/RepositoryHandler.php
new file mode 100644
index 0000000..89f7010
--- /dev/null
+++ b/core/modules/workspace/src/Annotation/RepositoryHandler.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace\Annotation;
+
+use Drupal\Component\Annotation\Plugin;
+
+/**
+ * Defines a RepositoryHandler annotation object.
+ *
+ * @see \Drupal\workspace\RepositoryHandlerInterface
+ * @see \Drupal\workspace\RepositoryHandlerBase
+ * @see \Drupal\workspace\RepositoryHandlerManager
+ * @see plugin_api
+ *
+ * @Annotation
+ */
+class RepositoryHandler extends Plugin {
+
+  /**
+   * The plugin ID.
+   *
+   * @var string
+   */
+  public $id;
+
+  /**
+   * The human-readable name of the repository handler plugin.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $label;
+
+  /**
+   * A short description of the repository handler plugin.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $description;
+
+  /**
+   * The human-readable category.
+   *
+   * @var \Drupal\Core\Annotation\Translation
+   *
+   * @ingroup plugin_translatable
+   */
+  public $category = '';
+
+}
diff --git a/core/modules/workspace/src/Entity/Workspace.php b/core/modules/workspace/src/Entity/Workspace.php
new file mode 100644
index 0000000..9c57347
--- /dev/null
+++ b/core/modules/workspace/src/Entity/Workspace.php
@@ -0,0 +1,220 @@
+<?php
+
+namespace Drupal\workspace\Entity;
+
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityChangedTrait;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\user\UserInterface;
+use Drupal\workspace\RepositoryHandlerInterface;
+use Drupal\workspace\WorkspaceInterface;
+use Drupal\workspace\WorkspaceManager;
+
+/**
+ * The workspace entity class.
+ *
+ * @ContentEntityType(
+ *   id = "workspace",
+ *   label = @Translation("Workspace"),
+ *   label_collection = @Translation("Workspaces"),
+ *   label_singular = @Translation("workspace"),
+ *   label_plural = @Translation("workspaces"),
+ *   label_count = @PluralTranslation(
+ *     singular = "@count workspace",
+ *     plural = "@count workspaces"
+ *   ),
+ *   handlers = {
+ *     "storage" = "Drupal\Core\Entity\Sql\SqlContentEntityStorage",
+ *     "list_builder" = "\Drupal\workspace\WorkspaceListBuilder",
+ *     "access" = "Drupal\workspace\WorkspaceAccessControlHandler",
+ *     "route_provider" = {
+ *       "html" = "\Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
+ *     },
+ *     "form" = {
+ *       "default" = "\Drupal\workspace\Form\WorkspaceForm",
+ *       "add" = "\Drupal\workspace\Form\WorkspaceForm",
+ *       "edit" = "\Drupal\workspace\Form\WorkspaceForm",
+ *       "delete" = "\Drupal\workspace\Form\WorkspaceDeleteForm",
+ *       "activate" = "\Drupal\workspace\Form\WorkspaceActivateForm",
+ *       "deploy" = "\Drupal\workspace\Form\WorkspaceDeployForm",
+ *     },
+ *   },
+ *   admin_permission = "administer workspaces",
+ *   base_table = "workspace",
+ *   revision_table = "workspace_revision",
+ *   data_table = "workspace_field_data",
+ *   revision_data_table = "workspace_field_revision",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "revision" = "revision_id",
+ *     "uuid" = "uuid",
+ *     "label" = "label",
+ *     "uid" = "uid",
+ *   },
+ *   links = {
+ *     "add-form" = "/admin/config/workflow/workspace/add",
+ *     "edit-form" = "/admin/config/workflow/workspace/{workspace}/edit",
+ *     "delete-form" = "/admin/config/workflow/workspace/{workspace}/delete",
+ *     "activate-form" = "/admin/config/workflow/workspace/{workspace}/activate",
+ *     "deploy-form" = "/admin/config/workflow/workspace/{workspace}/deploy",
+ *     "collection" = "/admin/config/workflow/workspace",
+ *   },
+ * )
+ */
+class Workspace extends ContentEntityBase implements WorkspaceInterface {
+
+  use EntityChangedTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields = parent::baseFieldDefinitions($entity_type);
+
+    $fields['id'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Workspace ID'))
+      ->setDescription(new TranslatableMarkup('The workspace ID.'))
+      ->setSetting('max_length', 128)
+      ->setRequired(TRUE)
+      ->addConstraint('UniqueField')
+      ->addConstraint('DeletedWorkspace')
+      ->addPropertyConstraints('value', ['Regex' => ['pattern' => '/^[a-z0-9_]*$/']]);
+
+    $fields['label'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Workspace name'))
+      ->setDescription(new TranslatableMarkup('The workspace name.'))
+      ->setRevisionable(TRUE)
+      ->setSetting('max_length', 128)
+      ->setRequired(TRUE);
+
+    $fields['uid'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(new TranslatableMarkup('Owner'))
+      ->setDescription(new TranslatableMarkup('The workspace owner.'))
+      ->setRevisionable(TRUE)
+      ->setSetting('target_type', 'user')
+      ->setDefaultValueCallback('Drupal\workspace\Entity\Workspace::getCurrentUserId')
+      ->setDisplayOptions('form', [
+        'type' => 'entity_reference_autocomplete',
+        'weight' => 5,
+      ])
+      ->setDisplayConfigurable('form', TRUE);
+
+    $fields['changed'] = BaseFieldDefinition::create('changed')
+      ->setLabel(new TranslatableMarkup('Changed'))
+      ->setDescription(new TranslatableMarkup('The time that the workspace was last edited.'))
+      ->setRevisionable(TRUE);
+
+    $fields['created'] = BaseFieldDefinition::create('created')
+      ->setLabel(new TranslatableMarkup('Created'))
+      ->setDescription(new TranslatableMarkup('The UNIX timestamp of when the workspace has been created.'));
+
+    $fields['target'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Target workspace'))
+      ->setDescription(new TranslatableMarkup('The workspace to push to and pull from.'))
+      ->setRevisionable(TRUE)
+      ->setRequired(TRUE)
+      ->setDefaultValue('live');
+
+    return $fields;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRepositoryHandlerPlugin() {
+    if (($target = $this->target->value) && $target !== RepositoryHandlerInterface::EMPTY_VALUE) {
+      $configuration = [
+        'source' => $this->id(),
+        'target' => $target,
+      ];
+      return \Drupal::service('plugin.manager.workspace.repository_handler')->createInstance($target, $configuration);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isDefaultWorkspace() {
+    return $this->id() === WorkspaceManager::DEFAULT_WORKSPACE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setCreatedTime($created) {
+    $this->set('created', (int) $created);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getStartTime() {
+    return $this->get('created')->value;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOwner() {
+    return $this->get('uid')->entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOwner(UserInterface $account) {
+    $this->set('uid', $account->id());
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOwnerId() {
+    return $this->get('uid')->target_id;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOwnerId($uid) {
+    $this->set('uid', $uid);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function postDelete(EntityStorageInterface $storage, array $entities) {
+    parent::postDelete($storage, $entities);
+
+    // Add the IDs of the deleted workspaces to the list of workspaces that will
+    // be purged on cron.
+    $state = \Drupal::state();
+    $deleted_workspace_ids = $state->get('workspace.deleted', []);
+    unset($entities[WorkspaceManager::DEFAULT_WORKSPACE]);
+    $deleted_workspace_ids += array_combine(array_keys($entities), array_keys($entities));
+    $state->set('workspace.deleted', $deleted_workspace_ids);
+
+    // Trigger a batch purge to allow empty workspaces to be deleted
+    // immediately.
+    \Drupal::service('workspace.manager')->purgeDeletedWorkspacesBatch();
+  }
+
+  /**
+   * Default value callback for 'uid' base field definition.
+   *
+   * @see ::baseFieldDefinitions()
+   *
+   * @return int[]
+   *   An array containing the ID of the current user.
+   */
+  public static function getCurrentUserId() {
+    return [\Drupal::currentUser()->id()];
+  }
+
+}
diff --git a/core/modules/workspace/src/Entity/WorkspaceAssociation.php b/core/modules/workspace/src/Entity/WorkspaceAssociation.php
new file mode 100644
index 0000000..099f7a2
--- /dev/null
+++ b/core/modules/workspace/src/Entity/WorkspaceAssociation.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\workspace\Entity;
+
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Defines the Workspace association entity.
+ *
+ * @ContentEntityType(
+ *   id = "workspace_association",
+ *   label = @Translation("Workspace association"),
+ *   label_singular = @Translation("workspace association"),
+ *   label_plural = @Translation("workspace associations"),
+ *   label_count = @PluralTranslation(
+ *     singular = "@count workspace association",
+ *     plural = "@count workspace associations"
+ *   ),
+ *   handlers = {
+ *     "storage" = "Drupal\workspace\WorkspaceAssociationStorage"
+ *   },
+ *   base_table = "workspace_association",
+ *   revision_table = "workspace_association_revision",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "revision" = "revision_id",
+ *     "uuid" = "uuid",
+ *   }
+ * )
+ *
+ * @internal
+ *   This entity is marked internal because it should not be used directly to
+ *   alter the workspace an entity belongs to.
+ */
+class WorkspaceAssociation extends ContentEntityBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields = parent::baseFieldDefinitions($entity_type);
+
+    $fields['workspace'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(new TranslatableMarkup('workspace'))
+      ->setDescription(new TranslatableMarkup('The workspace of the referenced content.'))
+      ->setSetting('target_type', 'workspace')
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE)
+      ->addConstraint('workspace', []);
+
+    $fields['content_entity_type_id'] = BaseFieldDefinition::create('string')
+      ->setLabel(new TranslatableMarkup('Content entity type ID'))
+      ->setDescription(new TranslatableMarkup('The ID of the content entity type associated with this workspace.'))
+      ->setSetting('max_length', EntityTypeInterface::ID_MAX_LENGTH)
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    $fields['content_entity_id'] = BaseFieldDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('Content entity ID'))
+      ->setDescription(new TranslatableMarkup('The ID of the content entity associated with this workspace.'))
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    $fields['content_entity_revision_id'] = BaseFieldDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('Content entity revision ID'))
+      ->setDescription(new TranslatableMarkup('The revision ID of the content entity associated with this workspace.'))
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    return $fields;
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityAccess.php b/core/modules/workspace/src/EntityAccess.php
new file mode 100644
index 0000000..58733f7
--- /dev/null
+++ b/core/modules/workspace/src/EntityAccess.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Service wrapper for hooks relating to entity access control.
+ *
+ * @internal
+ */
+class EntityAccess implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The workspace manager service.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a new EntityAccess instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager service.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('workspace.manager')
+    );
+  }
+
+  /**
+   * Implements a hook bridge for hook_entity_access().
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to check access for.
+   * @param string $operation
+   *   The operation being performed.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The user account making the to check access for.
+   *
+   * @return \Drupal\Core\Access\AccessResult
+   *   The result of the access check.
+   *
+   * @see hook_entity_access()
+   */
+  public function entityOperationAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    // Workspaces themselves are handled by their own access handler and we
+    // should not try to do any access checks for entity types that can not
+    // belong to a workspace.
+    if ($entity->getEntityTypeId() === 'workspace' || !$this->workspaceManager->entityTypeCanBelongToWorkspaces($entity->getEntityType())) {
+      return AccessResult::neutral();
+    }
+
+    return $this->bypassAccessResult($account);
+  }
+
+  /**
+   * Implements a hook bridge for hook_entity_create_access().
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The user account making the to check access for.
+   * @param array $context
+   *   The context of the access check.
+   * @param string $entity_bundle
+   *   The bundle of the entity.
+   *
+   * @return \Drupal\Core\Access\AccessResult
+   *   The result of the access check.
+   *
+   * @see hook_entity_create_access()
+   */
+  public function entityCreateAccess(AccountInterface $account, array $context, $entity_bundle) {
+    // Workspaces themselves are handled by their own access handler and we
+    // should not try to do any access checks for entity types that can not
+    // belong to a workspace.
+    $entity_type = $this->entityTypeManager->getDefinition($context['entity_type_id']);
+    if ($entity_type->id() === 'workspace' || !$this->workspaceManager->entityTypeCanBelongToWorkspaces($entity_type)) {
+      return AccessResult::neutral();
+    }
+
+    return $this->bypassAccessResult($account);
+  }
+
+  /**
+   * Checks the 'bypass' permissions.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The user account making the to check access for.
+   *
+   * @return \Drupal\Core\Access\AccessResult
+   *   The result of the access check.
+   */
+  protected function bypassAccessResult(AccountInterface $account) {
+    // This approach assumes that the current "global" active workspace is
+    // correct, ie, if you're "in" a given workspace then you get ALL THE PERMS
+    // to ALL THE THINGS! That's why this is a dangerous permission.
+    $active_workspace = $this->workspaceManager->getActiveWorkspace();
+
+    return AccessResult::allowedIfHasPermission($account, 'bypass entity access workspace ' . $active_workspace->id())->addCacheableDependency($active_workspace)
+      ->orIf(
+        AccessResult::allowedIf($active_workspace->getOwnerId() == $account->id())->cachePerUser()->addCacheableDependency($active_workspace)
+          ->andIf(AccessResult::allowedIfHasPermission($account, 'bypass entity access own workspace'))
+      );
+  }
+
+  /**
+   * Returns an array of workspace-specific permissions.
+   *
+   * @return array
+   *   The workspace permissions.
+   */
+  public function workspacePermissions() {
+    $perms = [];
+
+    foreach ($this->entityTypeManager->getStorage('workspace')->loadMultiple() as $workspace) {
+      /** @var \Drupal\workspace\WorkspaceInterface $workspace */
+      $perms += $this->createWorkspaceBypassPermission($workspace);
+    }
+
+    return $perms;
+  }
+
+  /**
+   * Derives the delete permission for a specific workspace.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace from which to derive the permission.
+   *
+   * @return array
+   *   A single-item array with the permission to define.
+   */
+  protected function createWorkspaceBypassPermission(WorkspaceInterface $workspace) {
+    $perms['bypass entity access workspace ' . $workspace->id()] = [
+      'title' => $this->t('Bypass content entity access in %workspace workspace', ['%workspace' => $workspace->label()]),
+      'description' => $this->t('Allow all Edit/Update/Delete permissions for all content in the %workspace workspace', ['%workspace' => $workspace->label()]),
+      'restrict access' => TRUE,
+    ];
+
+    return $perms;
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityOperations.php b/core/modules/workspace/src/EntityOperations.php
new file mode 100644
index 0000000..57fefe4
--- /dev/null
+++ b/core/modules/workspace/src/EntityOperations.php
@@ -0,0 +1,254 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\workspace\Entity\WorkspaceAssociation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class for reacting to entity events.
+ *
+ * @internal
+ */
+class EntityOperations implements ContainerInjectionInterface {
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The workspace manager service.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a new EntityOperations instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager service.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('workspace.manager')
+    );
+  }
+
+  /**
+   * Acts on entities when loaded.
+   *
+   * @see hook_entity_load()
+   */
+  public function entityLoad(array &$entities, $entity_type_id) {
+    // Only run if the entity type can belong to a workspace and we are in a
+    // non-default workspace.
+    if (!$this->workspaceManager->entityTypeCanBelongToWorkspaces($this->entityTypeManager->getDefinition($entity_type_id))
+       || (($active_workspace = $this->workspaceManager->getActiveWorkspace()) && $active_workspace->isDefaultWorkspace())) {
+      return;
+    }
+
+    // Get a list of revision IDs for entities that have a revision set for the
+    // current active workspace. If an entity has multiple revisions set for a
+    // workspace, only the one with the highest ID is returned.
+    $entity_ids = array_keys($entities);
+    $max_revision_id = 'max_content_entity_revision_id';
+    $results = $this->entityTypeManager
+      ->getStorage('workspace_association')
+      ->getAggregateQuery()
+      ->allRevisions()
+      ->aggregate('content_entity_revision_id', 'MAX', NULL, $max_revision_id)
+      ->groupBy('content_entity_id')
+      ->condition('content_entity_type_id', $entity_type_id)
+      ->condition('content_entity_id', $entity_ids, 'IN')
+      ->condition('workspace', $active_workspace->id(), '=')
+      ->execute();
+
+    // Since hook_entity_load() is called on both regular entity load as well as
+    // entity revision load, we need to prevent infinite recursion by checking
+    // whether the default revisions were already swapped with the workspace
+    // revision.
+    // @todo This recursion protection should be removed when
+    //   https://www.drupal.org/project/drupal/issues/2928888 is resolved.
+    if ($results) {
+      foreach ($results as $key => $result) {
+        if ($entities[$result['content_entity_id']]->getRevisionId() == $result[$max_revision_id]) {
+          unset($results[$key]);
+        }
+      }
+    }
+
+    if ($results) {
+      /** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
+      $storage = $this->entityTypeManager->getStorage($entity_type_id);
+
+      // Swap out every entity which has a revision set for the current active
+      // workspace.
+      $swap_revision_ids = array_column($results, $max_revision_id);
+      foreach ($storage->loadMultipleRevisions($swap_revision_ids) as $revision) {
+        $entities[$revision->id()] = $revision;
+      }
+    }
+  }
+
+  /**
+   * Acts on an entity before it is created or updated.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity being saved.
+   *
+   * @see hook_entity_presave()
+   */
+  public function entityPresave(EntityInterface $entity) {
+    /** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+    // Only run if the entity type can belong to a workspace and we are in a
+    // non-default workspace.
+    if (!$this->workspaceManager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+       || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
+      return;
+    }
+
+    // Force a new revision if the entity is not replicating.
+    if (!$entity->isNew() && !isset($entity->_isReplicating)) {
+      $entity->setNewRevision(TRUE);
+
+      // All entities in the non-default workspace are pending revisions,
+      // regardless of their publishing status. This means that when creating
+      // a published pending revision in a non-default workspace it will also be
+      // a published pending revision in the default workspace, however, it will
+      // become the default revision only when it is replicated to the default
+      // workspace.
+      $entity->isDefaultRevision(FALSE);
+    }
+
+    // When a new published entity is inserted in a non-default workspace, we
+    // actually want two revisions to be saved:
+    // - An unpublished default revision in the default ('live') workspace.
+    // - A published pending revision in the current workspace.
+    if ($entity->isNew() && $entity->isPublished()) {
+      // Keep track of the publishing status for workspace_entity_insert() and
+      // unpublish the default revision.
+      $entity->_initialPublished = TRUE;
+      $entity->setUnpublished();
+    }
+  }
+
+  /**
+   * Responds to the creation of a new entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity that was just saved.
+   *
+   * @see hook_entity_insert()
+   */
+  public function entityInsert(EntityInterface $entity) {
+    /** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+    // Only run if the entity type can belong to a workspace and we are in a
+    // non-default workspace.
+    if (!$this->workspaceManager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+       || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
+      return;
+    }
+
+    $this->trackEntity($entity);
+
+    // Handle the case when a new published entity was created in a non-default
+    // workspace and create a published pending revision for it.
+    if (isset($entity->_initialPublished)) {
+      // Operate on a clone to avoid changing the entity prior to subsequent
+      // hook_entity_insert() implementations.
+      $pending_revision = clone $entity;
+      $pending_revision->setPublished();
+      $pending_revision->isDefaultRevision(FALSE);
+      $pending_revision->save();
+    }
+  }
+
+  /**
+   * Responds to updates to an entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity that was just saved.
+   *
+   * @see hook_entity_update()
+   */
+  public function entityUpdate(EntityInterface $entity) {
+    // Only run if the entity type can belong to a workspace and we are in a
+    // non-default workspace.
+    if (!$this->workspaceManager->entityTypeCanBelongToWorkspaces($entity->getEntityType())
+       || $this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
+      return;
+    }
+
+    // Only track new revisions.
+    /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    if ($entity->getLoadedRevisionId() != $entity->getRevisionId()) {
+      $this->trackEntity($entity);
+    }
+  }
+
+  /**
+   * Updates or creates a WorkspaceAssociation entity for a given entity.
+   *
+   * If the passed-in entity can belong to a workspace and already has a
+   * WorkspaceAssociation entity, then a new revision of this will be created with
+   * the new information. Otherwise, a new WorkspaceAssociation entity is created to
+   * store the passed-in entity's information.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to update or create from.
+   */
+  protected function trackEntity(EntityInterface $entity) {
+    /** @var \Drupal\Core\Entity\RevisionableInterface|\Drupal\Core\Entity\EntityPublishedInterface $entity */
+    // If the entity is not new, check if there's an existing
+    // WorkspaceAssociation entity for it.
+    if (!$entity->isNew()) {
+      $workspace_associations = $this->entityTypeManager
+        ->getStorage('workspace_association')
+        ->loadByProperties([
+          'content_entity_type_id' => $entity->getEntityTypeId(),
+          'content_entity_id' => $entity->id(),
+        ]);
+
+      /** @var \Drupal\Core\Entity\ContentEntityInterface $workspace_association */
+      $workspace_association = reset($workspace_associations);
+    }
+
+    // If there was a WorkspaceAssociation entry create a new revision,
+    // otherwise create a new entity with the type and ID.
+    if (!empty($workspace_association)) {
+      $workspace_association->setNewRevision(TRUE);
+    }
+    else {
+      $workspace_association = WorkspaceAssociation::create([
+        'content_entity_type_id' => $entity->getEntityTypeId(),
+        'content_entity_id' => $entity->id(),
+      ]);
+    }
+
+    // Add the revision ID and the workspace ID.
+    $workspace_association->set('content_entity_revision_id', $entity->getRevisionId());
+    $workspace_association->set('workspace', $this->workspaceManager->getActiveWorkspace()->id());
+
+    // Save without updating the tracked content entity.
+    $workspace_association->save();
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php b/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php
new file mode 100644
index 0000000..c7b7429
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/PgsqlQueryFactory.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Query\QueryBase;
+use Drupal\Core\Entity\Query\Sql\pgsql\QueryFactory as BaseQueryFactory;
+use Drupal\workspace\WorkspaceManagerInterface;
+
+/**
+ * Workspace PostgreSQL specific entity query implementation.
+ */
+class PgsqlQueryFactory extends BaseQueryFactory {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a SqlQueryFactory object.
+   *
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection used by the entity query.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   */
+  public function __construct(Connection $connection, WorkspaceManagerInterface $workspace_manager) {
+    $this->connection = $connection;
+    $this->workspaceManager = $workspace_manager;
+    $this->namespaces = QueryBase::getNamespaces($this);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get(EntityTypeInterface $entity_type, $conjunction) {
+    $class = QueryBase::getClass($this->namespaces, 'Query');
+    return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAggregate(EntityTypeInterface $entity_type, $conjunction) {
+    $class = QueryBase::getClass($this->namespaces, 'QueryAggregate');
+    return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/Query.php b/core/modules/workspace/src/EntityQuery/Query.php
new file mode 100644
index 0000000..19479f1
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/Query.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Entity\Query\Sql\Query as BaseQuery;
+
+/**
+ * Alters entity queries to use a workspace revision instead of the default one.
+ */
+class Query extends BaseQuery {
+
+  use QueryTrait {
+    prepare as traitPrepare;
+  }
+
+  /**
+   * Stores the SQL expressions used to build the SQL query.
+   *
+   * The array is keyed by the expression alias and the values are the actual
+   * expressions.
+   *
+   * @var array
+   *   An array of expressions.
+   */
+  protected $sqlExpressions = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function prepare() {
+    $this->traitPrepare();
+
+    // If the prepare() method from the trait decided that we need to alter this
+    // query, we need to re-define the the key fields for fetchAllKeyed() as SQL
+    // expressions.
+    if ($this->sqlQuery->getMetaData('active_workspace_id')) {
+      $id_field = $this->entityType->getKey('id');
+      $revision_field = $this->entityType->getKey('revision');
+
+      // Since the query is against the base table, we have to take into account
+      // that the revision ID might come from the workspace_association
+      // relationship, and, as a consequence, the revision ID field is no longer
+      // a simple SQL field but an expression.
+      $this->sqlFields = [];
+      $this->sqlExpressions[$revision_field] = "COALESCE(workspace_association.content_entity_revision_id, base_table.$revision_field)";
+      $this->sqlExpressions[$id_field] = "base_table.$id_field";
+    }
+
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function finish() {
+    foreach ($this->sqlExpressions as $alias => $expression) {
+      $this->sqlQuery->addExpression($expression, $alias);
+    }
+    return parent::finish();
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/QueryAggregate.php b/core/modules/workspace/src/EntityQuery/QueryAggregate.php
new file mode 100644
index 0000000..3a1f181
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/QueryAggregate.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Entity\Query\Sql\QueryAggregate as BaseQueryAggregate;
+
+/**
+ * Alters aggregate entity queries to use a workspace revision if possible.
+ */
+class QueryAggregate extends BaseQueryAggregate {
+
+  use QueryTrait {
+    prepare as traitPrepare;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function prepare() {
+    // Aggregate entity queries do not return an array of entity IDs keyed by
+    // revision IDs, they only return the values of the aggregated fields, so we
+    // don't need to add any expressions like we do in
+    // \Drupal\workspace\EntityQuery\Query::prepare().
+    $this->traitPrepare();
+
+    // Throw away the ID fields.
+    $this->sqlFields = [];
+    return $this;
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/QueryFactory.php b/core/modules/workspace/src/EntityQuery/QueryFactory.php
new file mode 100644
index 0000000..9feae9b
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/QueryFactory.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Query\QueryBase;
+use Drupal\Core\Entity\Query\Sql\QueryFactory as BaseQueryFactory;
+use Drupal\workspace\WorkspaceManagerInterface;
+
+/**
+ * Workspace specific entity query implementation.
+ */
+class QueryFactory extends BaseQueryFactory {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a SqlQueryFactory object.
+   *
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection used by the entity query.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   */
+  public function __construct(Connection $connection, WorkspaceManagerInterface $workspace_manager) {
+    $this->connection = $connection;
+    $this->workspaceManager = $workspace_manager;
+    $this->namespaces = QueryBase::getNamespaces($this);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get(EntityTypeInterface $entity_type, $conjunction) {
+    $class = QueryBase::getClass($this->namespaces, 'Query');
+    return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getAggregate(EntityTypeInterface $entity_type, $conjunction) {
+    $class = QueryBase::getClass($this->namespaces, 'QueryAggregate');
+    return new $class($entity_type, $conjunction, $this->connection, $this->namespaces, $this->workspaceManager);
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/QueryTrait.php b/core/modules/workspace/src/EntityQuery/QueryTrait.php
new file mode 100644
index 0000000..6606106
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/QueryTrait.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\workspace\WorkspaceManagerInterface;
+
+/**
+ * Provides workspace-specific helpers for altering entity queries.
+ */
+trait QueryTrait {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a Query object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param string $conjunction
+   *   - AND: all of the conditions on the query need to match.
+   *   - OR: at least one of the conditions on the query need to match.
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection to run the query against.
+   * @param array $namespaces
+   *   List of potential namespaces of the classes belonging to this query.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   */
+  public function __construct(EntityTypeInterface $entity_type, $conjunction, Connection $connection, array $namespaces, WorkspaceManagerInterface $workspace_manager) {
+    parent::__construct($entity_type, $conjunction, $connection, $namespaces);
+
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function prepare() {
+    parent::prepare();
+
+    // Do not alter entity revision queries.
+    // @todo How about queries for the latest revision? Should we alter them to
+    //   look for the latest workspace-specific revision?
+    if ($this->allRevisions) {
+      return $this;
+    }
+
+    // Only alter the query if the active workspace is not the default one and
+    // the entity type is supported.
+    $active_workspace = $this->workspaceManager->getActiveWorkspace();
+    if (!$active_workspace->isDefaultWorkspace() && $this->workspaceManager->entityTypeCanBelongToWorkspaces($this->entityType)) {
+      $this->sqlQuery->addMetaData('active_workspace_id', $active_workspace->id());
+      $this->sqlQuery->addMetaData('simple_query', FALSE);
+
+      // LEFT JOIN 'workspace_association' to the base table of the query so we
+      // can properly include live content along with a possible workspace
+      // revision.
+      $id_field = $this->entityType->getKey('id');
+      $this->sqlQuery->leftJoin('workspace_association', 'workspace_association', "%alias.content_entity_type_id = '{$this->entityTypeId}' AND %alias.content_entity_id = base_table.$id_field AND %alias.workspace = '{$active_workspace->id()}'");
+    }
+
+    return $this;
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityQuery/Tables.php b/core/modules/workspace/src/EntityQuery/Tables.php
new file mode 100644
index 0000000..ed0eb01
--- /dev/null
+++ b/core/modules/workspace/src/EntityQuery/Tables.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Drupal\workspace\EntityQuery;
+
+use Drupal\Core\Database\Query\SelectInterface;
+use Drupal\Core\Entity\EntityType;
+use Drupal\Core\Entity\Query\Sql\Tables as BaseTables;
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
+
+/**
+ * Alters entity queries to use a workspace revision instead of the default one.
+ */
+class Tables extends BaseTables {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Workspace association table array, key is base table name, value is alias.
+   *
+   * @var array
+   */
+  protected $contentWorkspaceTables = [];
+
+  /**
+   * Keeps track of the entity type IDs for each base table of the query.
+   *
+   * The array is keyed by the base table alias and the values are entity type
+   * IDs.
+   *
+   * @var array
+   */
+  protected $baseTablesEntityType = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(SelectInterface $sql_query) {
+    parent::__construct($sql_query);
+
+    $this->workspaceManager = \Drupal::service('workspace.manager');
+
+    // The join between the first 'workspace_association' table and base table
+    // of the query is done \Drupal\workspace\EntityQuery\QueryTrait::prepare(),
+    // so we need to initialize its entry manually.
+    if ($this->sqlQuery->getMetaData('active_workspace_id')) {
+      $this->contentWorkspaceTables['base_table'] = 'workspace_association';
+      $this->baseTablesEntityType['base_table'] = $this->sqlQuery->getMetaData('entity_type');
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addField($field, $type, $langcode) {
+    // The parent method uses shared and dedicated revision tables only when the
+    // entity query is instructed to query all revisions. However, if we are
+    // looking for workspace-specific revisions, we have to force the parent
+    // method to always pick the revision tables if the field being queried is
+    // revisionable.
+    if ($active_workspace_id = $this->sqlQuery->getMetaData('active_workspace_id')) {
+      $this->sqlQuery->addMetaData('all_revisions', TRUE);
+    }
+
+    $alias = parent::addField($field, $type, $langcode);
+
+    // Restore the 'all_revisions' metadata because we don't want to interfere
+    // with the rest of the query.
+    if ($active_workspace_id) {
+      $this->sqlQuery->addMetaData('all_revisions', FALSE);
+    }
+
+    return $alias;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function addJoin($type, $table, $join_condition, $langcode, $delta = NULL) {
+    if ($this->sqlQuery->getMetaData('active_workspace_id')) {
+      // The join condition for a shared or dedicated field table is in the form
+      // of "%alias.$id_field = $base_table.$id_field". Whenever we join a field
+      // table we have to check:
+      // 1) if $base_table is of an entity type that can belong to a workspace;
+      // 2) if $id_field is the revision key of that entity type or the special
+      // 'revision_id' string used when joining dedicated field tables.
+      // If those two conditions are met, we have to update the join condition
+      // to also look for a possible workspace-specific revision using COALESCE.
+      $condition_parts = explode(' = ', $join_condition);
+      list($base_table, $id_field) = explode('.', $condition_parts[1]);
+
+      if (isset($this->baseTablesEntityType[$base_table])) {
+        $entity_type_id = $this->baseTablesEntityType[$base_table];
+        $revision_key = $this->entityManager->getDefinition($entity_type_id)->getKey('revision');
+
+        if ($id_field === $revision_key || $id_field === 'revision_id') {
+          $workspace_association_table = $this->contentWorkspaceTables[$base_table];
+          $join_condition = "{$condition_parts[0]} = COALESCE($workspace_association_table.content_entity_revision_id, {$condition_parts[1]})";
+        }
+      }
+    }
+
+    return parent::addJoin($type, $table, $join_condition, $langcode, $delta);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function addNextBaseTable(EntityType $entity_type, $table, $sql_column, FieldStorageDefinitionInterface $field_storage) {
+    $next_base_table_alias = parent::addNextBaseTable($entity_type, $table, $sql_column, $field_storage);
+
+    $active_workspace_id = $this->sqlQuery->getMetaData('active_workspace_id');
+    if ($active_workspace_id && $this->workspaceManager->entityTypeCanBelongToWorkspaces($entity_type)) {
+      $this->addWorkspaceAssociationJoin($entity_type->id(), $next_base_table_alias, $active_workspace_id);
+    }
+
+    return $next_base_table_alias;
+  }
+
+  /**
+   * Adds a new join to the 'workspace_association' table for an entity base table.
+   *
+   * This method assumes that the active workspace has already been determined
+   * to be a non-default workspace.
+   *
+   * @param string $entity_type_id
+   *   The ID of the entity type whose base table we are joining.
+   * @param string $base_table_alias
+   *   The alias of the entity type's base table.
+   * @param string $active_workspace_id
+   *   The ID of the active workspace.
+   *
+   * @return string
+   *   The alias of the joined table.
+   */
+  public function addWorkspaceAssociationJoin($entity_type_id, $base_table_alias, $active_workspace_id) {
+    if (!isset($this->contentWorkspaceTables[$base_table_alias])) {
+      $entity_type = $this->entityManager->getDefinition($entity_type_id);
+      $id_field = $entity_type->getKey('id');
+
+      // LEFT join the Workspace association entity's table so we can properly
+      // include live content along with a possible workspace-specific revision.
+      $this->contentWorkspaceTables[$base_table_alias] = $this->sqlQuery->leftJoin('workspace_association', NULL, "%alias.content_entity_type_id = '$entity_type_id' AND %alias.content_entity_id = $base_table_alias.$id_field AND %alias.workspace = '$active_workspace_id'");
+
+      $this->baseTablesEntityType[$base_table_alias] = $entity_type->id();
+    }
+    return $this->contentWorkspaceTables[$base_table_alias];
+  }
+
+}
diff --git a/core/modules/workspace/src/EntityTypeInfo.php b/core/modules/workspace/src/EntityTypeInfo.php
new file mode 100644
index 0000000..8cea472
--- /dev/null
+++ b/core/modules/workspace/src/EntityTypeInfo.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityFormInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Manipulates entity type information.
+ *
+ * This class contains primarily bridged hooks for compile-time or
+ * cache-clear-time hooks. Runtime hooks should be placed in EntityOperations.
+ *
+ * @internal
+ */
+class EntityTypeInfo implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The workspace manager service.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a new EntityTypeInfo instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager service.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, WorkspaceManagerInterface $workspace_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('workspace.manager')
+    );
+  }
+
+  /**
+   * Adds the "EntityWorkspaceConflict" constraint to eligible entity types.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface[] $entity_types
+   *   An associative array of all entity type definitions, keyed by the entity
+   *   type name. Passed by reference.
+   *
+   * @see hook_entity_type_build()
+   */
+  public function entityTypeBuild(array &$entity_types) {
+    foreach ($entity_types as $entity_type) {
+      if ($this->workspaceManager->entityTypeCanBelongToWorkspaces($entity_type)) {
+        $entity_type->addConstraint('EntityWorkspaceConflict');
+      }
+    }
+  }
+
+  /**
+   * Alters entity forms to disallow concurrent editing in multiple workspaces.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   * @param string $form_id
+   *   The form ID.
+   *
+   * @see hook_form_alter()
+   */
+  public function formAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    $form_object = $form_state->getFormObject();
+    if ($form_object instanceof EntityFormInterface) {
+      $entity = $form_object->getEntity();
+
+      if ($this->workspaceManager->entityTypeCanBelongToWorkspaces($entity->getEntityType())) {
+        /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+        $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+        if ($workspace_ids = $workspace_association_storage->isEntityTracked($entity)) {
+          // An entity can only be edited in one workspace.
+          $workspace_id = reset($workspace_ids);
+
+          if ($workspace_id != $this->workspaceManager->getActiveWorkspace()->id()) {
+            $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
+
+            $form['#markup'] = $this->t('The content is being edited in the %label workspace.', ['%label' => $workspace->label()]);
+            $form['#access'] = FALSE;
+          }
+        }
+      }
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceActivateForm.php b/core/modules/workspace/src/Form/WorkspaceActivateForm.php
new file mode 100644
index 0000000..4da5cad
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceActivateForm.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\workspace\WorkspaceManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Handle activation of a workspace on administrative pages.
+ */
+class WorkspaceActivateForm extends EntityConfirmFormBase {
+
+  /**
+   * The workspace entity.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $entity;
+
+  /**
+   * The workspace replication manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * Constructs a new WorkspaceActivateForm.
+   *
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   */
+  public function __construct(WorkspaceManagerInterface $workspace_manager, MessengerInterface $messenger) {
+    $this->workspaceManager = $workspace_manager;
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('workspace.manager'),
+      $container->get('messenger')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Would you like to activate the %workspace workspace?', ['%workspace' => $this->entity->label()]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return $this->t('Activate the %workspace workspace.', ['%workspace' => $this->entity->label()]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl() {
+    return $this->entity->toUrl('collection');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildForm($form, $form_state);
+
+    // Content entity forms do not use the parent's #after_build callback.
+    unset($form['#after_build']);
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function actions(array $form, FormStateInterface $form_state) {
+    $actions = parent::actions($form, $form_state);
+    $actions['cancel']['#attributes']['class'][] = 'dialog-cancel';
+    return $actions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    try {
+      $this->workspaceManager->setActiveWorkspace($this->entity);
+      $this->messenger->addMessage($this->t("@workspace is now the active workspace.", ['@workspace' => $this->entity->label()]));
+      $form_state->setRedirectUrl($this->entity->toUrl('collection'));
+    }
+    catch (\Exception $e) {
+      watchdog_exception('workspace', $e);
+      $this->messenger->addError($e->getMessage());
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceDeleteForm.php b/core/modules/workspace/src/Form/WorkspaceDeleteForm.php
new file mode 100644
index 0000000..43311b2
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceDeleteForm.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Core\Entity\ContentEntityDeleteForm;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides a form for deleting a workspace.
+ *
+ * @internal
+ */
+class WorkspaceDeleteForm extends ContentEntityDeleteForm {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildForm($form, $form_state);
+    $source_rev_diff = $this->entity->getRepositoryHandlerPlugin()->getSourceRevisionDifference();
+    $items = [];
+    foreach ($source_rev_diff as $entity_type_id => $revision_ids) {
+      $label = $this->entityTypeManager->getDefinition($entity_type_id)->getLabel();
+      $items[] = $this->formatPlural(count($revision_ids), '1 @label revision.', '@count @label revisions.', ['@label' => $label]);
+    }
+    $form['revisions'] = [
+      '#theme' => 'item_list',
+      '#title' => $this->t('The following will also be deleted:'),
+      '#items' => $items,
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return $this->t('This action cannot be undone, and will also delete all content created in this workspace.');
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceDeployForm.php b/core/modules/workspace/src/Form/WorkspaceDeployForm.php
new file mode 100644
index 0000000..7bbf345
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceDeployForm.php
@@ -0,0 +1,200 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Entity\ContentEntityForm;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+/**
+ * Provides the workspace deploy form.
+ */
+class WorkspaceDeployForm extends ContentEntityForm {
+
+  /**
+   * The workspace entity.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $entity;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * Constructs a new WorkspaceDeployForm.
+   *
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager service.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
+   *   The entity type bundle service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   */
+  public function __construct(EntityManagerInterface $entity_manager, MessengerInterface $messenger, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL) {
+    parent::__construct($entity_manager, $entity_type_bundle_info, $time);
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity.manager'),
+      $container->get('messenger'),
+      $container->get('entity_type.bundle.info'),
+      $container->get('datetime.time')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $form = parent::form($form, $form_state);
+
+    $repository_handler = $this->entity->getRepositoryHandlerPlugin();
+
+    // We can not push or pull if we do not have a valid target.
+    if (!$repository_handler) {
+      throw new HttpException(500, 'The specified repository handler plugin does not exist.');
+    }
+
+    $args = [
+      '%source_label' => $this->entity->label(),
+      '%target_label' => $repository_handler->getLabel(),
+    ];
+    $form['#title'] = $this->t('Deploy %source_label workspace', $args);
+
+    // List the changes that can be pushed.
+    if ($source_rev_diff = $repository_handler->getSourceRevisionDifference()) {
+      $total_count = count($source_rev_diff, COUNT_RECURSIVE) - count($source_rev_diff);
+      $form['deploy'] = [
+        '#theme' => 'item_list',
+        '#title' => $this->formatPlural($total_count, 'There is @count item that can be deployed from %source_label to %target_label', 'There are @count items that can be deployed from %source_label to %target_label', $args),
+        '#items' => [],
+        '#total_count' => $total_count,
+      ];
+      foreach ($source_rev_diff as $entity_type_id => $revision_difference) {
+        $form['deploy']['#items'][$entity_type_id] = $this->entityTypeManager->getDefinition($entity_type_id)->getCountLabel(count($revision_difference));
+      }
+    }
+
+    // List the changes that can be pulled.
+    if ($target_rev_diff = $repository_handler->getTargetRevisionDifference()) {
+      $total_count = count($target_rev_diff, COUNT_RECURSIVE) - count($target_rev_diff);
+      $form['refresh'] = [
+        '#theme' => 'item_list',
+        '#title' => $this->formatPlural($total_count, 'There is @count item that can be refreshed from %target_label to %source_label', 'There are @count items that can be refreshed from %target_label to %source_label', $args),
+        '#items' => [],
+        '#total_count' => $total_count,
+      ];
+      foreach ($target_rev_diff as $entity_type_id => $revision_difference) {
+        $form['deploy']['#items'][$entity_type_id] = $this->entityTypeManager->getDefinition($entity_type_id)->getCountLabel(count($revision_difference));
+      }
+    }
+
+    // If there are no changes to push or pull, show an informational message.
+    if (!isset($form['deploy']) && !isset($form['refresh'])) {
+      $form['help'] = [
+        '#markup' => $this->t('There are no changes that can be deployed from %source_label to %target_label.', $args),
+      ];
+    }
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function actions(array $form, FormStateInterface $form_state) {
+    $elements = parent::actions($form, $form_state);
+
+    $repositoy_handler = $this->entity->getRepositoryHandlerPlugin();
+
+    if (isset($form['deploy'])) {
+      $total_count = $form['deploy']['#total_count'];
+      $elements['submit']['#value'] = $this->formatPlural($total_count, 'Deploy @count item to @target', 'Deploy @count items to @target', ['@target' => $repositoy_handler->getLabel()]);
+      $elements['submit']['#submit'] = ['::submitForm', '::deploy'];
+    }
+    else {
+      // Do not allow the 'Deploy' operation if there's nothing to push.
+      $elements['submit']['#value'] = $this->t('Deploy');
+      $elements['submit']['#disabled'] = TRUE;
+    }
+
+    // Only show the 'Refresh' operation if there's something to pull.
+    if (isset($form['refresh'])) {
+      $total_count = $form['refresh']['#total_count'];
+      $elements['refresh'] = [
+        '#type' => 'submit',
+        '#value' => $this->formatPlural($total_count, 'Refresh @count item from @target', 'Refresh @count items from @target', ['@target' => $repositoy_handler->getLabel()]),
+        '#submit' => ['::submitForm', '::refresh'],
+      ];
+    }
+
+    $elements['cancel'] = [
+      '#type' => 'link',
+      '#title' => $this->t('Cancel'),
+      '#attributes' => ['class' => ['button']],
+      '#url' => $this->entity->toUrl('collection'),
+    ];
+
+    return $elements;
+  }
+
+  /**
+   * Form submission handler; deploys the content to the workspace's target.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  public function deploy(array &$form, FormStateInterface $form_state) {
+    $workspace = $this->entity;
+
+    try {
+      $workspace->getRepositoryHandlerPlugin()->push();
+      $this->messenger->addMessage($this->t('Successful deployment.'));
+    }
+    catch (\Exception $e) {
+      watchdog_exception('workspace', $e);
+      $this->messenger->addMessage($this->t('Deployment error'), 'error');
+    }
+  }
+
+  /**
+   * Form submission handler; pulls the target's content into a workspace.
+   *
+   * @param array $form
+   *   An associative array containing the structure of the form.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  public function refresh(array &$form, FormStateInterface $form_state) {
+    $workspace = $this->entity;
+
+    try {
+      $workspace->getRepositoryHandlerPlugin()->pull();
+      $this->messenger->addMessage($this->t('Refresh successful.'));
+    }
+    catch (\Exception $e) {
+      watchdog_exception('workspace', $e);
+      $this->messenger->addMessage($this->t('refresh error'), 'error');
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceForm.php b/core/modules/workspace/src/Form/WorkspaceForm.php
new file mode 100644
index 0000000..cc0b85c
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceForm.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Entity\ContentEntityForm;
+use Drupal\Core\Entity\EntityConstraintViolationListInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Url;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Form controller for the workspace edit forms.
+ */
+class WorkspaceForm extends ContentEntityForm {
+
+  /**
+   * The workspace entity.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $entity;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * Constructs a new WorkspaceForm.
+   *
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager service.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info
+   *   The entity type bundle service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   */
+  public function __construct(EntityManagerInterface $entity_manager, MessengerInterface $messenger, EntityTypeBundleInfoInterface $entity_type_bundle_info = NULL, TimeInterface $time = NULL) {
+    parent::__construct($entity_manager, $entity_type_bundle_info, $time);
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity.manager'),
+      $container->get('messenger'),
+      $container->get('entity_type.bundle.info'),
+      $container->get('datetime.time')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $workspace = $this->entity;
+
+    if ($this->operation == 'edit') {
+      $form['#title'] = $this->t('Edit workspace %label', ['%label' => $workspace->label()]);
+    }
+    $form['label'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Label'),
+      '#maxlength' => 255,
+      '#default_value' => $workspace->label(),
+      '#required' => TRUE,
+    ];
+
+    $form['id'] = [
+      '#type' => 'machine_name',
+      '#title' => $this->t('Workspace ID'),
+      '#maxlength' => 255,
+      '#default_value' => $workspace->id(),
+      '#disabled' => !$workspace->isNew(),
+      '#machine_name' => [
+        'exists' => '\Drupal\workspace\Entity\Workspace::load',
+      ],
+      '#element_validate' => [],
+    ];
+
+    return parent::form($form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEditedFieldNames(FormStateInterface $form_state) {
+    return array_merge([
+      'label',
+      'id',
+    ], parent::getEditedFieldNames($form_state));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) {
+    // Manually flag violations of fields not handled by the form display. This
+    // is necessary as entity form displays only flag violations for fields
+    // contained in the display.
+    $field_names = [
+      'label',
+      'id',
+    ];
+    foreach ($violations->getByFields($field_names) as $violation) {
+      list($field_name) = explode('.', $violation->getPropertyPath(), 2);
+      $form_state->setErrorByName($field_name, $violation->getMessage());
+    }
+    parent::flagViolations($violations, $form, $form_state);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    $workspace = $this->entity;
+    $workspace->setNewRevision(TRUE);
+    $status = $workspace->save();
+
+    $info = ['%info' => $workspace->label()];
+    $context = ['@type' => $workspace->bundle(), '%info' => $workspace->label()];
+    $logger = $this->logger('workspace');
+
+    if ($status == SAVED_UPDATED) {
+      $logger->notice('@type: updated %info.', $context);
+      $this->messenger->addMessage($this->t('Workspace %info has been updated.', $info));
+    }
+    else {
+      $logger->notice('@type: added %info.', $context);
+      $this->messenger->addMessage($this->t('Workspace %info has been created.', $info));
+    }
+
+    if ($workspace->id()) {
+      $form_state->setValue('id', $workspace->id());
+      $form_state->set('id', $workspace->id());
+
+      $collection_url = $workspace->toUrl('collection');
+      $redirect = $collection_url->access() ? $collection_url : Url::fromRoute('<front>');
+      $form_state->setRedirectUrl($redirect);
+    }
+    else {
+      $this->messenger->addError($this->t('The workspace could not be saved.'));
+      $form_state->setRebuild();
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php
new file mode 100644
index 0000000..4e21390
--- /dev/null
+++ b/core/modules/workspace/src/Form/WorkspaceSwitcherForm.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Drupal\workspace\Form;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\workspace\WorkspaceManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a form that activates a different workspace.
+ */
+class WorkspaceSwitcherForm extends FormBase {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * The workspace entity storage handler.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $workspaceStorage;
+
+  /**
+   * The messenger service.
+   *
+   * @var \Drupal\Core\Messenger\MessengerInterface
+   */
+  protected $messenger;
+
+  /**
+   * Constructs a new WorkspaceSwitcherForm.
+   *
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   */
+  public function __construct(WorkspaceManagerInterface $workspace_manager, EntityTypeManagerInterface $entity_type_manager, MessengerInterface $messenger) {
+    $this->workspaceManager = $workspace_manager;
+    $this->workspaceStorage = $entity_type_manager->getStorage('workspace');
+    $this->messenger = $messenger;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('workspace.manager'),
+      $container->get('entity_type.manager'),
+      $container->get('messenger')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'workspace_switcher_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $workspaces = $this->workspaceStorage->loadMultiple();
+    $workspace_labels = [];
+    foreach ($workspaces as $workspace) {
+      $workspace_labels[$workspace->id()] = $workspace->label();
+    }
+
+    $active_workspace = $this->workspaceManager->getActiveWorkspace();
+    unset($workspace_labels[$active_workspace->id()]);
+
+    $form['current'] = [
+      '#type' => 'item',
+      '#title' => $this->t('Current workspace'),
+      '#markup' => $active_workspace->label(),
+      '#wrapper_attributes' => [
+        'class' => ['container-inline'],
+      ],
+    ];
+
+    $form['workspace_id'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Select workspace'),
+      '#required' => TRUE,
+      '#options' => $workspace_labels,
+      '#wrapper_attributes' => [
+        'class' => ['container-inline'],
+      ],
+    ];
+
+    $form['submit'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Activate'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    $id = $form_state->getValue('workspace_id');
+
+    // Ensure the workspace by that ID exists.
+    if (!$this->workspaceStorage->load($id)) {
+      $form_state->setErrorByName('workspace_id', $this->t('This workspace does not exist.'));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $id = $form_state->getValue('workspace_id');
+
+    /** @var \Drupal\workspace\WorkspaceInterface $workspace */
+    $workspace = $this->workspaceStorage->load($id);
+
+    try {
+      $this->workspaceManager->setActiveWorkspace($workspace);
+      $this->messenger->addMessage($this->t("@workspace is now the active workspace.", ['@workspace' => $workspace->label()]));
+      $form_state->setRedirect('<front>');
+    }
+    catch (\Exception $e) {
+      watchdog_exception('workspace', $e);
+      $this->messenger->addError($e->getMessage());
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php
new file mode 100644
index 0000000..e93d0c6
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/DefaultWorkspaceNegotiator.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\Component\Utility\Unicode;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\workspace\WorkspaceInterface;
+use Drupal\workspace\WorkspaceManager;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Defines the default workspace negotiator.
+ */
+class DefaultWorkspaceNegotiator implements WorkspaceNegotiatorInterface {
+
+  /**
+   * The workspace storage handler.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $workspaceStorage;
+
+  /**
+   * The default workspace entity.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $defaultWorkspace;
+
+  /**
+   * Constructor.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+    $this->workspaceStorage = $entity_type_manager->getStorage('workspace');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(Request $request) {
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getActiveWorkspace(Request $request) {
+    if (!$this->defaultWorkspace) {
+      $default_workspace = $this->workspaceStorage->create([
+        'id' => WorkspaceManager::DEFAULT_WORKSPACE,
+        'label' => Unicode::ucwords(WorkspaceManager::DEFAULT_WORKSPACE),
+      ]);
+      $default_workspace->enforceIsNew(FALSE);
+
+      $this->defaultWorkspace = $default_workspace;
+    }
+
+    return $this->defaultWorkspace;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setActiveWorkspace(WorkspaceInterface $workspace) {}
+
+}
diff --git a/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php
new file mode 100644
index 0000000..1d45dfd
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/SessionWorkspaceNegotiator.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\workspace\WorkspaceInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Session\Session;
+
+/**
+ * Defines the session workspace negotiator.
+ */
+class SessionWorkspaceNegotiator implements WorkspaceNegotiatorInterface {
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The session.
+   *
+   * @var \Symfony\Component\HttpFoundation\Session\Session
+   */
+  protected $session;
+
+  /**
+   * The workspace storage handler.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $workspaceStorage;
+
+  /**
+   * Constructor.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   * @param \Symfony\Component\HttpFoundation\Session\Session $session
+   *   The session.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(AccountInterface $current_user, Session $session, EntityTypeManagerInterface $entity_type_manager) {
+    $this->currentUser = $current_user;
+    $this->session = $session;
+    $this->workspaceStorage = $entity_type_manager->getStorage('workspace');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies(Request $request) {
+    // This negotiator only applies if the current user is authenticated.
+    return $this->currentUser->isAuthenticated();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getActiveWorkspace(Request $request) {
+    $workspace_id = $this->session->get('active_workspace_id');
+
+    if ($workspace_id && ($workspace = $this->workspaceStorage->load($workspace_id))) {
+      return $workspace;
+    }
+
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setActiveWorkspace(WorkspaceInterface $workspace) {
+    $this->session->set('active_workspace_id', $workspace->id());
+  }
+
+}
diff --git a/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php
new file mode 100644
index 0000000..5ec824e
--- /dev/null
+++ b/core/modules/workspace/src/Negotiator/WorkspaceNegotiatorInterface.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\workspace\Negotiator;
+
+use Drupal\workspace\WorkspaceInterface;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Workspace negotiators provide a way to get the active workspace.
+ *
+ * \Drupal\workspace\WorkspaceManager acts as the service collector for
+ * Workspace negotiators.
+ */
+interface WorkspaceNegotiatorInterface {
+
+  /**
+   * Checks whether the negotiator applies to the current request or not.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request.
+   *
+   * @return bool
+   *   TRUE if the negotiator applies for the current request, FALSE otherwise.
+   */
+  public function applies(Request $request);
+
+  /**
+   * Gets the negotiated workspace, if any.
+   *
+   * Note that it is the responsibility of each implementation to check whether
+   * the negotiated workspace actually exists in the storage.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The HTTP request.
+   *
+   * @return \Drupal\workspace\WorkspaceInterface|null
+   *   The negotiated workspace or NULL if the negotiator could not determine a
+   *   valid workspace.
+   */
+  public function getActiveWorkspace(Request $request);
+
+  /**
+   * Sets the negotiated workspace.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace entity.
+   */
+  public function setActiveWorkspace(WorkspaceInterface $workspace);
+
+}
diff --git a/core/modules/workspace/src/Plugin/Block/WorkspaceSwitcherBlock.php b/core/modules/workspace/src/Plugin/Block/WorkspaceSwitcherBlock.php
new file mode 100644
index 0000000..d47d013
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Block/WorkspaceSwitcherBlock.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Block;
+
+use Drupal\Core\Block\BlockBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBuilderInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a 'Workspace switcher' block.
+ *
+ * @Block(
+ *   id = "workspace_switcher",
+ *   admin_label = @Translation("Workspace switcher"),
+ *   category = @Translation("Workspace"),
+ * )
+ */
+class WorkspaceSwitcherBlock extends BlockBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * The form builder.
+   *
+   * @var \Drupal\Core\Form\FormBuilderInterface
+   */
+  protected $formBuilder;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs a new WorkspaceSwitcherBlock instance.
+   *
+   * @param array $configuration
+   *   The plugin configuration.
+   * @param string $plugin_id
+   *   The plugin ID.
+   * @param mixed $plugin_definition
+   *   The plugin definition.
+   * @param \Drupal\Core\Form\FormBuilderInterface $form_builder
+   *   The form builder.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, FormBuilderInterface $form_builder, EntityTypeManagerInterface $entity_type_manager) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->formBuilder = $form_builder;
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('form_builder'),
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build() {
+    $build = [
+      'form' => $this->formBuilder->getForm('Drupal\workspace\Form\WorkspaceSwitcherForm'),
+      '#cache' => [
+        'contexts' => $this->entityTypeManager->getDefinition('workspace')->getListCacheContexts(),
+        'tags' => $this->entityTypeManager->getDefinition('workspace')->getListCacheTags(),
+      ],
+    ];
+    return $build;
+  }
+
+}
diff --git a/core/modules/workspace/src/Plugin/RepositoryHandler/LiveRepositoryHandler.php b/core/modules/workspace/src/Plugin/RepositoryHandler/LiveRepositoryHandler.php
new file mode 100644
index 0000000..daaee46
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/RepositoryHandler/LiveRepositoryHandler.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace Drupal\workspace\Plugin\RepositoryHandler;
+
+use Drupal\Core\Database\Connection;
+use Drupal\workspace\RepositoryHandlerBase;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\workspace\RepositoryHandlerInterface;
+use Drupal\workspace\WorkspaceConflictException;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a plugin which replicates content to the default (Live) workspace.
+ *
+ * @RepositoryHandler(
+ *   id = "live",
+ *   label = @Translation("Live"),
+ *   description = @Translation("The default (Live) workspace."),
+ * )
+ */
+class LiveRepositoryHandler extends RepositoryHandlerBase implements RepositoryHandlerInterface, ContainerFactoryPluginInterface {
+
+  /**
+   * The source workspace entity for the repository handler.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $sourceWorkspace;
+
+  /**
+   * The target workspace entity for the repository handler.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $targetWorkspace;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * The workspace association storage.
+   *
+   * @var \Drupal\workspace\WorkspaceAssociationStorageInterface
+   */
+  protected $workspaceAssociationStorage;
+
+  /**
+   * Constructs a new DefaultRepositoryHandler.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Database\Connection $database
+   *   Database connection.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, Connection $database) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+    $this->entityTypeManager = $entity_type_manager;
+    $this->database = $database;
+    $this->workspaceAssociationStorage = $entity_type_manager->getStorage('workspace_association');
+    $this->sourceWorkspace = $this->entityTypeManager->getStorage('workspace')->load($this->source);
+    $this->targetWorkspace = $this->entityTypeManager->getStorage('workspace')->load($this->target);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('entity_type.manager'),
+      $container->get('database')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    $this->dependencies = parent::calculateDependencies();
+    $this->addDependency($this->sourceWorkspace->getConfigDependencyKey(), $this->sourceWorkspace->getConfigDependencyName());
+
+    return $this->dependencies;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function push() {
+    if ($this->checkConflictsOnTarget()) {
+      throw new WorkspaceConflictException();
+    }
+
+    $transaction = $this->database->startTransaction();
+    try {
+      foreach ($this->getSourceRevisionDifference() as $entity_type_id => $revision_difference) {
+        $entity_revisions = $this->entityTypeManager->getStorage($entity_type_id)
+          ->loadMultipleRevisions(array_keys($revision_difference));
+        /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\Core\Entity\RevisionableInterface $entity */
+        foreach ($entity_revisions as $entity) {
+          // When pushing workspace-specific revisions to the default workspace
+          // (Live), we simply need to mark them as default revisions.
+          $entity->_isReplicating = TRUE;
+          $entity->isDefaultRevision(TRUE);
+          $entity->save();
+        }
+      }
+    }
+    catch (\Exception $e) {
+      $transaction->rollBack();
+      watchdog_exception('workspace', $e);
+      throw $e;
+    }
+
+    // Notify the workspace association storage that a workspace has been
+    // pushed.
+    $this->workspaceAssociationStorage->postPush($this->sourceWorkspace);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function pull() {
+    // Nothing to do for now, pulling in changes can only be implemented when we
+    // are able to resolve conflicts.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function checkConflictsOnTarget() {
+    // Nothing to do for now, we can not get to a conflicting state because an
+    // entity which is being edited in a workspace can not be edited in any
+    // other workspace.
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTargetRevisionDifference() {
+    $target_revision_difference = [];
+
+    $tracked_entities = $this->workspaceAssociationStorage->getTrackedEntities($this->source);
+    foreach ($tracked_entities as $entity_type_id => $tracked_revisions) {
+      $entity_type = $this->entityTypeManager->getDefinition($entity_type_id);
+
+      // Get the latest revision IDs for all the entities that are tracked by
+      // the source workspace.
+      $query = $this->entityTypeManager
+        ->getStorage($entity_type_id)
+        ->getQuery()
+        ->condition($entity_type->getKey('id'), $tracked_revisions, 'IN')
+        ->latestRevision();
+      $result = $query->execute();
+
+      // Now we compare the revision IDs which are tracked by the source
+      // workspace to the latest revision IDs of those entities and the
+      // difference between these two arrays gives us all the entities which
+      // have been modified on the target.
+      if ($revision_difference = array_diff_key($result, $tracked_revisions)) {
+        $target_revision_difference[$entity_type_id] = $revision_difference;
+      }
+    }
+
+    return $target_revision_difference;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSourceRevisionDifference() {
+    // Get the Workspace association revisions which haven't been pushed yet.
+    return $this->workspaceAssociationStorage->getTrackedEntities($this->source);
+  }
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraint.php b/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraint.php
new file mode 100644
index 0000000..a33a8c7
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraint.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Deleted workspace constraint.
+ *
+ * @Constraint(
+ *   id = "DeletedWorkspace",
+ *   label = @Translation("Deleted workspace", context = "Validation"),
+ * )
+ */
+class DeletedWorkspaceConstraint extends Constraint {
+
+  /**
+   * The default violation message.
+   *
+   * @var string
+   */
+  public $message = 'A workspace with this ID has been deleted but data still exists for it.';
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php b/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php
new file mode 100644
index 0000000..d7fdb99
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/DeletedWorkspaceConstraintValidator.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\workspace\WorkspaceAssociationStorageInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Checks if data still exists for a deleted workspace ID.
+ */
+class DeletedWorkspaceConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+  /**
+   * The workspace association storage.
+   *
+   * @var \Drupal\workspace\WorkspaceAssociationStorageInterface
+   */
+  protected $workspaceAssociationStorage;
+
+  /**
+   * Creates a new DeletedWorkspaceConstraintValidator instance.
+   *
+   * @param \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage
+   *   The workspace association storage.
+   */
+  public function __construct(WorkspaceAssociationStorageInterface $workspace_association_storage) {
+    $this->workspaceAssociationStorage = $workspace_association_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager')->getStorage('workspace_association')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($value, Constraint $constraint) {
+    /** @var \Drupal\Core\Field\FieldItemListInterface $value */
+    if (!isset($value)) {
+      return;
+    }
+
+    $count = $this->workspaceAssociationStorage
+      ->getQuery()
+      ->allRevisions()
+      ->condition('workspace', $value->getEntity()->id())
+      ->count()
+      ->execute();
+    if ($count) {
+      $this->context->addViolation($constraint->message);
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraint.php b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraint.php
new file mode 100644
index 0000000..a8ebb0a
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraint.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Validation constraint for an entity being edited in multiple workspaces.
+ *
+ * @Constraint(
+ *   id = "EntityWorkspaceConflict",
+ *   label = @Translation("Entity workspace conflict", context = "Validation"),
+ *   type = {"entity"}
+ * )
+ */
+class EntityWorkspaceConflictConstraint extends Constraint {
+
+  public $message = 'The content is being edited in the %label workspace. As a result, your changes cannot be saved.';
+
+}
diff --git a/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php
new file mode 100644
index 0000000..b175932
--- /dev/null
+++ b/core/modules/workspace/src/Plugin/Validation/Constraint/EntityWorkspaceConflictConstraintValidator.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\workspace\Plugin\Validation\Constraint;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Validates the EntityWorkspaceConflict constraint.
+ */
+class EntityWorkspaceConflictConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Constructs an EntityUntranslatableFieldsConstraintValidator object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($entity, Constraint $constraint) {
+    /** @var \Drupal\Core\Entity\EntityInterface $entity */
+    if (isset($entity) && !$entity->isNew()) {
+      /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+      $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+
+      if ($workspace_ids = $workspace_association_storage->isEntityTracked($entity, FALSE)) {
+        // An entity can only be edited in one workspace.
+        $workspace_id = reset($workspace_ids);
+        $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
+
+        $this->context->buildViolation($constraint->message)
+          ->setParameter('%label', $workspace->label())
+          ->addViolation();
+      }
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerBase.php b/core/modules/workspace/src/RepositoryHandlerBase.php
new file mode 100644
index 0000000..636fc28
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerBase.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\DependencyTrait;
+use Drupal\Core\Plugin\PluginBase;
+
+/**
+ * Defines a base RepositoryHandler plugin implementation.
+ *
+ * @see \Drupal\workspace\RepositoryHandlerInterface
+ * @see \Drupal\workspace\RepositoryHandlerManager
+ * @see \Drupal\workspace\Annotation\RepositoryHandler
+ * @see plugin_api
+ */
+abstract class RepositoryHandlerBase extends PluginBase implements RepositoryHandlerInterface {
+
+  use DependencyTrait;
+
+  /**
+   * The source repository identifier.
+   *
+   * @var string
+   */
+  protected $source;
+
+  /**
+   * The target repository identifier.
+   *
+   * @var string
+   */
+  protected $target;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+    if (!isset($configuration['source'])) {
+      throw new \InvalidArgumentException('Missing repository handler source configuration');
+    }
+    if (!isset($configuration['target'])) {
+      throw new \InvalidArgumentException('Missing repository handler target configuration');
+    }
+
+    $this->source = $configuration['source'];
+    $this->target = $configuration['target'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLabel() {
+    return $this->getPluginDefinition()['label'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDescription() {
+    return $this->getPluginDefinition()['description'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    return [];
+  }
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerInterface.php b/core/modules/workspace/src/RepositoryHandlerInterface.php
new file mode 100644
index 0000000..6e49fd8
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerInterface.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Component\Plugin\DerivativeInspectionInterface;
+use Drupal\Component\Plugin\PluginInspectionInterface;
+
+/**
+ * RepositoryHandler plugins handle content replication.
+ *
+ * The replication will use data from the target repository handler plugin to
+ * merge the content between the source and the target. For example an internal
+ * replication might just need the workspace IDs, but a contrib module
+ * performing an external replication may need hostname, port, username,
+ * password etc.
+ */
+interface RepositoryHandlerInterface extends PluginInspectionInterface, DerivativeInspectionInterface {
+
+  /**
+   * Default empty value for repository handler fields.
+   */
+  const EMPTY_VALUE = '_none';
+
+  /**
+   * Indicate that an item has been updated both on the source and the target.
+   *
+   * @var int
+   */
+  const CONFLICT_UPDATE_ON_CHANGE = 1;
+
+  /**
+   * Indicate that an item updated on the source has been deleted on the target.
+   *
+   * @var int
+   */
+  const CONFLICT_UPDATE_ON_DELETE = 2;
+
+  /**
+   * Indicate that an item deleted on the source has been changed on the target.
+   *
+   * @var int
+   */
+  const CONFLICT_DELETE_ON_CHANGE = 3;
+
+  /**
+   * Returns the label of the repository handler.
+   *
+   * This is used as a form label where a user selects the replication target.
+   *
+   * @return string
+   *   The label text, which could be a plain string or an object that can be
+   *   cast to a string.
+   */
+  public function getLabel();
+
+  /**
+   * Returns the repository handler plugin description.
+   *
+   * @return string
+   *   The description text, which could be a plain string or an object that can
+   *   be cast to a string.
+   */
+  public function getDescription();
+
+  /**
+   * Pushes content from a source repository to a target repository.
+   */
+  public function push();
+
+  /**
+   * Pulls content from a target repository to a source repository.
+   */
+  public function pull();
+
+  /**
+   * Checks if there are any conflicts between the source and the target.
+   *
+   * @return array
+   *   Returns an array consisting of the number of conflicts between the source
+   *   and the target, keyed by the conflict type constant.
+   */
+  public function checkConflictsOnTarget();
+
+  /**
+   * Gets the revision identifiers for items which have changed on the target.
+   *
+   * @return array
+   *   A multidimensional array of revision identifiers, either the revision ID
+   *   or the revision UUID, keyed by entity type IDs.
+   */
+  public function getTargetRevisionDifference();
+
+  /**
+   * Gets the revision identifiers for items which have changed on the source.
+   *
+   * @return array
+   *   A multidimensional array of revision identifiers, either the revision ID
+   *   or the revision UUID, keyed by entity type IDs.
+   */
+  public function getSourceRevisionDifference();
+
+}
diff --git a/core/modules/workspace/src/RepositoryHandlerManager.php b/core/modules/workspace/src/RepositoryHandlerManager.php
new file mode 100644
index 0000000..92777b1
--- /dev/null
+++ b/core/modules/workspace/src/RepositoryHandlerManager.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Component\Plugin\CategorizingPluginManagerInterface;
+use Drupal\Core\Plugin\CategorizingPluginManagerTrait;
+use Drupal\Core\Plugin\DefaultPluginManager;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+
+/**
+ * Provides a Repository Handler Manager for Repository Handlers.
+ *
+ * @see \Drupal\workspace\Annotation\RepositoryHandler
+ * @see \Drupal\workspace\RepositoryHandlerInterface
+ * @see plugin_api
+ */
+class RepositoryHandlerManager extends DefaultPluginManager implements CategorizingPluginManagerInterface {
+
+  use CategorizingPluginManagerTrait;
+
+  /**
+   * Constructs a new RepositoryHandlerManager.
+   *
+   * @param \Traversable $namespaces
+   *   An object that implements \Traversable which contains the root paths
+   *   keyed by the corresponding namespace to look for plugin implementations.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   Cache backend instance to use.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler to invoke the alter hook with.
+   */
+  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
+    parent::__construct('Plugin/RepositoryHandler', $namespaces, $module_handler, 'Drupal\workspace\RepositoryHandlerInterface', 'Drupal\workspace\Annotation\RepositoryHandler');
+    $this->alterInfo('workspace_repository_handler_info');
+    $this->setCacheBackend($cache_backend, 'workspace_repository_handler');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function processDefinition(&$definition, $plugin_id) {
+    parent::processDefinition($definition, $plugin_id);
+    $this->processDefinitionCategory($definition);
+  }
+
+}
diff --git a/core/modules/workspace/src/ViewsQueryAlter.php b/core/modules/workspace/src/ViewsQueryAlter.php
new file mode 100644
index 0000000..6da6ae6
--- /dev/null
+++ b/core/modules/workspace/src/ViewsQueryAlter.php
@@ -0,0 +1,423 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\views\Plugin\views\query\QueryPluginBase;
+use Drupal\views\Plugin\views\query\Sql;
+use Drupal\views\Plugin\ViewsHandlerManager;
+use Drupal\views\ViewExecutable;
+use Drupal\views\ViewsData;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class for altering views queries.
+ *
+ * @internal
+ */
+class ViewsQueryAlter implements ContainerInjectionInterface {
+
+  /**
+   * The entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The entity field manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $entityFieldManager;
+
+  /**
+   * The workspace manager service.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * The views data.
+   *
+   * @var \Drupal\views\ViewsData
+   */
+  protected $viewsData;
+
+  /**
+   * A plugin manager which handles instances of views join plugins.
+   *
+   * @var \Drupal\views\Plugin\ViewsHandlerManager
+   */
+  protected $viewsJoinPluginManager;
+
+  /**
+   * Constructs a new ViewsQueryAlter instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager
+   *   The entity field manager.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager service.
+   * @param \Drupal\views\ViewsData $views_data
+   *   The views data.
+   * @param \Drupal\views\Plugin\ViewsHandlerManager $views_join_plugin_manager
+   *   The views join plugin manager.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, WorkspaceManagerInterface $workspace_manager, ViewsData $views_data, ViewsHandlerManager $views_join_plugin_manager) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->entityFieldManager = $entity_field_manager;
+    $this->workspaceManager = $workspace_manager;
+    $this->viewsData = $views_data;
+    $this->viewsJoinPluginManager = $views_join_plugin_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('entity_field.manager'),
+      $container->get('workspace.manager'),
+      $container->get('views.views_data'),
+      $container->get('plugin.manager.views.join')
+    );
+  }
+
+  /**
+   * Implements a hook bridge for hook_views_query_alter().
+   *
+   * @see hook_views_query_alter()
+   */
+  public function alterQuery(ViewExecutable $view, QueryPluginBase $query) {
+    // Don't alter any views queries if we're in the default workspace.
+    if ($this->workspaceManager->getActiveWorkspace()->isDefaultWorkspace()) {
+      return;
+    }
+
+    // Don't alter any non-sql views queries.
+    if (!$query instanceof Sql) {
+      return;
+    }
+
+    // Find out what entity types are represented in this query.
+    $entity_type_ids = [];
+    foreach ($query->relationships as $info) {
+      $table_data = $this->viewsData->get($info['base']);
+      if (empty($table_data['table']['entity type'])) {
+        continue;
+      }
+      $entity_type_id = $table_data['table']['entity type'];
+      // This construct ensures each entity type exists only once.
+      $entity_type_ids[$entity_type_id] = $entity_type_id;
+    }
+
+    $entity_type_definitions = $this->entityTypeManager->getDefinitions();
+    foreach ($entity_type_ids as $entity_type_id) {
+      if ($this->workspaceManager->entityTypeCanBelongToWorkspaces($entity_type_definitions[$entity_type_id])) {
+        $this->alterQueryForEntityType($view, $query, $entity_type_definitions[$entity_type_id]);
+      }
+    }
+  }
+
+  /**
+   * Alters the entity type tables for a Views query.
+   *
+   * @param \Drupal\views\ViewExecutable $view
+   *   The view object about to be processed.
+   * @param \Drupal\views\Plugin\views\query\Sql $query
+   *   The query plugin object for the query.
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   */
+  protected function alterQueryForEntityType(ViewExecutable $view, Sql $query, EntityTypeInterface $entity_type) {
+    // This is only called after we determined that this entity type is involved
+    // in the query, and that a non-default workspace is in use.
+    /** @var \Drupal\Core\Entity\Sql\DefaultTableMapping $table_mapping */
+    $table_mapping = $this->entityTypeManager->getStorage($entity_type->id())->getTableMapping();
+    $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
+    $dedicated_field_storage_definitions = array_filter($field_storage_definitions, function ($definition) use ($table_mapping) {
+      return $table_mapping->requiresDedicatedTableStorage($definition);
+    });
+    $dedicated_field_data_tables = array_map(function ($definition) use ($table_mapping) {
+      return $table_mapping->getDedicatedDataTableName($definition);
+    }, $dedicated_field_storage_definitions);
+
+    $move_workspace_tables = [];
+    $table_queue =& $query->getTableQueue();
+    foreach ($table_queue as $alias => &$table_info) {
+      // If we reach the workspace_association array item before any candidates,
+      // then we do not need to move it.
+      if ($table_info['table'] == 'workspace_association') {
+        break;
+      }
+
+      // Any dedicated field table is a candidate.
+      if ($field_name = array_search($table_info['table'], $dedicated_field_data_tables, TRUE)) {
+        $relationship = $table_info['relationship'];
+
+        // There can be reverse relationships used. If so, Workspace can't do
+        // anything with them. Detect this and skip.
+        if ($table_info['join']->field != 'entity_id') {
+          continue;
+        }
+
+        // Get the dedicated revision table name.
+        $new_table_name = $table_mapping->getDedicatedRevisionTableName($field_storage_definitions[$field_name]);
+
+        // Now add the workspace_association table.
+        $workspace_association_table = $this->ensureWorkspaceAssociationTable($entity_type->id(), $query, $relationship);
+
+        // Update the join to use our COALESCE.
+        $revision_field = $entity_type->getKey('revision');
+        $table_info['join']->leftTable = NULL;
+        $table_info['join']->leftField = "COALESCE($workspace_association_table.content_entity_revision_id, $relationship.$revision_field)";
+
+        // Update the join and the table info to our new table name, and to join
+        // on the revision key.
+        $table_info['table'] = $new_table_name;
+        $table_info['join']->table = $new_table_name;
+        $table_info['join']->field = 'revision_id';
+
+        // Finally, if we added the workspace_association table we have to move
+        // it in the table queue so that it comes before this field.
+        if (empty($move_workspace_tables[$workspace_association_table])) {
+          $move_workspace_tables[$workspace_association_table] = $alias;
+        }
+      }
+    }
+
+    // JOINs must be in order. i.e, any tables you mention in the ON clause of a
+    // JOIN must appear prior to that JOIN. Since we're modifying a JOIN in
+    // place, and adding a new table, we must ensure that the new table appears
+    // prior to this one. So we recorded at what index we saw that table, and
+    // then use array_splice() to move the workspace_association table join to
+    // the correct position.
+    foreach ($move_workspace_tables as $workspace_association_table => $alias) {
+      $this->moveEntityTable($query, $workspace_association_table, $alias);
+    }
+
+    $base_entity_table = $entity_type->isTranslatable() ? $entity_type->getDataTable() : $entity_type->getBaseTable();
+
+    $base_fields = array_diff($table_mapping->getFieldNames($entity_type->getBaseTable()), [$entity_type->getKey('langcode')]);
+    $revisionable_fields = array_diff($table_mapping->getFieldNames($entity_type->getRevisionDataTable()), $base_fields);
+
+    // Go through and look to see if we have to modify fields and filters.
+    foreach ($query->fields as &$field_info) {
+      // Some fields don't actually have tables, meaning they're formulae and
+      // whatnot. At this time we are going to ignore those.
+      if (empty($field_info['table'])) {
+        continue;
+      }
+
+      // Dereference the alias into the actual table.
+      $table = $table_queue[$field_info['table']]['table'];
+      if ($table == $base_entity_table && in_array($field_info['field'], $revisionable_fields)) {
+        $relationship = $table_queue[$field_info['table']]['alias'];
+        $alias = $this->ensureRevisionTable($entity_type, $query, $relationship);
+        if ($alias) {
+          // Change the base table to use the revision table instead.
+          $field_info['table'] = $alias;
+        }
+      }
+    }
+
+    $relationships = [];
+    // Build a list of all relationships that might be for our table.
+    foreach ($query->relationships as $relationship => $info) {
+      if ($info['base'] == $base_entity_table) {
+        $relationships[] = $relationship;
+      }
+    }
+
+    // Now we have to go through our where clauses and modify any of our fields.
+    foreach ($query->where as &$clauses) {
+      foreach ($clauses['conditions'] as &$where_info) {
+        // Build a matrix of our possible relationships against fields we need to
+        // switch.
+        foreach ($relationships as $relationship) {
+          foreach ($revisionable_fields as $field) {
+            if (is_string($where_info['field']) && $where_info['field'] == "$relationship.$field") {
+              $alias = $this->ensureRevisionTable($entity_type, $query, $relationship);
+              if ($alias) {
+                // Change the base table to use the revision table instead.
+                $where_info['field'] = "$alias.$field";
+              }
+            }
+          }
+        }
+      }
+    }
+
+    // @todo Handle $query->orderby, $query->groupby, $query->having,
+    //   $query->count_field.
+  }
+
+  /**
+   * Adds the 'workspace_association' table to a views query.
+   *
+   * @param string $entity_type_id
+   *   The ID of the entity type to join.
+   * @param \Drupal\views\Plugin\views\query\Sql $query
+   *   The query plugin object for the query.
+   * @param string $relationship
+   *   The primary table alias this table is related to.
+   *
+   * @return string
+   *   The alias of the 'workspace_association' table.
+   */
+  protected function ensureWorkspaceAssociationTable($entity_type_id, Sql $query, $relationship) {
+    if (isset($query->tables[$relationship]['workspace_association'])) {
+      return $query->tables[$relationship]['workspace_association']['alias'];
+    }
+
+    $table_data = $this->viewsData->get($query->relationships[$relationship]['base']);
+
+    // Construct the join.
+    $definition = [
+      'table' => 'workspace_association',
+      'field' => 'content_entity_id',
+      'left_table' => $relationship,
+      'left_field' => $table_data['table']['base']['field'],
+      'extra' => [
+        [
+          'field' => 'content_entity_type_id',
+          'value' => $entity_type_id,
+        ],
+        [
+          'field' => 'workspace',
+          'value' => $this->workspaceManager->getActiveWorkspace()->id(),
+        ],
+      ],
+      'type' => 'LEFT',
+    ];
+
+    $join = $this->viewsJoinPluginManager->createInstance('standard', $definition);
+    $join->adjusted = TRUE;
+
+    return $query->queueTable('workspace_association', $relationship, $join);
+  }
+
+  /**
+   * Adds the revision table of an entity type to a query object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param \Drupal\views\Plugin\views\query\Sql $query
+   *   The query plugin object for the query.
+   * @param string $relationship
+   *   The name of the relationship.
+   *
+   * @return string
+   *   The alias of the relationship.
+   */
+  protected function ensureRevisionTable(EntityTypeInterface $entity_type, Sql $query, $relationship) {
+    // Get the alias for the 'workspace_association' table we chain off of in the
+    // COALESCE.
+    $workspace_association_table = $this->ensureWorkspaceAssociationTable($entity_type->id(), $query, $relationship);
+
+    // Get the name of the revision table and revision key.
+    $base_revision_table = $entity_type->isTranslatable() ? $entity_type->getRevisionDataTable() : $entity_type->getRevisionTable();
+    $revision_field = $entity_type->getKey('revision');
+
+    // If the table was already added and has a join against the same field on
+    // the revision table, reuse that rather than adding a new join.
+    if (isset($query->tables[$relationship][$base_revision_table])) {
+      $table_queue =& $query->getTableQueue();
+      $alias = $query->tables[$relationship][$base_revision_table]['alias'];
+      if (isset($table_queue[$alias]['join']->field) && $table_queue[$alias]['join']->field == $revision_field) {
+        // If this table previously existed, but was not added by us, we need
+        // to modify the join and make sure that 'workspace_association' comes first.
+        if (empty($table_queue[$alias]['join']->workspace_adjusted)) {
+          $table_queue[$alias]['join'] = $this->getRevisionTableJoin($relationship, $base_revision_table, $revision_field, $workspace_association_table);
+          // We also have to ensure that our 'workspace_association' comes before
+          // this.
+          $this->moveEntityTable($query, $workspace_association_table, $alias);
+        }
+
+        return $alias;
+      }
+    }
+
+    // Construct a new join.
+    $join = $this->getRevisionTableJoin($relationship, $base_revision_table, $revision_field, $workspace_association_table);
+    return $query->queueTable($base_revision_table, $relationship, $join);
+  }
+
+  /**
+   * Fetches a join for a revision table using the workspace_association table.
+   *
+   * @param string $relationship
+   *   The relationship to use in the view.
+   * @param string $table
+   *   The table name.
+   * @param string $field
+   *   The field to join on.
+   * @param string $workspace_association_table
+   *   The alias of the 'workspace_association' table joined to the main entity
+   *   table.
+   *
+   * @return \Drupal\views\Plugin\views\join\JoinPluginInterface
+   *   An adjusted views join object to add to the query.
+   */
+  protected function getRevisionTableJoin($relationship, $table, $field, $workspace_association_table) {
+    $definition = [
+      'table' => $table,
+      'field' => $field,
+      // Making this explicitly null allows the left table to be a formula.
+      'left_table' => NULL,
+      'left_field' => "COALESCE($workspace_association_table.content_entity_revision_id, $relationship.$field)",
+    ];
+
+    /** @var \Drupal\views\Plugin\views\join\JoinPluginInterface $join */
+    $join = $this->viewsJoinPluginManager->createInstance('standard', $definition);
+    $join->adjusted = TRUE;
+    $join->workspace_adjusted = TRUE;
+
+    return $join;
+  }
+
+  /**
+   * Moves a 'workspace_association' table to appear before the given alias.
+   *
+   * Because Workspace chains possibly pre-existing tables onto the
+   * 'workspace_association' table, we have to ensure that the
+   * 'workspace_association' table appears in the query before the alias it's
+   * chained on or the SQL is invalid. This uses array_slice() to reconstruct
+   * the table queue of the query.
+   *
+   * @param \Drupal\views\Plugin\views\query\Sql $query
+   *   The SQL query object.
+   * @param string $workspace_association_table
+   *   The alias of the 'workspace_association' table.
+   * @param string $alias
+   *   The alias of the table it needs to appear before.
+   */
+  protected function moveEntityTable(Sql $query, $workspace_association_table, $alias) {
+    $table_queue =& $query->getTableQueue();
+    $keys = array_keys($table_queue);
+    $current_index = array_search($workspace_association_table, $keys);
+    $index = array_search($alias, $keys);
+
+    // If it's already before our table, we don't need to move it, as we could
+    // accidentally move it forward.
+    if ($current_index < $index) {
+      return;
+    }
+    $splice = [$workspace_association_table => $table_queue[$workspace_association_table]];
+    unset($table_queue[$workspace_association_table]);
+
+    // Now move the item to the proper location in the array. Don't use
+    // array_splice() because that breaks indices.
+    $table_queue = array_slice($table_queue, 0, $index, TRUE) +
+      $splice +
+      array_slice($table_queue, $index, NULL, TRUE);
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAccessControlHandler.php b/core/modules/workspace/src/WorkspaceAccessControlHandler.php
new file mode 100644
index 0000000..838eb58
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAccessControlHandler.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityAccessControlHandler;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Defines the access control handler for the workspace entity type.
+ *
+ * @see \Drupal\workspace\Entity\Workspace
+ */
+class WorkspaceAccessControlHandler extends EntityAccessControlHandler {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    /** @var \Drupal\workspace\WorkspaceInterface $entity */
+    if ($operation === 'delete' && $entity->isDefaultWorkspace()) {
+      return AccessResult::forbidden()->addCacheableDependency($entity);
+    }
+
+    if ($account->hasPermission('administer workspaces')) {
+      return AccessResult::allowed()->cachePerPermissions();
+    }
+
+    // The default workspace is always viewable, no matter what.
+    if ($operation == 'view' && $entity->isDefaultWorkspace()) {
+      return AccessResult::allowed()->addCacheableDependency($entity);
+    }
+
+    $permission_operation = $operation === 'update' ? 'edit' : $operation;
+
+    // Check if the user has permission to access any workspace at all.
+    $access_result = AccessResult::allowedIfHasPermission($account, $permission_operation . ' any workspace');
+
+    // Check if it's their own workspace, and they have permission to access
+    // their own workspace.
+    if ($access_result->isNeutral() && $account->isAuthenticated() && $account->id() === $entity->getOwnerId()) {
+      $access_result = AccessResult::allowedIfHasPermission($account, $permission_operation . ' own workspace')
+        ->cachePerUser()
+        ->addCacheableDependency($entity);
+    }
+
+    return $access_result;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
+    return AccessResult::allowedIfHasPermission($account, 'create workspace');
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAccessException.php b/core/modules/workspace/src/WorkspaceAccessException.php
new file mode 100644
index 0000000..210ddae
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAccessException.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Access\AccessException;
+
+/**
+ * Exception thrown when trying to switch to an inaccessible workspace.
+ */
+class WorkspaceAccessException extends AccessException {
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAssociationStorage.php b/core/modules/workspace/src/WorkspaceAssociationStorage.php
new file mode 100644
index 0000000..0f6f8d9
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAssociationStorage.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\Sql\SqlContentEntityStorage;
+
+/**
+ * Defines the storage handler class for the Workspace association entity type.
+ */
+class WorkspaceAssociationStorage extends SqlContentEntityStorage implements WorkspaceAssociationStorageInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function postPush(WorkspaceInterface $workspace) {
+    $this->database
+      ->delete($this->entityType->getBaseTable())
+      ->condition('workspace', $workspace->id())
+      ->execute();
+    $this->database
+      ->delete($this->entityType->getRevisionTable())
+      ->condition('workspace', $workspace->id())
+      ->execute();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTrackedEntities($workspace_id, $all_revisions = FALSE, $group = TRUE) {
+    $table = $all_revisions ? $this->getRevisionTable() : $this->getBaseTable();
+    $query = $this->database->select($table, 'base_table');
+    $query
+      ->fields('base_table', ['content_entity_type_id', 'content_entity_id', 'content_entity_revision_id'])
+      ->orderBy('content_entity_revision_id', 'ASC')
+      ->condition('workspace', $workspace_id);
+
+    $tracked_revisions = [];
+    foreach ($query->execute() as $record) {
+      if ($group) {
+        $tracked_revisions[$record->content_entity_type_id][$record->content_entity_revision_id] = $record->content_entity_id;
+      }
+      else {
+        $tracked_revisions[] = [
+          'entity_type_id' => $record->content_entity_type_id,
+          'revision_id' => $record->content_entity_revision_id,
+          'entity_id' => $record->content_entity_id,
+        ];
+      }
+    }
+
+    return $tracked_revisions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isEntityTracked(EntityInterface $entity) {
+    $query = $this->database->select($this->getBaseTable(), 'base_table');
+    $query
+      ->fields('base_table', ['workspace'])
+      ->condition('content_entity_type_id', $entity->getEntityTypeId())
+      ->condition('content_entity_id', $entity->id());
+
+    return $query->execute()->fetchCol();
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceAssociationStorageInterface.php b/core/modules/workspace/src/WorkspaceAssociationStorageInterface.php
new file mode 100644
index 0000000..b08c035
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceAssociationStorageInterface.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\ContentEntityStorageInterface;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Defines an interface for workspace association entity storage classes.
+ */
+interface WorkspaceAssociationStorageInterface extends ContentEntityStorageInterface {
+
+  /**
+   * Marks all workspace association entities pushed for a given workspace.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   A workspace entity.
+   */
+  public function postPush(WorkspaceInterface $workspace);
+
+  /**
+   * Retrieves the content revisions tracked by a given workspace.
+   *
+   * @param string $workspace_id
+   *   The ID of the workspace.
+   * @param bool $all_revisions
+   *   (optional) Whether to return all the tracked revisions for each entity or
+   *   just the latest tracked revision. Defaults to FALSE.
+   * @param bool $group
+   *   (optional) Whether to group the results by their entity type ID. Defaults
+   *   to TRUE.
+   *
+   * @return array
+   *   Returns an array of entity identifiers which are tracked by a given
+   *   workspace. If the $group parameter is TRUE, returns a multidimensional
+   *   array where the first level keys are entity type IDs and the values are
+   *   an array of entity IDs, keyed by revision IDs. If the $group parameter is
+   *   FALSE, returns a single level array containing all the tracked entities.
+   */
+  public function getTrackedEntities($workspace_id, $all_revisions = FALSE, $group = TRUE);
+
+  /**
+   * Checks if a given entity is tracked in one or multiple workspaces.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   An entity object.
+   *
+   * @return string[]
+   *   An array of workspace IDs where the given entity is tracked, or an empty
+   *   array if it's not tracked anywhere.
+   */
+  public function isEntityTracked(EntityInterface $entity);
+
+}
diff --git a/core/modules/workspace/src/WorkspaceCacheContext.php b/core/modules/workspace/src/WorkspaceCacheContext.php
new file mode 100644
index 0000000..14ce019
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceCacheContext.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\Context\CacheContextInterface;
+
+/**
+ * Defines the WorkspaceCacheContext service, for "per workspace" caching.
+ *
+ * Cache context ID: 'workspace'.
+ */
+class WorkspaceCacheContext implements CacheContextInterface {
+
+  /**
+   * The workspace manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a new WorkspaceCacheContext service.
+   *
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager.
+   */
+  public function __construct(WorkspaceManagerInterface $workspace_manager) {
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getLabel() {
+    return t('Workspace');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getContext() {
+    return $this->workspaceManager->getActiveWorkspace()->id();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheableMetadata($type = NULL) {
+    return new CacheableMetadata();
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceConflictException.php b/core/modules/workspace/src/WorkspaceConflictException.php
new file mode 100644
index 0000000..1c89b09
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceConflictException.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Drupal\workspace;
+
+/**
+ * An exception thrown when two workspaces are in a conflicting content state.
+ */
+class WorkspaceConflictException extends \RuntimeException {
+
+}
diff --git a/core/modules/workspace/src/WorkspaceInterface.php b/core/modules/workspace/src/WorkspaceInterface.php
new file mode 100644
index 0000000..666b834
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceInterface.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityChangedInterface;
+use Drupal\user\EntityOwnerInterface;
+
+/**
+ * Defines an interface for the workspace entity type.
+ */
+interface WorkspaceInterface extends ContentEntityInterface, EntityChangedInterface, EntityOwnerInterface {
+
+  /**
+   * Gets an instance of the repository handler configured for the workspace.
+   *
+   * @return \Drupal\workspace\RepositoryHandlerInterface|null
+   *   A repository handler plugin object or NULL if there is no target
+   *   configured for the workspace. A NULL return value can only be returned
+   *   for the default (i.e. Live) workspace.
+   */
+  public function getRepositoryHandlerPlugin();
+
+  /**
+   * Determines whether the workspace is the default one or not.
+   *
+   * @return bool
+   *   TRUE if this workspace is the default one (e.g 'Live'), FALSE otherwise.
+   */
+  public function isDefaultWorkspace();
+
+  /**
+   * Sets the workspace creation timestamp.
+   *
+   * @param int $timestamp
+   *   The workspace creation timestamp.
+   *
+   * @return $this
+   */
+  public function setCreatedTime($timestamp);
+
+  /**
+   * Returns the workspace creation timestamp.
+   *
+   * @return int
+   *   Creation timestamp of the workspace.
+   */
+  public function getStartTime();
+
+}
diff --git a/core/modules/workspace/src/WorkspaceListBuilder.php b/core/modules/workspace/src/WorkspaceListBuilder.php
new file mode 100644
index 0000000..f4b2b21
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceListBuilder.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityListBuilder;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class to build a listing of workspace entities.
+ *
+ * @see \Drupal\workspace\Entity\Workspace
+ */
+class WorkspaceListBuilder extends EntityListBuilder {
+
+  /**
+   * The workspace manager service.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * Constructs a new EntityListBuilder object.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $storage
+   *   The entity storage class.
+   * @param \Drupal\workspace\WorkspaceManagerInterface $workspace_manager
+   *   The workspace manager service.
+   */
+  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, WorkspaceManagerInterface $workspace_manager) {
+    parent::__construct($entity_type, $storage);
+    $this->workspaceManager = $workspace_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static(
+      $entity_type,
+      $container->get('entity.manager')->getStorage($entity_type->id()),
+      $container->get('workspace.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildHeader() {
+    $header['label'] = $this->t('Workspace');
+    $header['uid'] = $this->t('Owner');
+    $header['status'] = $this->t('Status');
+
+    return $header + parent::buildHeader();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildRow(EntityInterface $entity) {
+    /** @var \Drupal\workspace\WorkspaceInterface $entity */
+    $row['label'] = $entity->label() . ' (' . $entity->id() . ')';
+    $row['owner'] = $entity->getOwner()->getDisplayname();
+    $active_workspace = $this->workspaceManager->getActiveWorkspace()->id();
+    $row['status'] = $active_workspace == $entity->id() ? $this->t('Active') : $this->t('Inactive');
+
+    return $row + parent::buildRow($entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDefaultOperations(EntityInterface $entity) {
+    /** @var \Drupal\workspace\WorkspaceInterface $entity */
+    $operations = parent::getDefaultOperations($entity);
+    if (isset($operations['edit'])) {
+      $operations['edit']['query']['destination'] = $entity->toUrl('collection')->toString();
+    }
+
+    $active_workspace = $this->workspaceManager->getActiveWorkspace();
+    if ($entity->id() != $active_workspace->id()) {
+      $operations['activate'] = [
+        'title' => $this->t('Set Active'),
+        // Use a weight lower than the one of the 'Edit' operation because we
+        // want the 'Activate' operation to be the primary operation.
+        'weight' => 0,
+        'url' => $entity->toUrl('activate-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
+      ];
+    }
+
+    if ($entity->getRepositoryHandlerPlugin()) {
+      $operations['deploy'] = [
+        'title' => $this->t('Deploy content'),
+        // The 'Deploy' operation should be the default one for the currently
+        // active workspace.
+        'weight' => ($entity->id() == $active_workspace->id()) ? 0 : 20,
+        'url' => $entity->toUrl('deploy-form', ['query' => ['destination' => $entity->toUrl('collection')->toString()]]),
+      ];
+    }
+
+    return $operations;
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceManager.php b/core/modules/workspace/src/WorkspaceManager.php
new file mode 100644
index 0000000..bdba834
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceManager.php
@@ -0,0 +1,256 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ClassResolverInterface;
+use Drupal\Core\Entity\EntityPublishedInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\Core\Site\Settings;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Provides the workspace manager.
+ */
+class WorkspaceManager implements WorkspaceManagerInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The default workspace ID.
+   */
+  const DEFAULT_WORKSPACE = 'live';
+
+  /**
+   * An array of entity type IDs that can not belong to a workspace.
+   *
+   * By default, only entity types which are revisionable and publishable can
+   * belong to a workspace.
+   *
+   * @var string[]
+   */
+  protected $blacklist = [
+    'workspace_association',
+    'workspace',
+  ];
+
+  /**
+   * The request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountProxyInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * A logger instance.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * The class resolver.
+   *
+   * @var \Drupal\Core\DependencyInjection\ClassResolverInterface
+   */
+  protected $classResolver;
+
+  /**
+   * The workspace negotiator service IDs.
+   *
+   * @var array
+   */
+  protected $negotiatorIds;
+
+  /**
+   * The current active workspace.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface
+   */
+  protected $activeWorkspace;
+
+  /**
+   * Constructs a new WorkspaceManager.
+   *
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Psr\Log\LoggerInterface $logger
+   *   A logger instance.
+   * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
+   *   The class resolver.
+   * @param array $negotiator_ids
+   *   The workspace negotiator service IDs.
+   */
+  public function __construct(RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, AccountProxyInterface $current_user, StateInterface $state, LoggerInterface $logger, ClassResolverInterface $class_resolver, array $negotiator_ids) {
+    $this->requestStack = $request_stack;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->currentUser = $current_user;
+    $this->state = $state;
+    $this->logger = $logger;
+    $this->classResolver = $class_resolver;
+    $this->negotiatorIds = $negotiator_ids;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function entityTypeCanBelongToWorkspaces(EntityTypeInterface $entity_type) {
+    if (!isset($this->blacklist[$entity_type->id()])
+      && $entity_type->entityClassImplements(EntityPublishedInterface::class)
+      && $entity_type->isRevisionable()) {
+      return TRUE;
+    }
+    $this->blacklist[$entity_type->id()] = $entity_type->id();
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSupportedEntityTypes() {
+    $entity_types = [];
+    foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) {
+      if ($this->entityTypeCanBelongToWorkspaces($entity_type)) {
+        $entity_types[$entity_type_id] = $entity_type;
+      }
+    }
+    return $entity_types;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getActiveWorkspace() {
+    if (!isset($this->activeWorkspace)) {
+      $request = $this->requestStack->getCurrentRequest();
+      foreach ($this->negotiatorIds as $negotiator_id) {
+        $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
+        if ($negotiator->applies($request)) {
+          if ($this->activeWorkspace = $negotiator->getActiveWorkspace($request)) {
+            break;
+          }
+        }
+      }
+    }
+
+    // The default workspace negotiator always returns a valid workspace.
+    return $this->activeWorkspace;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setActiveWorkspace(WorkspaceInterface $workspace) {
+    // If the current user doesn't have access to view the workspace, they
+    // shouldn't be allowed to switch to it.
+    if (!$workspace->access('view') && !$workspace->isDefaultWorkspace()) {
+      $this->logger->error('Denied access to view workspace %workspace_label', ['%workspace_label' => $workspace->label()]);
+      throw new WorkspaceAccessException('The user does not have permission to view that workspace.');
+    }
+
+    $this->activeWorkspace = $workspace;
+
+    // Set the workspace on the proper negotiator.
+    $request = $this->requestStack->getCurrentRequest();
+    foreach ($this->negotiatorIds as $negotiator_id) {
+      $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
+      if ($negotiator->applies($request)) {
+        $negotiator->setActiveWorkspace($workspace);
+        break;
+      }
+    }
+
+    $supported_entity_types = $this->getSupportedEntityTypes();
+    foreach ($supported_entity_types as $supported_entity_type) {
+      $this->entityTypeManager->getStorage($supported_entity_type->id())->resetCache();
+    }
+
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function purgeDeletedWorkspacesBatch() {
+    $deleted_workspace_ids = $this->state->get('workspace.deleted', []);
+    $batch_size = Settings::get('entity_update_batch_size', 50);
+
+    /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+    $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+
+    // Get the first deleted workspace from the list and delete the revisions
+    // associated with it, along with the workspace_association entries.
+    $workspace_id = reset($deleted_workspace_ids);
+    $workspace_association_ids = $workspace_association_storage
+      ->getQuery()
+      ->allRevisions()
+      ->accessCheck(FALSE)
+      ->condition('workspace', $workspace_id)
+      ->sort('revision_id', 'ASC')
+      ->range(0, $batch_size)
+      ->execute();
+
+    if ($workspace_association_ids) {
+      $workspace_associations = $workspace_association_storage->loadMultipleRevisions(array_keys($workspace_association_ids));
+      foreach ($workspace_associations as $workspace_association) {
+        $associated_entity_storage = $this->entityTypeManager->getStorage($workspace_association->content_entity_type_id->value);
+        // Delete the associated entity revision.
+        if ($entity = $associated_entity_storage->loadRevision($workspace_association->content_entity_revision_id->value)) {
+          if ($entity->isDefaultRevision()) {
+            $entity->delete();
+          }
+          else {
+            $associated_entity_storage->deleteRevision($workspace_association->content_entity_revision_id->value);
+          }
+        }
+
+        // Delete the workspace_association revision.
+        if ($workspace_association->isDefaultRevision()) {
+          $workspace_association->delete();
+        }
+        else {
+          $workspace_association_storage->deleteRevision($workspace_association->getRevisionId());
+        }
+      }
+    }
+
+    // Remove the deleted workspace ID entry from state if all its associated
+    // entities have been purged.
+    if (!$workspace_association_ids || ($workspace_association_ids && count($workspace_association_ids) < $batch_size)) {
+      unset($deleted_workspace_ids[$workspace_id]);
+      $this->state->set('workspace.deleted', $deleted_workspace_ids);
+    }
+  }
+
+}
diff --git a/core/modules/workspace/src/WorkspaceManagerInterface.php b/core/modules/workspace/src/WorkspaceManagerInterface.php
new file mode 100644
index 0000000..0467089
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceManagerInterface.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+
+/**
+ * Provides an interface for managing Workspaces.
+ */
+interface WorkspaceManagerInterface {
+
+  /**
+   * Returns whether an entity type can belong to a workspace or not.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type to check.
+   *
+   * @return bool
+   *   TRUE if the entity type can belong to a workspace, FALSE otherwise.
+   */
+  public function entityTypeCanBelongToWorkspaces(EntityTypeInterface $entity_type);
+
+  /**
+   * Returns an array of entity types that can belong to workspaces.
+   *
+   * @return \Drupal\Core\Entity\EntityTypeInterface[]
+   *   The entity types what can belong to workspaces.
+   */
+  public function getSupportedEntityTypes();
+
+  /**
+   * Gets the active workspace.
+   *
+   * @return \Drupal\workspace\WorkspaceInterface
+   *   The active workspace entity object.
+   */
+  public function getActiveWorkspace();
+
+  /**
+   * Sets the active workspace via the workspace negotiators.
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace to set as active.
+   *
+   * @return $this
+   *
+   * @throws \Drupal\workspace\WorkspaceAccessException
+   *   Thrown when the current user doesn't have access to view the workspace.
+   */
+  public function setActiveWorkspace(WorkspaceInterface $workspace);
+
+}
diff --git a/core/modules/workspace/src/WorkspaceServiceProvider.php b/core/modules/workspace/src/WorkspaceServiceProvider.php
new file mode 100644
index 0000000..5836493
--- /dev/null
+++ b/core/modules/workspace/src/WorkspaceServiceProvider.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\workspace;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+
+/**
+ * Defines a service provider for the workspace module.
+ */
+class WorkspaceServiceProvider extends ServiceProviderBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    // Add the 'workspace' cache context as required.
+    $renderer_config = $container->getParameter('renderer.config');
+    $renderer_config['required_cache_contexts'][] = 'workspace';
+    $container->setParameter('renderer.config', $renderer_config);
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php
new file mode 100644
index 0000000..04e2510
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonAnonTest.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+
+/**
+ * Test workspace entities for unauthenticated JSON requests.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonAnonTest extends WorkspaceResourceTestBase {
+
+  use AnonResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php
new file mode 100644
index 0000000..dbae2d5
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonBasicAuthTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+
+/**
+ * Test workspace entities for JSON requests via basic auth.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonBasicAuthTest extends WorkspaceResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php
new file mode 100644
index 0000000..f77dbf2
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceJsonCookieTest.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+
+/**
+ * Test workspace entities for JSON requests with cookie authentication.
+ *
+ * @group workspace
+ */
+class WorkspaceJsonCookieTest extends WorkspaceResourceTestBase {
+
+  use CookieResourceTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'application/json';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php
new file mode 100644
index 0000000..c0a4e83
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceResourceTestBase.php
@@ -0,0 +1,215 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\BcTimestampNormalizerUnixTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\EntityResourceTestBase;
+use Drupal\user\Entity\User;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Base class for workspace EntityResource tests.
+ */
+abstract class WorkspaceResourceTestBase extends EntityResourceTestBase {
+
+  use BcTimestampNormalizerUnixTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workspace'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $entityTypeId = 'workspace';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $patchProtectedFieldNames = ['changed'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $firstCreatedEntityId = 'running_on_faith';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $secondCreatedEntityId = 'running_on_faith_2';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUpAuthorization($method) {
+    switch ($method) {
+      case 'GET':
+        $this->grantPermissionsToTestedRole(['view any workspace']);
+        break;
+      case 'POST':
+        $this->grantPermissionsToTestedRole(['create workspace']);
+        break;
+      case 'PATCH':
+        $this->grantPermissionsToTestedRole(['edit any workspace']);
+        break;
+      case 'DELETE':
+        $this->grantPermissionsToTestedRole(['delete any workspace']);
+        break;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createEntity() {
+    $workspace = Workspace::create([
+      'id' => 'layla',
+      'label' => 'Layla',
+      'target' => 'live',
+    ]);
+    $workspace->save();
+    return $workspace;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createAnotherEntity() {
+    $workspace = $this->entity->createDuplicate();
+    $workspace->id = 'layla_dupe';
+    $workspace->label = 'Layla_dupe';
+    $workspace->save();
+    return $workspace;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedNormalizedEntity() {
+    $author = User::load($this->entity->getOwnerId());
+    return [
+      'created' => [
+        $this->formatExpectedTimestampItemValues((int) $this->entity->getStartTime()),
+      ],
+      'changed' => [
+        $this->formatExpectedTimestampItemValues($this->entity->getChangedTime()),
+      ],
+      'id' => [
+        [
+          'value' => 'layla',
+        ],
+      ],
+      'label' => [
+        [
+          'value' => 'Layla',
+        ],
+      ],
+      'revision_id' => [
+        [
+          'value' => 3,
+        ],
+      ],
+      'uid' => [
+        [
+          'target_id' => (int) $author->id(),
+          'target_type' => 'user',
+          'target_uuid' => $author->uuid(),
+          'url' => base_path() . 'user/' . $author->id(),
+        ],
+      ],
+      'target' => [
+        [
+          'value' => 'live',
+        ],
+      ],
+      'uuid' => [
+        [
+          'value' => $this->entity->uuid()
+        ],
+      ],
+      'revision_default' => [
+        [
+          'value' => TRUE,
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPostEntity() {
+    return [
+      'id' => [
+        [
+          'value' => static::$firstCreatedEntityId,
+        ],
+      ],
+      'label' => [
+        [
+          'value' => 'Running on faith',
+        ],
+      ],
+      'target' => [
+        [
+          'value' => 'local_workspace:stage',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getSecondNormalizedPostEntity() {
+    $normalized_post_entity = $this->getNormalizedPostEntity();
+    $normalized_post_entity['id'][0]['value'] = static::$secondCreatedEntityId;
+
+    return $normalized_post_entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getNormalizedPatchEntity() {
+    return [
+      'label' => [
+        [
+          'value' => 'Running on faith',
+        ],
+      ],
+      'target' => [
+        [
+          'value' => 'local_workspace:stage',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getExpectedUnauthorizedAccessMessage($method) {
+    if ($this->config('rest.settings')->get('bc_entity_resource_permissions')) {
+      return parent::getExpectedUnauthorizedAccessMessage($method);
+    }
+
+    switch ($method) {
+      case 'GET':
+        return "The 'view any workspace' permission is required.";
+        break;
+      case 'POST':
+        return "The 'create workspace' permission is required.";
+        break;
+      case 'PATCH':
+        return "The 'edit any workspace' permission is required.";
+        break;
+      case 'DELETE':
+        return "The 'delete any workspace' permission is required.";
+        break;
+    }
+    return parent::getExpectedUnauthorizedAccessMessage($method);
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php
new file mode 100644
index 0000000..5004f53
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlAnonTest.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * Test workspace entities for unauthenticated XML requests.
+ *
+ * @group workspace
+ */
+class WorkspaceXmlAnonTest extends WorkspaceResourceTestBase {
+
+  use AnonResourceTestTrait;
+  use XmlEntityNormalizationQuirksTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'xml';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'text/xml; charset=UTF-8';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchPath() {
+    // Deserialization of the XML format is not supported.
+    $this->markTestSkipped();
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php
new file mode 100644
index 0000000..ac6f3c5
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlBasicAuthTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * Test workspace entities for XML requests with cookie authentication.
+ *
+ * @group workspace
+ */
+class WorkspaceXmlBasicAuthTest extends WorkspaceResourceTestBase {
+
+  use BasicAuthResourceTestTrait;
+  use XmlEntityNormalizationQuirksTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['basic_auth'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'xml';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'text/xml; charset=UTF-8';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'basic_auth';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchPath() {
+    // Deserialization of the XML format is not supported.
+    $this->markTestSkipped();
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php
new file mode 100644
index 0000000..c8a23d1
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/EntityResource/WorkspaceXmlCookieTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional\EntityResource;
+
+use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
+use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
+
+/**
+ * Test workspace entities for XML requests.
+ *
+ * @group workspace
+ */
+class WorkspaceXmlCookieTest extends WorkspaceResourceTestBase {
+
+  use CookieResourceTestTrait;
+  use XmlEntityNormalizationQuirksTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $format = 'xml';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $mimeType = 'text/xml; charset=UTF-8';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $auth = 'cookie';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function testPatchPath() {
+    // Deserialization of the XML format is not supported.
+    $this->markTestSkipped();
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php
new file mode 100644
index 0000000..37e3fbe
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceBypassTest.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+
+/**
+ * Tests access bypass permission controls on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceBypassTest extends BrowserTestBase {
+
+  use WorkspaceTestUtilities;
+  use ContentTypeCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['node', 'user', 'block', 'workspace'];
+
+  /**
+   * Verifies that a user can edit anything in a workspace with a specific perm.
+   */
+  public function testBypassSpecificWorkspace() {
+    $permissions = [
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+    ];
+
+    $this->createContentType(['type' => 'test', 'label' => 'Test']);
+    $this->setupWorkspaceSwitcherBlock();
+
+    $ditka = $this->drupalCreateUser(array_merge($permissions, ['create test content']));
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($ditka);
+
+    $this->createNodeThroughUi('Vanilla node', 'test');
+
+    $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+    $this->switchToWorkspace($bears);
+
+    // Now create a node in the Bears workspace, as the owner of that workspace.
+    $ditka_bears_node = $this->createNodeThroughUi('Ditka Bears node', 'test');
+    $ditka_bears_node_id = $ditka_bears_node->id();
+
+    // Create a new user that should be able to edit anything in the Bears
+    // workspace.
+    $lombardi = $this->drupalCreateUser(array_merge($permissions, ['view any workspace', 'bypass entity access workspace ' . $bears->id()]));
+    $this->drupalLogin($lombardi);
+    $this->switchToWorkspace($bears);
+
+    // Because Lombardi has the bypass permission, he should be able to create
+    // and edit any node.
+    $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+    $this->assertSession()->statusCodeEquals(200);
+
+    $lombardi_bears_node = $this->createNodeThroughUi('Lombardi Bears node', 'test');
+    $lombardi_bears_node_id = $lombardi_bears_node->id();
+
+    $this->drupalLogin($ditka);
+    $this->switchToWorkspace($bears);
+
+    $this->drupalGet('/node/' . $lombardi_bears_node_id . '/edit');
+    $this->assertSession()->statusCodeEquals(403);
+
+    // Create a new user that should NOT be able to edit anything in the Bears
+    // workspace.
+    $belichick = $this->drupalCreateUser(array_merge($permissions, ['view any workspace']));
+    $this->drupalLogin($belichick);
+    $this->switchToWorkspace($bears);
+
+    $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Verifies that a user can edit anything in a workspace they own.
+   */
+  public function testBypassOwnWorkspace() {
+    $permissions = [
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+      'bypass entity access own workspace',
+    ];
+
+    $this->createContentType(['type' => 'test', 'label' => 'Test']);
+    $this->setupWorkspaceSwitcherBlock();
+
+    $ditka = $this->drupalCreateUser(array_merge($permissions, ['create test content']));
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($ditka);
+    $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+    $this->switchToWorkspace($bears);
+
+    // Now create a node in the Bears workspace, as the owner of that workspace.
+    $ditka_bears_node = $this->createNodeThroughUi('Ditka Bears node', 'test');
+    $ditka_bears_node_id = $ditka_bears_node->id();
+
+    // Editing both nodes should be possible.
+    $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+    $this->assertSession()->statusCodeEquals(200);
+
+    // Create a new user that should be able to edit anything in the Bears
+    // workspace.
+    $lombardi = $this->drupalCreateUser(array_merge($permissions, ['view any workspace']));
+    $this->drupalLogin($lombardi);
+    $this->switchToWorkspace($bears);
+
+    // Because editor 2 has the bypass permission, he should be able to create
+    // and edit any node.
+    $this->drupalGet('/node/' . $ditka_bears_node_id . '/edit');
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php
new file mode 100644
index 0000000..b6b5468
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceCacheContextTest.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
+use Drupal\workspace\Entity\Workspace;
+use Drupal\workspace\WorkspaceCacheContext;
+
+/**
+ * Tests the workspace cache context.
+ *
+ * @group workspace
+ * @group Cache
+ */
+class WorkspaceCacheContextTest extends BrowserTestBase {
+
+  use AssertPageCacheContextsAndTagsTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block', 'node', 'workspace'];
+
+  /**
+   * Tests the 'workspace' cache context.
+   */
+  public function testWorkspaceCacheContext() {
+    $this->dumpHeaders = TRUE;
+
+    $renderer = \Drupal::service('renderer');
+    $cache_contexts_manager = \Drupal::service("cache_contexts_manager");
+
+    // Check that the 'workspace' cache context is present when the module is
+    // installed.
+    $this->drupalGet('<front>');
+    $this->assertCacheContext('workspace');
+
+    $cache_context = new WorkspaceCacheContext(\Drupal::service('workspace.manager'));
+    $this->assertSame('live', $cache_context->getContext());
+
+    // Create a node and check that its render array contains the proper cache
+    // context.
+    $this->drupalCreateContentType(['type' => 'page']);
+    $node = $this->createNode();
+
+    // Get a fully built entity view render array.
+    $build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');
+
+    // Render it so the default cache contexts are applied.
+    $renderer->renderRoot($build);
+    $this->assertTrue(in_array('workspace', $build['#cache']['contexts'], TRUE));
+
+    $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys());
+    $this->assertTrue(in_array('[workspace]=live', $cid_parts, TRUE));
+
+    // Test that a cache entry is created.
+    $cid = implode(':', $cid_parts);
+    $bin = $build['#cache']['bin'];
+    $this->assertTrue($this->container->get('cache.' . $bin)->get($cid), 'The entity render element has been cached.');
+
+    // Switch to the 'stage' workspace and check that the correct workspace
+    // cache context is used.
+    $test_user = $this->drupalCreateUser(['view any workspace']);
+    $this->drupalLogin($test_user);
+
+    $stage = Workspace::load('stage');
+    $workspace_manager = \Drupal::service('workspace.manager');
+    $workspace_manager->setActiveWorkspace($stage);
+
+    $cache_context = new WorkspaceCacheContext($workspace_manager);
+    $this->assertSame('stage', $cache_context->getContext());
+
+    $build = \Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');
+
+    // Render it so the default cache contexts are applied.
+    $renderer->renderRoot($build);
+    $this->assertTrue(in_array('workspace', $build['#cache']['contexts'], TRUE));
+
+    $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys());
+    $this->assertTrue(in_array('[workspace]=stage', $cid_parts, TRUE));
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceConcurrentEditingTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceConcurrentEditingTest.php
new file mode 100644
index 0000000..396aa20
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceConcurrentEditingTest.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests concurrent edits in different workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceConcurrentEditingTest extends BrowserTestBase {
+
+  use WorkspaceTestUtilities;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block', 'node', 'workspace'];
+
+  /**
+   * Test switching workspace via the switcher block and admin page.
+   */
+  public function testSwitchingWorkspaces() {
+    $permissions = [
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+      'bypass entity access own workspace',
+    ];
+
+    $mayer = $this->drupalCreateUser($permissions);
+    $this->drupalLogin($mayer);
+    $this->setupWorkspaceSwitcherBlock();
+
+    // Create a test node.
+    $this->createContentType(['type' => 'test', 'label' => 'Test']);
+    $test_node = $this->createNodeThroughUi('Test node', 'test');
+
+    // Check that the user can edit the node.
+    $page = $this->getSession()->getPage();
+    $page->hasField('title[0][value]');
+
+    // Create two workspaces.
+    $vultures = $this->createWorkspaceThroughUi('Vultures', 'vultures');
+    $gravity = $this->createWorkspaceThroughUi('Gravity', 'gravity');
+
+    // Edit the node in workspace 'vultures'.
+    $this->switchToWorkspace($vultures);
+    $this->drupalGet('/node/' . $test_node->id() . '/edit');
+    $page = $this->getSession()->getPage();
+    $page->fillField('Title', 'Test node - override');
+    $page->findButton('Save')->click();
+
+    // Check that the user can still edit the node in the same workspace.
+    $this->drupalGet('/node/' . $test_node->id() . '/edit');
+    $page = $this->getSession()->getPage();
+    $this->assertTrue($page->hasField('title[0][value]'));
+
+    // Switch to a different workspace and check that the user can not edit the
+    // node anymore.
+    $this->switchToWorkspace($gravity);
+    $this->drupalGet('/node/' . $test_node->id() . '/edit');
+    $page = $this->getSession()->getPage();
+    $this->assertFalse($page->hasField('title[0][value]'));
+    $page->hasContent('The content is being edited in the Vultures workspace.');
+
+    // Check that the node fails validation for API calls.
+    $violations = $test_node->validate();
+    $this->assertCount(1, $violations);
+    $this->assertEquals('The content is being edited in the <em class="placeholder">Vultures</em> workspace. As a result, your changes cannot be saved.', $violations->get(0)->getMessage());
+
+    // Switch to the Live workspace and check that the user still can not edit
+    // the node.
+    $live = Workspace::load('live');
+    $this->switchToWorkspace($live);
+    $this->drupalGet('/node/' . $test_node->id() . '/edit');
+    $page = $this->getSession()->getPage();
+    $this->assertFalse($page->hasField('title[0][value]'));
+    $page->hasContent('The content is being edited in the Vultures workspace.');
+
+    // Check that the node fails validation for API calls.
+    $violations = $test_node->validate();
+    $this->assertCount(1, $violations);
+    $this->assertEquals('The content is being edited in the <em class="placeholder">Vultures</em> workspace. As a result, your changes cannot be saved.', $violations->get(0)->getMessage());
+
+    // Deploy the changes from the 'Vultures' workspace and check that the node
+    // can be edited again in other workspaces.
+    $vultures->getRepositoryHandlerPlugin()->push();
+    $this->switchToWorkspace($gravity);
+    $this->drupalGet('/node/' . $test_node->id() . '/edit');
+    $page = $this->getSession()->getPage();
+    $this->assertTrue($page->hasField('title[0][value]'));
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php
new file mode 100644
index 0000000..0afbb46
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspacePermissionsTest.php
@@ -0,0 +1,209 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests permission controls on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspacePermissionsTest extends BrowserTestBase {
+
+  use WorkspaceTestUtilities;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workspace'];
+
+  /**
+   * Verifies that a user can create but not edit a workspace.
+   */
+  public function testCreateWorkspace() {
+    $editor = $this->drupalCreateUser([
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+    ]);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor);
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    // Now edit that same workspace; We shouldn't be able to do so, since
+    // we don't have edit permissions.
+    /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $etm */
+    $etm = \Drupal::service('entity_type.manager');
+    /** @var \Drupal\workspace\WorkspaceInterface $bears */
+    $entity_list = $etm->getStorage('workspace')->loadByProperties(['label' => 'Bears']);
+    $bears = current($entity_list);
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Verifies that a user can create and edit only their own workspace.
+   */
+  public function testEditOwnWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    // Now edit that same workspace; We should be able to do so.
+    $bears = Workspace::load('bears');
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertSession()->statusCodeEquals(200);
+
+    $page = $this->getSession()->getPage();
+    $page->fillField('label', 'Bears again');
+    $page->fillField('id', 'bears');
+    $page->findButton('Save')->click();
+    $page->hasContent('Bears again (bears)');
+
+    // Now login as a different user and ensure they don't have edit access,
+    // and vice versa.
+    $editor2 = $this->drupalCreateUser($permissions);
+
+    $this->drupalLogin($editor2);
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+    $packers = Workspace::load('packers');
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$packers->id()}/edit");
+    $this->assertSession()->statusCodeEquals(200);
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Verifies that a user can edit any workspace.
+   */
+  public function testEditAnyWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    // Now edit that same workspace; We should be able to do so.
+    $bears = Workspace::load('bears');
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertSession()->statusCodeEquals(200);
+
+    $page = $this->getSession()->getPage();
+    $page->fillField('label', 'Bears again');
+    $page->fillField('id', 'bears');
+    $page->findButton('Save')->click();
+    $page->hasContent('Bears again (bears)');
+
+    // Now login as a different user and ensure they don't have edit access,
+    // and vice versa.
+    $admin = $this->drupalCreateUser(array_merge($permissions, ['edit any workspace']));
+
+    $this->drupalLogin($admin);
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+    $packers = Workspace::load('packers');
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$packers->id()}/edit");
+    $this->assertSession()->statusCodeEquals(200);
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/edit");
+    $this->assertSession()->statusCodeEquals(200);
+  }
+
+  /**
+   * Verifies that a user can create and delete only their own workspace.
+   */
+  public function testDeleteOwnWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'delete own workspace',
+    ];
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+    $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    // Now try to delete that same workspace; We should be able to do so.
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/delete");
+    $this->assertSession()->statusCodeEquals(200);
+
+    // Now login as a different user and ensure they don't have edit access,
+    // and vice versa.
+    $editor2 = $this->drupalCreateUser($permissions);
+
+    $this->drupalLogin($editor2);
+    $packers = $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$packers->id()}/delete");
+    $this->assertSession()->statusCodeEquals(200);
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/delete");
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+  /**
+   * Verifies that a user can delete any workspace.
+   */
+  public function testDeleteAnyWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'delete own workspace',
+    ];
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+    $bears = $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    // Now edit that same workspace; We should be able to do so.
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/delete");
+    $this->assertSession()->statusCodeEquals(200);
+
+    // Now login as a different user and ensure they have delete access on both
+    // workspaces.
+    $admin = $this->drupalCreateUser(array_merge($permissions, ['delete any workspace']));
+
+    $this->drupalLogin($admin);
+    $packers = $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$packers->id()}/delete");
+    $this->assertSession()->statusCodeEquals(200);
+
+    $this->drupalGet("/admin/config/workflow/workspace/{$bears->id()}/delete");
+    $this->assertSession()->statusCodeEquals(200);
+
+    // Check that the default workspace can not be deleted, even by a user with
+    // the "delete any workspace" permission.
+    $this->drupalGet("/admin/config/workflow/workspace/live/delete");
+    $this->assertSession()->statusCodeEquals(403);
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php
new file mode 100644
index 0000000..fda4731
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests workspace switching functionality.
+ *
+ * @group workspace
+ */
+class WorkspaceSwitcherTest extends BrowserTestBase {
+
+  use WorkspaceTestUtilities;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['block', 'workspace'];
+
+  /**
+   * Test switching workspace via the switcher block and admin page.
+   */
+  public function testSwitchingWorkspaces() {
+    $permissions = [
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+      'bypass entity access own workspace',
+    ];
+
+    $this->setupWorkspaceSwitcherBlock();
+
+    $mayer = $this->drupalCreateUser($permissions);
+    $this->drupalLogin($mayer);
+
+    $vultures = $this->createWorkspaceThroughUi('Vultures', 'vultures');
+    $this->switchToWorkspace($vultures);
+
+    $gravity = $this->createWorkspaceThroughUi('Gravity', 'gravity');
+
+    $this->drupalGet('/admin/config/workflow/workspace/' . $gravity->id() . '/activate');
+
+    $this->assertSession()->statusCodeEquals(200);
+    $page = $this->getSession()->getPage();
+    $page->findButton('Confirm')->click();
+
+    $page->findLink($gravity->label());
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php
new file mode 100644
index 0000000..97b487f
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Test the workspace entity.
+ *
+ * @group workspace
+ */
+class WorkspaceTest extends BrowserTestBase {
+
+  use WorkspaceTestUtilities;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workspace'];
+
+  /**
+   * A test user.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $editor1;
+
+  /**
+   * A test user.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $editor2;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp() {
+    parent::setUp();
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+      'edit any workspace',
+    ];
+
+    $this->editor1 = $this->drupalCreateUser($permissions);
+    $this->editor2 = $this->drupalCreateUser($permissions);
+  }
+
+  /**
+   * Test creating a workspace with special characters.
+   */
+  public function testSpecialCharacters() {
+    $this->drupalLogin($this->editor1);
+
+    // Test a valid workspace name.
+    $this->createWorkspaceThroughUi('Workspace 1', 'a0_$()+-/');
+
+    // Test and invalid workspace name.
+    $this->drupalGet('/admin/config/workflow/workspace/add');
+    $this->assertSession()->statusCodeEquals(200);
+
+    $page = $this->getSession()->getPage();
+    $page->fillField('label', 'workspace2');
+    $page->fillField('id', 'A!"£%^&*{}#~@?');
+    $page->findButton('Save')->click();
+    $page->hasContent("This value is not valid");
+  }
+
+  /**
+   * Test changing the owner of a workspace.
+   */
+  public function testWorkspaceOwner() {
+    $this->drupalLogin($this->editor1);
+
+    $this->drupalPostForm('/admin/config/workflow/workspace/add', [
+      'id' => 'test_workspace',
+      'label' => 'Test workspace',
+    ], 'Save');
+
+    $storage = \Drupal::entityTypeManager()->getStorage('workspace');
+    $test_workspace = $storage->load('test_workspace');
+    $this->assertEquals($this->editor1->id(), $test_workspace->getOwnerId());
+
+    $this->drupalPostForm('/admin/config/workflow/workspace/test_workspace/edit', [
+      'uid[0][target_id]' => $this->editor2->getUsername(),
+    ], 'Save');
+
+    $test_workspace = $storage->loadUnchanged('test_workspace');
+    $this->assertEquals($this->editor2->id(), $test_workspace->getOwnerId());
+  }
+
+  /**
+   * Tests that editing a workspace creates a new revision.
+   */
+  public function testWorkspaceFormRevisions() {
+    $this->drupalLogin($this->editor1);
+    $storage = \Drupal::entityTypeManager()->getStorage('workspace');
+
+    // The current live workspace entity should be revision 1.
+    $live_workspace = $storage->load('live');
+    $this->assertEquals('1', $live_workspace->getRevisionId());
+
+    // Re-save the live workspace via the UI to create revision 3.
+    $this->drupalPostForm($live_workspace->url('edit-form'), [], 'Save');
+    $live_workspace = $storage->loadUnchanged('live');
+    $this->assertEquals('3', $live_workspace->getRevisionId());
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php
new file mode 100644
index 0000000..41f337f
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceTestUtilities.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\block\Traits\BlockCreationTrait;
+use Drupal\workspace\Entity\Workspace;
+use Drupal\workspace\WorkspaceInterface;
+
+/**
+ * Utility methods for use in BrowserTestBase tests.
+ *
+ * This trait will not work if not used in a child of BrowserTestBase.
+ */
+trait WorkspaceTestUtilities {
+
+  use BlockCreationTrait;
+
+  /**
+   * Loads a single entity by its label.
+   *
+   * The UI approach to creating an entity doesn't make it easy to know what
+   * the ID is, so this lets us make paths for an entity after it's created.
+   *
+   * @param string $type
+   *   The type of entity to load.
+   * @param string $label
+   *   The label of the entity to load.
+   *
+   * @return \Drupal\Core\Entity\EntityInterface
+   *   The entity.
+   */
+  protected function getOneEntityByLabel($type, $label) {
+    /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */
+    $entity_type_manager = \Drupal::service('entity_type.manager');
+    $property = $entity_type_manager->getDefinition($type)->getKey('label');
+    $entity_list = $entity_type_manager->getStorage($type)->loadByProperties([$property => $label]);
+    $entity = current($entity_list);
+    if (!$entity) {
+      $this->fail("No {$type} entity named {$label} found.");
+    }
+
+    return $entity;
+  }
+
+  /**
+   * Creates a new Workspace through the UI.
+   *
+   * @param string $label
+   *   The label of the workspace to create.
+   * @param string $id
+   *   The ID of the workspace to create.
+   *
+   * @return \Drupal\workspace\WorkspaceInterface
+   *   The workspace that was just created.
+   */
+  protected function createWorkspaceThroughUi($label, $id) {
+    $this->drupalPostForm('/admin/config/workflow/workspace/add', [
+      'id' => $id,
+      'label' => $label,
+    ], 'Save');
+
+    $this->getSession()->getPage()->hasContent("$label ($id)");
+
+    return Workspace::load($id);
+  }
+
+  /**
+   * Adds the workspace switcher block to the site.
+   *
+   * This is necessary for switchToWorkspace() to function correctly.
+   */
+  protected function setupWorkspaceSwitcherBlock() {
+    // Add the block to the sidebar.
+    $this->placeBlock('workspace_switcher', [
+      'id' => 'workspaceswitcher',
+      'region' => 'sidebar_first',
+      'label' => 'Workspace switcher',
+    ]);
+
+    // Confirm the block shows on the front page.
+    $this->drupalGet('<front>');
+    $page = $this->getSession()->getPage();
+
+    $this->assertTrue($page->hasContent('Workspace switcher'));
+  }
+
+  /**
+   * Sets a given workspace as "active" for subsequent requests.
+   *
+   * This assumes that the switcher block has already been setup by calling
+   * setupWorkspaceSwitcherBlock().
+   *
+   * @param \Drupal\workspace\WorkspaceInterface $workspace
+   *   The workspace to set active.
+   */
+  protected function switchToWorkspace(WorkspaceInterface $workspace) {
+    /** @var \Drupal\Tests\WebAssert $session */
+    $session = $this->assertSession();
+    $session->buttonExists('Activate');
+    $this->drupalPostForm(NULL, ['workspace_id' => $workspace->id()], 'Activate');
+    $session->pageTextContains($workspace->label() . ' is now the active workspace.');
+  }
+
+  /**
+   * Creates a node by "clicking" buttons.
+   *
+   * @param string $label
+   *   The label of the Node to create.
+   * @param string $bundle
+   *   The bundle of the Node to create.
+   * @param bool $publish
+   *   The publishing status to set.
+   *
+   * @return \Drupal\node\NodeInterface
+   *   The Node that was just created.
+   *
+   * @throws \Behat\Mink\Exception\ElementNotFoundException
+   */
+  protected function createNodeThroughUi($label, $bundle, $publish = TRUE) {
+    $this->drupalGet('/node/add/' . $bundle);
+
+    /** @var \Behat\Mink\Session $session */
+    $session = $this->getSession();
+    $this->assertSession()->statusCodeEquals(200);
+
+    /** @var \Behat\Mink\Element\DocumentElement $page */
+    $page = $session->getPage();
+    $page->fillField('Title', $label);
+    if ($publish) {
+      $page->findButton('Save')->click();
+    }
+    else {
+      $page->uncheckField('Published');
+      $page->findButton('Save')->click();
+    }
+
+    $session->getPage()->hasContent("{$label} has been created");
+
+    return $this->getOneEntityByLabel('node', $label);
+  }
+
+  /**
+   * Determine if the content list has an entity's label.
+   *
+   * This assertion can be used to validate a particular entity exists in the
+   * current workspace.
+   */
+  protected function isLabelInContentOverview($label) {
+    $this->drupalGet('/admin/content');
+    $session = $this->getSession();
+    $this->assertSession()->statusCodeEquals(200);
+    $page = $session->getPage();
+    return $page->hasContent($label);
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php
new file mode 100644
index 0000000..a0ed21b
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceUninstallTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests uninstalling the Workspace module.
+ *
+ * @group workspace
+ */
+class WorkspaceUninstallTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $profile = 'standard';
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workspace'];
+
+  /**
+   * Tests deleting workspace entities and uninstalling Workspace module.
+   */
+  public function testUninstallingWorkspace() {
+    $this->drupalLogin($this->rootUser);
+    $this->drupalGet('/admin/modules/uninstall');
+    $session = $this->assertSession();
+    $session->linkExists('Remove workspaces');
+    $this->clickLink('Remove workspaces');
+    $session->pageTextContains('Are you sure you want to delete all workspaces?');
+    $this->drupalPostForm('/admin/modules/uninstall/entity/workspace', [], 'Delete all workspaces');
+    $this->drupalPostForm('admin/modules/uninstall', ['uninstall[workspace]' => TRUE], 'Uninstall');
+    $this->drupalPostForm(NULL, [], 'Uninstall');
+    $session->pageTextContains('The selected modules have been uninstalled.');
+    $session->pageTextNotContains('Workspace');
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php
new file mode 100644
index 0000000..6517c5d
--- /dev/null
+++ b/core/modules/workspace/tests/src/Functional/WorkspaceViewTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\workspace\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests permission controls on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceViewTest extends BrowserTestBase {
+
+  use WorkspaceTestUtilities;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['workspace', 'workspace'];
+
+  /**
+   * Verifies that a user can view their own workspace.
+   */
+  public function testViewOwnWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+      'view own workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    $bears = Workspace::load('bears');
+
+    // Now login as a different user and create a workspace.
+    $editor2 = $this->drupalCreateUser($permissions);
+
+    $this->drupalLogin($editor2);
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $packers = Workspace::load('packers');
+
+    // Load the activate form for the Bears workspace. It should fail because
+    // the workspace belongs to someone else.
+    $this->drupalGet("admin/config/workflow/workspace/{$bears->id()}/activate");
+    $this->assertSession()->statusCodeEquals(403);
+
+    // But editor 2 should be able to activate the Packers workspace.
+    $this->drupalGet("admin/config/workflow/workspace/{$packers->id()}/activate");
+    $this->assertSession()->statusCodeEquals(200);
+  }
+
+  /**
+   * Verifies that a user can view any workspace.
+   */
+  public function testViewAnyWorkspace() {
+    $permissions = [
+      'access administration pages',
+      'administer site configuration',
+      'create workspace',
+      'edit own workspace',
+      'view any workspace',
+    ];
+
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    // Login as a limited-access user and create a workspace.
+    $this->drupalLogin($editor1);
+
+    $this->createWorkspaceThroughUi('Bears', 'bears');
+
+    $bears = Workspace::load('bears');
+
+    // Now login as a different user and create a workspace.
+    $editor2 = $this->drupalCreateUser($permissions);
+
+    $this->drupalLogin($editor2);
+    $this->createWorkspaceThroughUi('Packers', 'packers');
+
+    $packers = Workspace::load('packers');
+
+    // Load the activate form for the Bears workspace. This user should be
+    // able to see both workspaces because of the "view any" permission.
+    $this->drupalGet("admin/config/workflow/workspace/{$bears->id()}/activate");
+
+    $this->assertSession()->statusCodeEquals(200);
+
+    // But editor 2 should be able to activate the Packers workspace.
+    $this->drupalGet("admin/config/workflow/workspace/{$packers->id()}/activate");
+    $this->assertSession()->statusCodeEquals(200);
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php
new file mode 100644
index 0000000..e4cd10d
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceAccessTest.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests access on workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceAccessTest extends KernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'user',
+    'system',
+    'workspace',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installSchema('system', ['sequences']);
+
+    $this->installEntitySchema('workspace');
+    $this->installEntitySchema('workspace_association');
+    $this->installEntitySchema('user');
+
+    // User 1.
+    $this->createUser();
+  }
+
+  /**
+   * Test cases for testWorkspaceAccess().
+   *
+   * @return array
+   *   An array of operations and permissions to test with.
+   */
+  public function operationCases() {
+    return [
+      ['create', 'create workspace'],
+      ['view', 'view any workspace'],
+      ['view', 'view own workspace'],
+      ['update', 'edit any workspace'],
+      ['update', 'edit own workspace'],
+      ['delete', 'delete any workspace'],
+      ['delete', 'delete own workspace'],
+    ];
+  }
+
+  /**
+   * Verifies all workspace roles have the correct access for the operation.
+   *
+   * @param string $operation
+   *   The operation to test with.
+   * @param string $permission
+   *   The permission to test with.
+   *
+   * @dataProvider operationCases
+   */
+  public function testWorkspaceAccess($operation, $permission) {
+    $user = $this->createUser();
+    $this->setCurrentUser($user);
+    $workspace = Workspace::create(['id' => 'oak']);
+    $workspace->save();
+    $role = $this->createRole([$permission]);
+    $user->addRole($role);
+    $this->assertTrue($workspace->access($operation, $user));
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php
new file mode 100644
index 0000000..345cd4d
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceCRUDTest.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace Drupal\Tests\workspace\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\workspace\Entity\Workspace;
+
+/**
+ * Tests CRUD operations for workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceCRUDTest extends KernelTestBase {
+
+  use UserCreationTrait;
+  use NodeCreationTrait;
+  use ContentTypeCreationTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The workspace replication manager.
+   *
+   * @var \Drupal\workspace\WorkspaceManagerInterface
+   */
+  protected $workspaceManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'user',
+    'system',
+    'workspace',
+    'field',
+    'filter',
+    'node',
+    'text',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installSchema('system', ['key_value_expire', 'sequences']);
+    $this->installSchema('node', ['node_access']);
+
+    $this->installEntitySchema('workspace');
+    $this->installEntitySchema('workspace_association');
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+
+    $this->installConfig(['filter', 'node', 'system']);
+
+    $this->createContentType(['type' => 'page']);
+
+    $this->entityTypeManager = \Drupal::entityTypeManager();
+    $this->state = \Drupal::state();
+    $this->workspaceManager = \Drupal::service('workspace.manager');
+  }
+
+  /**
+   * Tests the deletion of workspaces.
+   */
+  public function testDeletingWorkspaces() {
+    $admin = $this->createUser([
+      'administer nodes',
+      'create workspace',
+      'view any workspace',
+      'edit any workspace',
+      'delete any workspace',
+    ]);
+    $this->setCurrentUser($admin);
+
+    /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+    $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+    /** @var \Drupal\node\NodeStorageInterface $node_storage */
+    $node_storage = $this->entityTypeManager->getStorage('node');
+
+    // Create a workspace with a very small number of associated node revisions.
+    $workspace_1 = Workspace::create([
+      'id' => 'gibbon',
+      'label' => 'Gibbon',
+    ]);
+    $workspace_1->save();
+    $this->workspaceManager->setActiveWorkspace($workspace_1);
+
+    $workspace_1_node_1 = $this->createNode(['status' => FALSE]);
+    $workspace_1_node_2 = $this->createNode(['status' => FALSE]);
+    for ($i = 0; $i < 4; $i++) {
+      $workspace_1_node_1->setNewRevision(TRUE);
+      $workspace_1_node_1->save();
+
+      $workspace_1_node_2->setNewRevision(TRUE);
+      $workspace_1_node_2->save();
+    }
+
+    // The workspace should have 10 associated node revisions, 5 for each node.
+    $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_1->id(), TRUE, FALSE);
+    $this->assertCount(10, $associated_revisions);
+
+    // Check that we are allowed to delete the workspace.
+    $this->assertTrue($workspace_1->access('delete', $admin));
+
+    // Delete the workspace and check that all the workspace_association
+    // entities and all the node revisions have been deleted as well.
+    $workspace_1->delete();
+
+    $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_1->id(), TRUE, FALSE);
+    $this->assertCount(0, $associated_revisions);
+    $node_revision_count = $node_storage
+      ->getQuery()
+      ->allRevisions()
+      ->count()
+      ->execute();
+    $this->assertEquals(0, $node_revision_count);
+
+    // Create another workspace, this time with a larger number of associated
+    // node revisions so we can test the batch purge process.
+    $workspace_2 = Workspace::create([
+      'id' => 'baboon',
+      'label' => 'Baboon',
+    ]);
+    $workspace_2->save();
+    $this->workspaceManager->setActiveWorkspace($workspace_2);
+
+    $workspace_2_node_1 = $this->createNode(['status' => FALSE]);
+    for ($i = 0; $i < 59; $i++) {
+      $workspace_2_node_1->setNewRevision(TRUE);
+      $workspace_2_node_1->save();
+    }
+
+    // The workspace should have 60 associated node revisions.
+    $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE, FALSE);
+    $this->assertCount(60, $associated_revisions);
+
+    // Delete the workspace and check that we still have 10 revision left to
+    // delete.
+    $workspace_2->delete();
+
+    $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE, FALSE);
+    $this->assertCount(10, $associated_revisions);
+
+    $workspace_deleted = \Drupal::state()->get('workspace.deleted');
+    $this->assertCount(1, $workspace_deleted);
+
+    // Check that we can not create another workspace with the same ID while its
+    // data purging is not finished.
+    $workspace_3 = Workspace::create([
+      'id' => 'baboon',
+      'label' => 'Baboon',
+    ]);
+    $violations = $workspace_3->validate();
+    $this->assertCount(1, $violations);
+    $this->assertEquals('A workspace with this ID has been deleted but data still exists for it.', $violations[0]->getMessage());
+
+    // Running cron should delete the remaining data as well as the workspace ID
+    // from the "workspace.delete" state entry.
+    \Drupal::service('cron')->run();
+
+    $associated_revisions = $workspace_association_storage->getTrackedEntities($workspace_2->id(), TRUE, FALSE);
+    $this->assertCount(0, $associated_revisions);
+    $node_revision_count = $node_storage
+      ->getQuery()
+      ->allRevisions()
+      ->count()
+      ->execute();
+    $this->assertEquals(0, $node_revision_count);
+
+    $workspace_deleted = \Drupal::state()->get('workspace.deleted');
+    $this->assertCount(0, $workspace_deleted);
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php
new file mode 100644
index 0000000..f0d9d64
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceIntegrationTest.php
@@ -0,0 +1,679 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\entity_test\Entity\EntityTestMulRev;
+use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+use Drupal\views\Tests\ViewResultAssertionTrait;
+use Drupal\views\Views;
+use Drupal\workspace\Entity\Workspace;
+
+/**
+ * Tests a complete deployment scenario across different workspaces.
+ *
+ * @group workspace
+ */
+class WorkspaceIntegrationTest extends KernelTestBase {
+
+  use ContentTypeCreationTrait;
+  use EntityReferenceTestTrait;
+  use NodeCreationTrait;
+  use UserCreationTrait;
+  use ViewResultAssertionTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * An array of test workspaces, keyed by workspace ID.
+   *
+   * @var \Drupal\workspace\WorkspaceInterface[]
+   */
+  protected $workspaces = [];
+
+  /**
+   * Creation timestamp that should be incremented for each new entity.
+   *
+   * @var int
+   */
+  protected $createdTimestamp;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'entity_test',
+    'field',
+    'filter',
+    'node',
+    'text',
+    'user',
+    'system',
+    'views',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->entityTypeManager = \Drupal::entityTypeManager();
+
+    $this->installConfig(['filter', 'node', 'system']);
+
+    $this->installSchema('system', ['key_value_expire', 'sequences']);
+    $this->installSchema('node', ['node_access']);
+
+    $this->installEntitySchema('entity_test_mulrev');
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+
+    $this->createContentType(['type' => 'page']);
+
+    $this->setCurrentUser($this->createUser(['administer nodes']));
+
+    // Create two nodes, a published and an unpublished one, so we can test the
+    // behavior of the module with default/existing content.
+    $this->createdTimestamp = \Drupal::time()->getRequestTime();
+    $this->createNode(['title' => 'live - 1 - r1 - published', 'created' => $this->createdTimestamp++, 'status' => TRUE]);
+    $this->createNode(['title' => 'live - 2 - r2 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]);
+  }
+
+  /**
+   * Enables the Workspace module and creates two workspaces.
+   */
+  protected function initializeWorkspaceModule() {
+    // Enable the Workspace module here instead of the static::$modules array so
+    // we can test it with default content.
+    $this->enableModules(['workspace']);
+    $this->container = \Drupal::getContainer();
+    $this->entityTypeManager = \Drupal::entityTypeManager();
+
+    $this->installEntitySchema('workspace');
+    $this->installEntitySchema('workspace_association');
+
+    // Create two workspaces by default, 'live' and 'stage'.
+    $this->workspaces['live'] = Workspace::create(['id' => 'live']);
+    $this->workspaces['live']->save();
+    $this->workspaces['stage'] = Workspace::create(['id' => 'stage', 'target' => 'live']);
+    $this->workspaces['stage']->save();
+
+    $permissions = [
+      'administer nodes',
+      'create workspace',
+      'edit any workspace',
+      'view any workspace',
+    ];
+    $this->setCurrentUser($this->createUser($permissions));
+  }
+
+  /**
+   * Tests various scenarios for creating and deploying content in workspaces.
+   */
+  public function testWorkspaces() {
+    $this->initializeWorkspaceModule();
+
+    // Notes about the structure of the test scenarios:
+    // - a multi-dimensional array keyed by the workspace ID, then by the entity
+    //   ID and finally by the revision ID.
+    // - 'default_revision' indicates the entity revision that should be
+    //   returned by entity_load(), non-revision entity queries and non-revision
+    //   views *in a given workspace*, it does not indicate what is actually
+    //   stored in the base and data entity tables.
+    $test_scenarios = [];
+
+    // The $expected_workspace_association array holds the revision IDs which
+    // should be tracked by the Workspace Association entity type in each test
+    // scenario, keyed by workspace ID.
+    $expected_workspace_association = [];
+
+    // In the initial state we have only the two revisions that were created
+    // before the Workspace module was installed.
+    $revision_state = [
+      'live' => [
+        1 => [
+          1 => [
+            'title' => 'live - 1 - r1 - published',
+            'status' => TRUE,
+            'default_revision' => TRUE,
+          ],
+        ],
+        2 => [
+          2 => [
+            'title' => 'live - 2 - r2 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+      'stage' => [
+        1 => [
+          1 => [
+            'title' => 'live - 1 - r1 - published',
+            'status' => TRUE,
+            'default_revision' => TRUE,
+          ],
+        ],
+        2 => [
+          2 => [
+            'title' => 'live - 2 - r2 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ];
+    $test_scenarios['initial_state'] = $revision_state;
+    $expected_workspace_association['initial_state'] = ['stage' => []];
+
+    // Unpublish node 1 in 'stage'. The new revision is also added to 'live' but
+    // it is not the default revision.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        1 => [
+          3 => [
+            'title' => 'stage - 1 - r3 - unpublished',
+            'status' => FALSE,
+            'default_revision' => FALSE,
+          ],
+        ],
+      ],
+      'stage' => [
+        1 => [
+          1 => ['default_revision' => FALSE],
+          3 => [
+            'title' => 'stage - 1 - r3 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ]);
+    $test_scenarios['unpublish_node_1_in_stage'] = $revision_state;
+    $expected_workspace_association['unpublish_node_1_in_stage'] = ['stage' => [3]];
+
+    // Publish node 2 in 'stage'. The new revision is also added to 'live' but
+    // it is not the default revision.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        2 => [
+          4 => [
+            'title' => 'stage - 2 - r4 - published',
+            'status' => TRUE,
+            'default_revision' => FALSE,
+          ],
+        ],
+      ],
+      'stage' => [
+        2 => [
+          2 => ['default_revision' => FALSE],
+          4 => [
+            'title' => 'stage - 2 - r4 - published',
+            'status' => TRUE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ]);
+    $test_scenarios['publish_node_2_in_stage'] = $revision_state;
+    $expected_workspace_association['publish_node_2_in_stage'] = ['stage' => [3, 4]];
+
+    // Adding a new unpublished node on 'stage' should create a single
+    // unpublished revision on both 'stage' and 'live'.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        3 => [
+          5 => [
+            'title' => 'stage - 3 - r5 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+      'stage' => [
+        3 => [
+          5 => [
+            'title' => 'stage - 3 - r5 - unpublished',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ]);
+    $test_scenarios['add_unpublished_node_in_stage'] = $revision_state;
+    $expected_workspace_association['add_unpublished_node_in_stage'] = ['stage' => [3, 4, 5]];
+
+    // Adding a new published node on 'stage' should create two revisions, an
+    // unpublished revision on 'live' and a published one on 'stage'.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        4 => [
+          6 => [
+            'title' => 'stage - 4 - r6 - published',
+            'status' => FALSE,
+            'default_revision' => TRUE,
+          ],
+          7 => [
+            'title' => 'stage - 4 - r6 - published',
+            'status' => TRUE,
+            'default_revision' => FALSE,
+          ],
+        ],
+      ],
+      'stage' => [
+        4 => [
+          6 => [
+            'title' => 'stage - 4 - r6 - published',
+            'status' => FALSE,
+            'default_revision' => FALSE,
+          ],
+          7 => [
+            'title' => 'stage - 4 - r6 - published',
+            'status' => TRUE,
+            'default_revision' => TRUE,
+          ],
+        ],
+      ],
+    ]);
+    $test_scenarios['add_published_node_in_stage'] = $revision_state;
+    $expected_workspace_association['add_published_node_in_stage'] = ['stage' => [3, 4, 5, 6, 7]];
+
+    // Deploying 'stage' to 'live' should simply make the latest revisions in
+    // 'stage' the default ones in 'live'.
+    $revision_state = array_replace_recursive($revision_state, [
+      'live' => [
+        1 => [
+          1 => ['default_revision' => FALSE],
+          3 => ['default_revision' => TRUE],
+        ],
+        2 => [
+          2 => ['default_revision' => FALSE],
+          4 => ['default_revision' => TRUE],
+        ],
+        // Node 3 has a single revision for both 'stage' and 'live' and it is
+        // already the default revision in both of them.
+        4 => [
+          6 => ['default_revision' => FALSE],
+          7 => ['default_revision' => TRUE],
+        ],
+      ],
+    ]);
+    $test_scenarios['push_stage_to_live'] = $revision_state;
+    $expected_workspace_association['push_stage_to_live'] = ['stage' => []];
+
+    // Check the initial state after the module was installed.
+    $this->assertWorkspaceStatus($test_scenarios['initial_state'], 'node');
+    $this->assertWorkspaceAssociation($expected_workspace_association['initial_state'], 'node');
+
+    // Unpublish node 1 in 'stage'.
+    $this->switchToWorkspace('stage');
+    $node = $this->entityTypeManager->getStorage('node')->load(1);
+    $node->setTitle('stage - 1 - r3 - unpublished');
+    $node->setUnpublished();
+    $node->save();
+    $this->assertWorkspaceStatus($test_scenarios['unpublish_node_1_in_stage'], 'node');
+    $this->assertWorkspaceAssociation($expected_workspace_association['unpublish_node_1_in_stage'], 'node');
+
+    // Publish node 2 in 'stage'.
+    $this->switchToWorkspace('stage');
+    $node = $this->entityTypeManager->getStorage('node')->load(2);
+    $node->setTitle('stage - 2 - r4 - published');
+    $node->setPublished();
+    $node->save();
+    $this->assertWorkspaceStatus($test_scenarios['publish_node_2_in_stage'], 'node');
+    $this->assertWorkspaceAssociation($expected_workspace_association['publish_node_2_in_stage'], 'node');
+
+    // Add a new unpublished node on 'stage'.
+    $this->switchToWorkspace('stage');
+    $this->createNode(['title' => 'stage - 3 - r5 - unpublished', 'created' => $this->createdTimestamp++, 'status' => FALSE]);
+    $this->assertWorkspaceStatus($test_scenarios['add_unpublished_node_in_stage'], 'node');
+    $this->assertWorkspaceAssociation($expected_workspace_association['add_unpublished_node_in_stage'], 'node');
+
+    // Add a new published node on 'stage'.
+    $this->switchToWorkspace('stage');
+    $this->createNode(['title' => 'stage - 4 - r6 - published', 'created' => $this->createdTimestamp++, 'status' => TRUE]);
+    $this->assertWorkspaceStatus($test_scenarios['add_published_node_in_stage'], 'node');
+    $this->assertWorkspaceAssociation($expected_workspace_association['add_published_node_in_stage'], 'node');
+
+    // Deploy 'stage' to 'live'.
+    $stage_repository_handler = $this->workspaces['stage']->getRepositoryHandlerPlugin();
+
+    // Check which revisions need to be pushed.
+    $expected = [
+      'node' => [
+        3 => 1,
+        4 => 2,
+        5 => 3,
+        7 => 4,
+      ],
+    ];
+    $this->assertEquals($expected, $stage_repository_handler->getSourceRevisionDifference());
+
+    $stage_repository_handler->push();
+    $this->assertWorkspaceStatus($test_scenarios['push_stage_to_live'], 'node');
+    $this->assertWorkspaceAssociation($expected_workspace_association['push_stage_to_live'], 'node');
+
+    // Check that there are no more revisions to push.
+    $this->assertEmpty($stage_repository_handler->getSourceRevisionDifference());
+  }
+
+  /**
+   * Tests the Entity Query relationship API with workspaces.
+   */
+  public function testEntityQueryRelationship() {
+    $this->initializeWorkspaceModule();
+
+    // Add an entity reference field that targets 'entity_test_mulrev' entities.
+    $this->createEntityReferenceField('node', 'page', 'field_test_entity', 'Test entity reference', 'entity_test_mulrev');
+
+    // Add an entity reference field that targets 'node' entities so we can test
+    // references to the same base tables.
+    $this->createEntityReferenceField('node', 'page', 'field_test_node', 'Test node reference', 'node');
+
+    $this->switchToWorkspace('live');
+    $node_1 = $this->createNode([
+      'title' => 'live node 1'
+    ]);
+    $entity_test = EntityTestMulRev::create([
+      'name' => 'live entity_test_mulrev',
+      'non_rev_field' => 'live non-revisionable value',
+    ]);
+    $entity_test->save();
+
+    $node_2 = $this->createNode([
+      'title' => 'live node 2',
+      'field_test_entity' => $entity_test->id(),
+      'field_test_node' => $node_1->id(),
+    ]);
+
+    // Switch to the 'stage' workspace and change some values for the referenced
+    // entities.
+    $this->switchToWorkspace('stage');
+    $node_1->title->value = 'stage node 1';
+    $node_1->save();
+
+    $node_2->title->value = 'stage node 2';
+    $node_2->save();
+
+    $entity_test->name->value = 'stage entity_test_mulrev';
+    $entity_test->non_rev_field->value = 'stage non-revisionable value';
+    $entity_test->save();
+
+    // Make sure that we're requesting the default revision.
+    $query = $this->entityTypeManager->getStorage('node')->getQuery();
+    $query->currentRevision();
+
+    $query
+      // Check a condition on the revision data table.
+      ->condition('title', 'stage node 2')
+      // Check a condition on the revision table.
+      ->condition('revision_uid', $node_2->getRevisionUserId())
+      // Check a condition on the data table.
+      ->condition('type', $node_2->bundle())
+      // Check a condition on the base table.
+      ->condition('uuid', $node_2->uuid());
+
+    // Add conditions for a reference to the same entity type.
+    $query
+      // Check a condition on the revision data table.
+      ->condition('field_test_node.entity.title', 'stage node 1')
+      // Check a condition on the revision table.
+      ->condition('field_test_node.entity.revision_uid', $node_1->getRevisionUserId())
+      // Check a condition on the data table.
+      ->condition('field_test_node.entity.type', $node_1->bundle())
+      // Check a condition on the base table.
+      ->condition('field_test_node.entity.uuid', $node_1->uuid());
+
+    // Add conditions for a reference to a different entity type.
+    $query
+      // Check a condition on the revision data table.
+      ->condition('field_test_entity.entity.name', 'stage entity_test_mulrev')
+      // Check a condition on the data table.
+      ->condition('field_test_entity.entity.non_rev_field', 'stage non-revisionable value')
+      // Check a condition on the base table.
+      ->condition('field_test_entity.entity.uuid', $entity_test->uuid());
+
+    $result = $query->execute();
+    $this->assertSame([$node_2->getRevisionId() => $node_2->id()], $result);
+  }
+
+  /**
+   * Checks entity load, entity queries and views results for a test scenario.
+   *
+   * @param array $expected
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type that is being tested.
+   */
+  protected function assertWorkspaceStatus(array $expected, $entity_type_id) {
+    $expected = $this->flattenExpectedValues($expected, $entity_type_id);
+
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    foreach ($expected as $workspace_id => $expected_values) {
+      $this->switchToWorkspace($workspace_id);
+
+      // Check that default revisions are swapped with the workspace revision.
+      $this->assertEntityLoad($expected_values, $entity_type_id);
+
+      // Check that non-default revisions are not changed.
+      $this->assertEntityRevisionLoad($expected_values, $entity_type_id);
+
+      // Check that entity queries return the correct results.
+      $this->assertEntityQuery($expected_values, $entity_type_id);
+
+      // Check that the 'Frontpage' view only shows published content that is
+      // also considered as the default revision in the given workspace.
+      $expected_frontpage = array_filter($expected_values, function ($expected_value) {
+        return $expected_value['status'] === TRUE && $expected_value['default_revision'] === TRUE;
+      });
+      // The 'Frontpage' view will output nodes in reverse creation order.
+      usort($expected_frontpage, function ($a, $b) {
+        return $b['nid'] - $a['nid'];
+      });
+      $view = Views::getView('frontpage');
+      $view->execute();
+      $this->assertIdenticalResultset($view, $expected_frontpage, ['nid' => 'nid']);
+
+      $rendered_view = $view->render('page_1');
+      $output = \Drupal::service('renderer')->renderRoot($rendered_view);
+      $this->setRawContent($output);
+      foreach ($expected_values as $expected_entity_values) {
+        if ($expected_entity_values[$entity_keys['published']] === TRUE && $expected_entity_values['default_revision'] === TRUE) {
+          $this->assertRaw($expected_entity_values[$entity_keys['label']]);
+        }
+        // Node 4 will always appear in the 'stage' workspace because it has
+        // both an unpublished revision as well as a published one.
+        elseif ($workspace_id != 'stage' && $expected_entity_values[$entity_keys['id']] != 4) {
+          $this->assertNoRaw($expected_entity_values[$entity_keys['label']]);
+        }
+      }
+    }
+  }
+
+  /**
+   * Asserts that default revisions are properly swapped in a workspace.
+   *
+   * @param array $expected_values
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type to check.
+   */
+  protected function assertEntityLoad(array $expected_values, $entity_type_id) {
+    // Filter the expected values so we can check only the default revisions.
+    $expected_default_revisions = array_filter($expected_values, function ($expected_value) {
+      return $expected_value['default_revision'] === TRUE;
+    });
+
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    $id_key = $entity_keys['id'];
+    $revision_key = $entity_keys['revision'];
+    $label_key = $entity_keys['label'];
+    $published_key = $entity_keys['published'];
+
+    // Check \Drupal\Core\Entity\EntityStorageInterface::loadMultiple().
+    /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+    $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultiple(array_column($expected_default_revisions, $id_key));
+    foreach ($expected_default_revisions as $expected_default_revision) {
+      $entity_id = $expected_default_revision[$id_key];
+      $this->assertEquals($expected_default_revision[$revision_key], $entities[$entity_id]->getRevisionId());
+      $this->assertEquals($expected_default_revision[$label_key], $entities[$entity_id]->label());
+      $this->assertEquals($expected_default_revision[$published_key], $entities[$entity_id]->isPublished());
+    }
+
+    // Check \Drupal\Core\Entity\EntityStorageInterface::loadUnchanged().
+    foreach ($expected_default_revisions as $expected_default_revision) {
+      /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+      $entity = $this->entityTypeManager->getStorage($entity_type_id)->loadUnchanged($expected_default_revision[$id_key]);
+      $this->assertEquals($expected_default_revision[$revision_key], $entity->getRevisionId());
+      $this->assertEquals($expected_default_revision[$label_key], $entity->label());
+      $this->assertEquals($expected_default_revision[$published_key], $entity->isPublished());
+    }
+  }
+
+  /**
+   * Asserts that non-default revisions are not changed.
+   *
+   * @param array $expected_values
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type to check.
+   */
+  protected function assertEntityRevisionLoad(array $expected_values, $entity_type_id) {
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    $id_key = $entity_keys['id'];
+    $revision_key = $entity_keys['revision'];
+    $label_key = $entity_keys['label'];
+    $published_key = $entity_keys['published'];
+
+    /** @var \Drupal\Core\Entity\ContentEntityInterface[]|\Drupal\Core\Entity\EntityPublishedInterface[] $entities */
+    $entities = $this->entityTypeManager->getStorage($entity_type_id)->loadMultipleRevisions(array_column($expected_values, $revision_key));
+    foreach ($expected_values as $expected_revision) {
+      $revision_id = $expected_revision[$revision_key];
+      $this->assertEquals($expected_revision[$id_key], $entities[$revision_id]->id());
+      $this->assertEquals($expected_revision[$revision_key], $entities[$revision_id]->getRevisionId());
+      $this->assertEquals($expected_revision[$label_key], $entities[$revision_id]->label());
+      $this->assertEquals($expected_revision[$published_key], $entities[$revision_id]->isPublished());
+    }
+  }
+
+  /**
+   * Asserts that entity queries are giving the correct results in a workspace.
+   *
+   * @param array $expected_values
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type to check.
+   */
+  protected function assertEntityQuery(array $expected_values, $entity_type_id) {
+    $storage = $this->entityTypeManager->getStorage($entity_type_id);
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    $id_key = $entity_keys['id'];
+    $revision_key = $entity_keys['revision'];
+    $label_key = $entity_keys['label'];
+    $published_key = $entity_keys['published'];
+
+    // Filter the expected values so we can check only the default revisions.
+    $expected_default_revisions = array_filter($expected_values, function ($expected_value) {
+      return $expected_value['default_revision'] === TRUE;
+    });
+
+    // Check entity query counts.
+    $result = $storage->getQuery()->count()->execute();
+    $this->assertEquals(count($expected_default_revisions), $result);
+
+    $result = $storage->getAggregateQuery()->count()->execute();
+    $this->assertEquals(count($expected_default_revisions), $result);
+
+    // Check entity queries with no conditions.
+    $result = $storage->getQuery()->execute();
+    $expected_result = array_combine(array_column($expected_default_revisions, $revision_key), array_column($expected_default_revisions, $id_key));
+    $this->assertEquals($expected_result, $result);
+
+    // Check querying each revision individually.
+    foreach ($expected_values as $expected_value) {
+      $query = $storage->getQuery();
+      $query
+        ->condition($entity_keys['id'], $expected_value[$id_key])
+        ->condition($entity_keys['label'], $expected_value[$label_key])
+        ->condition($entity_keys['published'], (int) $expected_value[$published_key]);
+
+      // If the entity is not expected to be the default revision, we need to
+      // query all revisions if we want to find it.
+      if (!$expected_value['default_revision']) {
+        $query->allRevisions();
+      }
+
+      $result = $query->execute();
+      $this->assertEquals([$expected_value[$revision_key] => $expected_value[$id_key]], $result);
+    }
+  }
+
+  /**
+   * Checks the workspace_association entries for a test scenario.
+   *
+   * @param array $expected
+   *   An array of expected values, as defined in ::testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type that is being tested.
+   */
+  protected function assertWorkspaceAssociation(array $expected, $entity_type_id) {
+    /** @var \Drupal\workspace\WorkspaceAssociationStorageInterface $workspace_association_storage */
+    $workspace_association_storage = $this->entityTypeManager->getStorage('workspace_association');
+    foreach ($expected as $workspace_id => $expected_tracked_revision_ids) {
+      $tracked_entities = $workspace_association_storage->getTrackedEntities($workspace_id, TRUE, FALSE);
+      $tracked_entities = array_filter($tracked_entities, function ($tracked_entity) use ($entity_type_id) {
+        return $tracked_entity['entity_type_id'] === $entity_type_id;
+      });
+      $this->assertEquals($expected_tracked_revision_ids, array_column($tracked_entities, 'revision_id'));
+    }
+  }
+
+  /**
+   * Sets a given workspace as active.
+   *
+   * @param string $workspace_id
+   *   The ID of the workspace to switch to.
+   */
+  protected function switchToWorkspace($workspace_id) {
+    // Switch the test runner's context to the specified workspace.
+    $workspace = $this->entityTypeManager->getStorage('workspace')->load($workspace_id);
+    \Drupal::service('workspace.manager')->setActiveWorkspace($workspace);
+  }
+
+  /**
+   * Flattens the expectations array defined by testWorkspaces().
+   *
+   * @param array $expected
+   *   An array as defined by testWorkspaces().
+   * @param string $entity_type_id
+   *   The ID of the entity type that is being tested.
+   *
+   * @return array
+   *   An array where all the entity IDs and revision IDs are merged inside each
+   *   expected values array.
+   */
+  protected function flattenExpectedValues(array $expected, $entity_type_id) {
+    $flattened = [];
+
+    $entity_keys = $this->entityTypeManager->getDefinition($entity_type_id)->getKeys();
+    foreach ($expected as $workspace_id => $workspace_values) {
+      foreach ($workspace_values as $entity_id => $entity_revisions) {
+        foreach ($entity_revisions as $revision_id => $revision_values) {
+          $flattened[$workspace_id][] = [$entity_keys['id'] => $entity_id, $entity_keys['revision'] => $revision_id] + $revision_values;
+        }
+      }
+    }
+
+    return $flattened;
+  }
+
+}
diff --git a/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php
new file mode 100644
index 0000000..f2a859e
--- /dev/null
+++ b/core/modules/workspace/tests/src/Kernel/WorkspaceInternalResourceTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\workspace\Kernel;
+
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\rest\Entity\RestResourceConfig;
+use Drupal\rest\RestResourceConfigInterface;
+
+/**
+ * Tests REST module with internal workspace entity types.
+ *
+ * @group workspace
+ */
+class WorkspaceInternalResourceTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['user', 'serialization', 'rest', 'workspace'];
+
+  /**
+   * Tests enabling workspace associations for REST throws an exception.
+   *
+   * @see workspace_rest_resource_alter()
+   */
+  public function testCreateWorkspaceAssociationResource() {
+    $this->setExpectedException(PluginNotFoundException::class, 'The "entity:workspace_association" plugin does not exist.');
+    RestResourceConfig::create([
+      'id' => 'entity.workspace_association',
+      'granularity' => RestResourceConfigInterface::RESOURCE_GRANULARITY,
+      'configuration' => [
+        'methods' => ['GET'],
+        'formats' => ['json'],
+        'authentication' => ['cookie'],
+      ],
+    ])
+      ->enable()
+      ->save();
+  }
+
+}
diff --git a/core/modules/workspace/workspace.info.yml b/core/modules/workspace/workspace.info.yml
new file mode 100644
index 0000000..fad98f8
--- /dev/null
+++ b/core/modules/workspace/workspace.info.yml
@@ -0,0 +1,7 @@
+name: Workspace
+type: module
+description: 'Provides the ability to have multiple workspaces on a single site to facilitate things like full-site preview and content staging.'
+version: VERSION
+core: 8.x
+package: Core (Experimental)
+configure: entity.workspace.collection
diff --git a/core/modules/workspace/workspace.install b/core/modules/workspace/workspace.install
new file mode 100644
index 0000000..0be1294
--- /dev/null
+++ b/core/modules/workspace/workspace.install
@@ -0,0 +1,47 @@
+<?php
+
+/**
+ * @file
+ * Contains install, update and uninstall functions for the workspace module.
+ */
+
+use Drupal\workspace\Entity\Workspace;
+use Drupal\workspace\RepositoryHandlerInterface;
+
+/**
+ * Implements hook_install().
+ */
+function workspace_install() {
+  // Set the owner of these default workspaces to be first user which which has
+  // the 'administrator' role. This way we avoid hard coding user ID 1 for sites
+  // that prefer to not give it any special meaning.
+  $admin_roles = \Drupal::entityTypeManager()->getStorage('user_role')->getQuery()
+    ->condition('is_admin', TRUE)
+    ->execute();
+  if (!empty($admin_roles)) {
+    $query = \Drupal::entityTypeManager()->getStorage('user')->getQuery()
+      ->condition('roles', $admin_roles, 'IN')
+      ->condition('status', 1)
+      ->sort('uid', 'ASC')
+      ->range(0, 1);
+    $result = $query->execute();
+  }
+
+  // Default to user ID 1 if we could not find any other administrator users.
+  $owner_id = !empty($result) ? reset($result) : 1;
+
+  // Create two workspaces by default, 'live' and 'stage'.
+  Workspace::create([
+    'id' => 'live',
+    'label' => 'Live',
+    'target' => RepositoryHandlerInterface::EMPTY_VALUE,
+    'uid' => $owner_id,
+  ])->save();
+
+  Workspace::create([
+    'id' => 'stage',
+    'label' => 'Stage',
+    'target' => 'live',
+    'uid' => $owner_id,
+  ])->save();
+}
diff --git a/core/modules/workspace/workspace.libraries.yml b/core/modules/workspace/workspace.libraries.yml
new file mode 100644
index 0000000..c7aad42
--- /dev/null
+++ b/core/modules/workspace/workspace.libraries.yml
@@ -0,0 +1,5 @@
+drupal.workspace.toolbar:
+  version: VERSION
+  css:
+    theme:
+      css/workspace.toolbar.css: {}
diff --git a/core/modules/workspace/workspace.link_relation_types.yml b/core/modules/workspace/workspace.link_relation_types.yml
new file mode 100644
index 0000000..d591603
--- /dev/null
+++ b/core/modules/workspace/workspace.link_relation_types.yml
@@ -0,0 +1,8 @@
+# Workspace extension relation types.
+# See https://tools.ietf.org/html/rfc5988#section-4.2.
+activate-form:
+  uri: https://drupal.org/link-relations/activate-form
+  description: A form where a workspace can be activated.
+deploy-form:
+  uri: https://drupal.org/link-relations/deploy-form
+  description: A form where a workspace can be deployed.
diff --git a/core/modules/workspace/workspace.links.action.yml b/core/modules/workspace/workspace.links.action.yml
new file mode 100644
index 0000000..9f22598
--- /dev/null
+++ b/core/modules/workspace/workspace.links.action.yml
@@ -0,0 +1,5 @@
+entity.workspace.add_form:
+  route_name: entity.workspace.add_form
+  title: 'Add workspace'
+  appears_on:
+    - entity.workspace.collection
diff --git a/core/modules/workspace/workspace.links.menu.yml b/core/modules/workspace/workspace.links.menu.yml
new file mode 100644
index 0000000..c7faefb
--- /dev/null
+++ b/core/modules/workspace/workspace.links.menu.yml
@@ -0,0 +1,5 @@
+entity.workspace.collection:
+  title: 'Workspaces'
+  parent: system.admin_config_workflow
+  description: 'Create and manage workspaces.'
+  route_name: entity.workspace.collection
diff --git a/core/modules/workspace/workspace.module b/core/modules/workspace/workspace.module
new file mode 100644
index 0000000..ca43e86
--- /dev/null
+++ b/core/modules/workspace/workspace.module
@@ -0,0 +1,260 @@
+<?php
+
+/**
+ * @file
+ * Provides full-site preview functionality for content staging.
+ */
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Url;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\views\Plugin\views\query\QueryPluginBase;
+use Drupal\views\ViewExecutable;
+use Drupal\workspace\EntityAccess;
+use Drupal\workspace\EntityOperations;
+use Drupal\workspace\EntityTypeInfo;
+use Drupal\workspace\ViewsQueryAlter;
+
+/**
+ * Implements hook_help().
+ */
+function workspace_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    // Main module help for the workspace module.
+    case 'help.page.workspace':
+      $output = '';
+      $output .= '<h3>' . t('About') . '</h3>';
+      $output .= '<p>' . t('The Workspace module allows workspaces to be defined and switched between. Content is then assigned to the active workspace when created. For more information, see the <a href=":workspace">online documentation for the Workspace module</a>.', [':workspace' => 'https://www.drupal.org/node/2824024']) . '</p>';
+      return $output;
+  }
+}
+
+/**
+ * Implements hook_entity_type_build().
+ */
+function workspace_entity_type_build(array &$entity_types) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityTypeInfo::class)
+    ->entityTypeBuild($entity_types);
+}
+
+/**
+ * Implements hook_form_alter().
+ */
+function workspace_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityTypeInfo::class)
+    ->formAlter($form, $form_state, $form_id);
+}
+
+/**
+ * Implements hook_entity_load().
+ */
+function workspace_entity_load(array &$entities, $entity_type_id) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityOperations::class)
+    ->entityLoad($entities, $entity_type_id);
+}
+
+/**
+ * Implements hook_entity_presave().
+ */
+function workspace_entity_presave(EntityInterface $entity) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityOperations::class)
+    ->entityPresave($entity);
+}
+
+/**
+ * Implements hook_entity_insert().
+ */
+function workspace_entity_insert(EntityInterface $entity) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityOperations::class)
+    ->entityInsert($entity);
+}
+
+/**
+ * Implements hook_entity_update().
+ */
+function workspace_entity_update(EntityInterface $entity) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityOperations::class)
+    ->entityUpdate($entity);
+}
+
+/**
+ * Implements hook_entity_access().
+ *
+ * @see \Drupal\workspace\EntityAccess
+ */
+function workspace_entity_access(EntityInterface $entity, $operation, AccountInterface $account) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityAccess::class)
+    ->entityOperationAccess($entity, $operation, $account);
+}
+
+/**
+ * Implements hook_entity_create_access().
+ *
+ * @see \Drupal\workspace\EntityAccess
+ */
+function workspace_entity_create_access(AccountInterface $account, array $context, $entity_bundle) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(EntityAccess::class)
+    ->entityCreateAccess($account, $context, $entity_bundle);
+}
+
+/**
+ * Implements hook_views_query_alter().
+ */
+function workspace_views_query_alter(ViewExecutable $view, QueryPluginBase $query) {
+  return \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(ViewsQueryAlter::class)
+    ->alterQuery($view, $query);
+}
+
+/**
+ * Implements hook_rest_resource_alter().
+ */
+function workspace_rest_resource_alter(&$definitions) {
+  // WorkspaceAssociation is an internal entity types, therefore it should not
+  // be exposed via REST.
+  unset($definitions['entity:workspace_association']);
+}
+
+/**
+ * Implements hook_cron().
+ */
+function workspace_cron() {
+  \Drupal::service('workspace.manager')->purgeDeletedWorkspacesBatch();
+}
+
+/**
+ * Implements hook_toolbar().
+ */
+function workspace_toolbar() {
+  $items = [];
+  $items['workspace'] = [
+    '#cache' => [
+      'contexts' => [
+        'user.permissions',
+      ],
+    ],
+  ];
+
+  $current_user = \Drupal::currentUser();
+  if (!$current_user->hasPermission('administer workspaces')
+    || !$current_user->hasPermission('view own workspace')
+    || !$current_user->hasPermission('view any workspace')) {
+    return $items;
+  }
+
+  /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */
+  $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace();
+
+  $configure_link = NULL;
+  if ($current_user->hasPermission('administer workspaces')) {
+    $configure_link = [
+      '#type' => 'link',
+      '#title' => t('Manage workspaces'),
+      '#url' => $active_workspace->toUrl('collection'),
+      '#options' => ['attributes' => ['class' => ['manage-workspaces']]],
+    ];
+  }
+
+  $items['workspace'] = [
+    '#type' => 'toolbar_item',
+    'tab' => [
+      '#type' => 'link',
+      '#title' => $active_workspace->label(),
+      '#url' => $active_workspace->toUrl('collection'),
+      '#attributes' => [
+        'title' => t('Switch workspace'),
+        'class' => ['toolbar-icon', 'toolbar-icon-workspace'],
+      ],
+    ],
+    'tray' => [
+      '#heading' => t('Workspaces'),
+      'workspaces' => workspace_renderable_links(),
+      'configure' => $configure_link,
+    ],
+    '#wrapper_attributes' => [
+      'class' => ['workspace-toolbar-tab'],
+    ],
+    '#attached' => [
+      'library' => ['workspace/drupal.workspace.toolbar'],
+    ],
+    '#weight' => 500,
+  ];
+
+  // Add a special class to the wrapper if we are in the default workspace so we
+  // can highlight it with a different color.
+  if ($active_workspace->isDefaultWorkspace()) {
+    $items['workspace']['#wrapper_attributes']['class'][] = 'is-live';
+  }
+
+  return $items;
+}
+
+/**
+ * Returns an array of workspace activation form links, suitable for rendering.
+ *
+ * @return array
+ *   A render array containing links to the workspace activation form.
+ */
+function workspace_renderable_links() {
+  $entity_type_manager = \Drupal::entityTypeManager();
+  /** @var \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository */
+  $entity_repository = \Drupal::service('entity.repository');
+  /** @var \Drupal\workspace\WorkspaceInterface $active_workspace */
+  $active_workspace = \Drupal::service('workspace.manager')->getActiveWorkspace();
+
+  $links = $cache_tags = [];
+  foreach ($entity_type_manager->getStorage('workspace')->loadMultiple() as $workspace) {
+    $workspace = $entity_repository->getTranslationFromContext($workspace);
+
+    // Add the 'is-active' class for the currently active workspace.
+    $options = [];
+    if ($workspace->id() === $active_workspace->id()) {
+      $options['attributes']['class'][] = 'is-active';
+    }
+
+    // Get the URL of the workspace activation form and display it in a modal.
+    $url = Url::fromRoute('entity.workspace.activate_form', ['workspace' => $workspace->id()], $options);
+    if ($url->access()) {
+      $links[$workspace->id()] = [
+        'type' => 'link',
+        'title' => $workspace->label(),
+        'url' => $url,
+        'attributes' => [
+          'class' => ['use-ajax'],
+          'data-dialog-type' => 'modal',
+          'data-dialog-options' => Json::encode([
+            'width' => 500,
+          ]),
+        ],
+      ];
+      $cache_tags = Cache::mergeTags($cache_tags, $workspace->getCacheTags());
+    }
+  }
+
+  if (!empty($links)) {
+    $links = [
+      '#theme' => 'links__toolbar_workspaces',
+      '#links' => $links,
+      '#attributes' => [
+        'class' => ['toolbar-menu'],
+      ],
+      '#cache' => [
+        'tags' => $cache_tags,
+      ],
+    ];
+  }
+
+  return $links;
+}
diff --git a/core/modules/workspace/workspace.permissions.yml b/core/modules/workspace/workspace.permissions.yml
new file mode 100644
index 0000000..569957b
--- /dev/null
+++ b/core/modules/workspace/workspace.permissions.yml
@@ -0,0 +1,34 @@
+administer workspaces:
+  title: Administer workspaces
+
+create workspace:
+  title: Create a new workspace
+
+view own workspace:
+  title: View own workspace
+
+view any workspace:
+  title: View any workspace
+
+edit own workspace:
+  title: Edit own workspace
+
+edit any workspace:
+  title: Edit any workspace
+
+delete own workspace:
+  title: Delete own workspace
+
+delete any workspace:
+  title: Delete any workspace
+
+bypass entity access own workspace:
+  title: Bypass content entity access in own workspace
+  description: Allow all Edit/Update/Delete permissions for all content entities in a workspace owned by the user.
+  restrict access: TRUE
+
+update any workspace from its target:
+  title: Update any workspace from its target
+
+permission_callbacks:
+  - Drupal\workspace\EntityAccess::workspacePermissions
diff --git a/core/modules/workspace/workspace.routing.yml b/core/modules/workspace/workspace.routing.yml
new file mode 100644
index 0000000..2226501
--- /dev/null
+++ b/core/modules/workspace/workspace.routing.yml
@@ -0,0 +1,27 @@
+entity.workspace.collection:
+  path: '/admin/config/workflow/workspace'
+  defaults:
+    _title: 'Workspaces'
+    _entity_list: 'workspace'
+  requirements:
+    _permission: 'administer workspaces+edit any workspace'
+
+entity.workspace.activate_form:
+  path: '/admin/config/workflow/workspace/{workspace}/activate'
+  defaults:
+    _entity_form: 'workspace.activate'
+    _title: 'Activate Workspace'
+  options:
+    _admin_route: TRUE
+  requirements:
+    _entity_access: 'workspace.view'
+
+entity.workspace.deploy_form:
+  path: '/admin/config/workflow/workspace/{workspace}/deploy'
+  defaults:
+    _entity_form: 'workspace.deploy'
+    _title: 'Deploy Workspace'
+  options:
+    _admin_route: TRUE
+  requirements:
+    _permission: 'administer workspaces'
diff --git a/core/modules/workspace/workspace.services.yml b/core/modules/workspace/workspace.services.yml
new file mode 100644
index 0000000..13d67ae
--- /dev/null
+++ b/core/modules/workspace/workspace.services.yml
@@ -0,0 +1,41 @@
+services:
+  workspace.manager:
+    class: Drupal\workspace\WorkspaceManager
+    arguments: ['@request_stack', '@entity_type.manager', '@current_user', '@state', '@logger.channel.workspace', '@class_resolver']
+    tags:
+      - { name: service_id_collector, tag: workspace_negotiator }
+  plugin.manager.workspace.repository_handler:
+    class: Drupal\workspace\RepositoryHandlerManager
+    parent: default_plugin_manager
+  workspace.negotiator.default:
+    class: Drupal\workspace\Negotiator\DefaultWorkspaceNegotiator
+    arguments: ['@entity_type.manager']
+    tags:
+      - { name: workspace_negotiator, priority: 0 }
+  workspace.negotiator.session:
+    class: Drupal\workspace\Negotiator\SessionWorkspaceNegotiator
+    arguments: ['@current_user', '@session', '@entity_type.manager']
+    tags:
+      - { name: workspace_negotiator, priority: 100 }
+  cache_context.workspace:
+    class: Drupal\workspace\WorkspaceCacheContext
+    arguments: ['@workspace.manager']
+    tags:
+      - { name: cache.context }
+  logger.channel.workspace:
+    parent: logger.channel_base
+    arguments: ['workspace']
+  workspace.entity.query.sql:
+    decorates: 'entity.query.sql'
+    class: Drupal\workspace\EntityQuery\QueryFactory
+    arguments: ['@database', '@workspace.manager']
+    public: false
+    decoration_priority: 50
+    tags:
+      - { name: backend_overridable }
+  pgsql.workspace.entity.query.sql:
+    decorates: 'pgsql.entity.query.sql'
+    class: Drupal\workspace\EntityQuery\PgsqlQueryFactory
+    arguments: ['@database', '@workspace.manager']
+    public: false
+    decoration_priority: 50
