diff --git a/core/MAINTAINERS.txt b/core/MAINTAINERS.txt
index c7f78ed90f1aea170fde6d981c80e9b074135ec8..8f04c06f6abc05e01a5c9345acd7eef8757c7906 100644
--- a/core/MAINTAINERS.txt
+++ b/core/MAINTAINERS.txt
@@ -307,6 +307,9 @@ Contact module
 - Jibran Ijaz 'jibran' https://www.drupal.org/u/jibran
 - Andrey Postnikov 'andypost' https://www.drupal.org/u/andypost
 
+Content Moderation module
+- Tim Millwood 'timmillwood' https://www.drupal.org/u/timmillwood
+
 Content Translation module
 - Francesco Placella 'plach' https://www.drupal.org/u/plach
 
diff --git a/core/composer.json b/core/composer.json
index 9ac5ab02d9ec35b3821aff832cecf74a25b07b1e..e38b665978c5fd4b8d121677e08c516f3026be2b 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -63,6 +63,7 @@
         "drupal/config": "self.version",
         "drupal/config_translation": "self.version",
         "drupal/contact": "self.version",
+        "drupal/content_moderation": "self.version",
         "drupal/content_translation": "self.version",
         "drupal/contextual": "self.version",
         "drupal/core-annotation": "self.version",
diff --git a/core/modules/content_moderation/config/install/content_moderation.state.archived.yml b/core/modules/content_moderation/config/install/content_moderation.state.archived.yml
new file mode 100644
index 0000000000000000000000000000000000000000..02794819c69fdf7a2a44064fff1b11e687ac8acb
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state.archived.yml
@@ -0,0 +1,8 @@
+langcode: en
+status: true
+dependencies: {  }
+id: archived
+label: Archived
+published: false
+default_revision: true
+weight: -8
diff --git a/core/modules/content_moderation/config/install/content_moderation.state.draft.yml b/core/modules/content_moderation/config/install/content_moderation.state.draft.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c7eb64c6c6330445a0813f7e01380f6467e0f21a
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state.draft.yml
@@ -0,0 +1,8 @@
+langcode: en
+status: true
+dependencies: {  }
+id: draft
+label: Draft
+published: false
+default_revision: false
+weight: -10
diff --git a/core/modules/content_moderation/config/install/content_moderation.state.published.yml b/core/modules/content_moderation/config/install/content_moderation.state.published.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8467e86c0cd3c5235708d2a622513dfbf21f5485
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state.published.yml
@@ -0,0 +1,8 @@
+langcode: en
+status: true
+dependencies: {  }
+id: published
+label: Published
+published: true
+default_revision: true
+weight: -9
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8fbf9c3dfc25883c28fcbe0110739b4cfb3c158b
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.state.archived
+    - content_moderation.state.draft
+id: archived_draft
+label: 'Un-archive to Draft'
+stateFrom: archived
+stateTo: draft
+weight: -5
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4be7600aaaa4003617e614593a32a4a7f346a993
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.state.archived
+    - content_moderation.state.published
+id: archived_published
+label: 'Un-archive'
+stateFrom: archived
+stateTo: published
+weight: -4
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0ba0f34706d032675994d4ef6da9f156d5285552
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.state.draft
+id: draft_draft
+label: 'Create New Draft'
+stateFrom: draft
+stateTo: draft
+weight: -10
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cf95d3daba977b4f6a53acd460c0a716706ff527
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.state.draft
+    - content_moderation.state.published
+id: draft_published
+label: 'Publish'
+stateFrom: draft
+stateTo: published
+weight: -9
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f3a866a6afccea36b27eb7acfd44f5b09532ae02
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.state.archived
+    - content_moderation.state.published
+id: published_archived
+label: 'Archive'
+stateFrom: published
+stateTo: archived
+weight: -6
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bd25a31ef3cc05c7704344b7f0df99c3aee78855
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.state.draft
+    - content_moderation.state.published
+id: published_draft
+label: 'Create New Draft'
+stateFrom: published
+stateTo: draft
+weight: -8
diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3c09a85a0a109420f42883d61bac0bc09be1c33e
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.state.published
+id: published_published
+label: 'Publish'
+stateFrom: published
+stateTo: published
+weight: -7
diff --git a/core/modules/content_moderation/config/schema/content_moderation.schema.yml b/core/modules/content_moderation/config/schema/content_moderation.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7f9e8fdeff5cda3c2e5fd119afe23fca410b6048
--- /dev/null
+++ b/core/modules/content_moderation/config/schema/content_moderation.schema.yml
@@ -0,0 +1,79 @@
+content_moderation.state.*:
+  type: config_entity
+  label: 'Moderation state config'
+  mapping:
+    id:
+      type: string
+      label: 'ID'
+    label:
+      type: label
+      label: 'Label'
+    published:
+      type: boolean
+      label: 'Is published'
+    default_revision:
+      type: boolean
+      label: 'Is default revision'
+    weight:
+      type: integer
+      label: 'Weight'
+
+content_moderation.state_transition.*:
+  type: config_entity
+  label: 'Moderation state transition config'
+  mapping:
+    id:
+      type: string
+      label: 'ID'
+    label:
+      type: label
+      label: 'Label'
+    stateFrom:
+      type: string
+      label: 'From state'
+    stateTo:
+      type: string
+      label: 'To state'
+    weight:
+      type: integer
+      label: 'Weight'
+
+node.type.*.third_party.content_moderation:
+  type: mapping
+  label: 'Enable moderation states for this node type'
+  mapping:
+    enabled:
+      type: boolean
+      label: 'Moderation states enabled'
+    allowed_moderation_states:
+      type: sequence
+      sequence:
+        type: string
+        label: 'Moderation state'
+    default_moderation_state:
+      type: string
+      label: 'Moderation state for new content'
+
+block_content.type.*.third_party.content_moderation:
+  type: mapping
+  label: 'Enable moderation states for this block content type'
+  mapping:
+    enabled:
+      type: boolean
+      label: 'Moderation states enabled'
+    allowed_moderation_states:
+      type: sequence
+      sequence:
+        type: string
+        label: 'Moderation state'
+    default_moderation_state:
+      type: string
+      label: 'Moderation state for new block content'
+
+views.filter.latest_revision:
+  type: views_filter
+  label: 'Latest revision'
+  mapping:
+    value:
+      type: string
+      label: 'Value'
diff --git a/core/modules/content_moderation/content_moderation.info.yml b/core/modules/content_moderation/content_moderation.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6d92b64d41dd2284080b6701032b9fcfce530f82
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.info.yml
@@ -0,0 +1,7 @@
+name: 'Content Moderation'
+type: module
+description: 'Provides moderation states for content'
+version: VERSION
+core: 8.x
+package: Core (Experimental)
+configure: content_moderation.overview
diff --git a/core/modules/content_moderation/content_moderation.libraries.yml b/core/modules/content_moderation/content_moderation.libraries.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6caaaf3d5ba50b1ac8a0f2ac72258ac62d60bcbc
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.libraries.yml
@@ -0,0 +1,5 @@
+entity-moderation-form:
+  version: VERSION
+  css:
+    layout:
+      css/entity-moderation-form.css: {}
diff --git a/core/modules/content_moderation/content_moderation.links.action.yml b/core/modules/content_moderation/content_moderation.links.action.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9de50610552031c17d8be4c21959e97dfaf81642
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.links.action.yml
@@ -0,0 +1,11 @@
+entity.moderation_state.add_form:
+  route_name: 'entity.moderation_state.add_form'
+  title: 'Add Moderation state'
+  appears_on:
+    - entity.moderation_state.collection
+
+entity.moderation_state_transition.add_form:
+  route_name: 'entity.moderation_state_transition.add_form'
+  title: 'Add Moderation state transition'
+  appears_on:
+    - entity.moderation_state_transition.collection
diff --git a/core/modules/content_moderation/content_moderation.links.menu.yml b/core/modules/content_moderation/content_moderation.links.menu.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0fcb3ebd4a3841470228c50a06e655625e051b9a
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.links.menu.yml
@@ -0,0 +1,21 @@
+# Moderation state menu items definition
+content_moderation.overview:
+  title: 'Content moderation'
+  route_name: content_moderation.overview
+  description: 'Configure states and transitions for entities.'
+  parent: system.admin_config_workflow
+
+entity.moderation_state.collection:
+  title: 'Moderation states'
+  route_name: entity.moderation_state.collection
+  description: 'Administer moderation states.'
+  parent: content_moderation.overview
+  weight: 10
+
+# Moderation state transition menu items definition
+entity.moderation_state_transition.collection:
+  title: 'Moderation state transitions'
+  route_name: entity.moderation_state_transition.collection
+  description: 'Administer moderation states transitions.'
+  parent: content_moderation.overview
+  weight: 20
diff --git a/core/modules/content_moderation/content_moderation.links.task.yml b/core/modules/content_moderation/content_moderation.links.task.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d715219a05f80fdc0eb3975bf2bbeabcf99bd76b
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.links.task.yml
@@ -0,0 +1,3 @@
+moderation_state.entities:
+  deriver: 'Drupal\content_moderation\Plugin\Derivative\DynamicLocalTasks'
+  weight: 100
diff --git a/core/modules/content_moderation/content_moderation.module b/core/modules/content_moderation/content_moderation.module
new file mode 100644
index 0000000000000000000000000000000000000000..07a4bb513e39b3a54485dbc6c9b150d5348e8346
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.module
@@ -0,0 +1,221 @@
+<?php
+
+/**
+ * @file
+ * Contains content_moderation.module.
+ */
+
+use Drupal\content_moderation\EntityOperations;
+use Drupal\content_moderation\EntityTypeInfo;
+use Drupal\content_moderation\ContentPreprocess;
+use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublishNode;
+use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublishNode;
+use Drupal\content_moderation\Plugin\Menu\EditTab;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\node\NodeInterface;
+use Drupal\node\Plugin\Action\PublishNode;
+use Drupal\node\Plugin\Action\UnpublishNode;
+
+/**
+ * Implements hook_help().
+ */
+function content_moderation_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    // Main module help for the content_moderation module.
+    case 'help.page.content_moderation':
+      $output = '';
+      $output .= '<h3>' . t('About') . '</h3>';
+      $output .= '<p>' . t('The Content Moderation module provides basic moderation for content. For more information, see the <a href=":content_moderation">online documentation for the Content Moderation module</a>.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation']) . '</p>';
+      return $output;
+  }
+}
+
+/**
+ * Creates an EntityTypeInfo object to respond to entity hooks.
+ *
+ * @return \Drupal\content_moderation\EntityTypeInfo
+ */
+function _content_moderation_create_entity_type_info() {
+  return new EntityTypeInfo(
+    \Drupal::service('string_translation'),
+    \Drupal::service('content_moderation.moderation_information'),
+    \Drupal::service('entity_type.manager')
+  );
+}
+
+/**
+ * Implements hook_entity_base_field_info().
+ */
+function content_moderation_entity_base_field_info(EntityTypeInterface $entity_type) {
+  return _content_moderation_create_entity_type_info()->entityBaseFieldInfo($entity_type);
+}
+
+/**
+ * Implements hook_entity_type_alter().
+ */
+function content_moderation_entity_type_alter(array &$entity_types) {
+  _content_moderation_create_entity_type_info()->entityTypeAlter($entity_types);
+}
+
+/**
+ * Implements hook_entity_operation().
+ */
+function content_moderation_entity_operation(EntityInterface $entity) {
+  _content_moderation_create_entity_type_info()->entityOperation($entity);
+}
+
+/**
+ * Sets required flag based on enabled state.
+ */
+function content_moderation_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) {
+  _content_moderation_create_entity_type_info()->entityBundleFieldInfoAlter($fields, $entity_type, $bundle);
+}
+
+/**
+ * Creates an EntityOperations object to respond to entity operation hooks.
+ *
+ * @return \Drupal\content_moderation\EntityOperations
+ */
+function _content_moderation_create_entity_operations() {
+  return new EntityOperations(
+    \Drupal::service('content_moderation.moderation_information'),
+    \Drupal::service('entity_type.manager'),
+    \Drupal::service('form_builder'),
+    \Drupal::service('content_moderation.revision_tracker')
+  );
+}
+
+/**
+ * Implements hook_entity_presave().
+ */
+function content_moderation_entity_presave(EntityInterface $entity) {
+  return _content_moderation_create_entity_operations()->entityPresave($entity);
+}
+
+/**
+ * Implements hook_entity_insert().
+ */
+function content_moderation_entity_insert(EntityInterface $entity) {
+  return _content_moderation_create_entity_operations()->entityInsert($entity);
+}
+
+/**
+ * Implements hook_entity_update().
+ */
+function content_moderation_entity_update(EntityInterface $entity) {
+  return _content_moderation_create_entity_operations()->entityUpdate($entity);
+}
+
+/**
+ * Implements hook_local_tasks_alter().
+ */
+function content_moderation_local_tasks_alter(&$local_tasks) {
+  $content_entity_type_ids = array_keys(array_filter(\Drupal::entityTypeManager()->getDefinitions(), function (EntityTypeInterface $entity_type) {
+    return $entity_type->isRevisionable();
+  }));
+
+  foreach ($content_entity_type_ids as $content_entity_type_id) {
+    if (isset($local_tasks["entity.$content_entity_type_id.edit_form"])) {
+      $local_tasks["entity.$content_entity_type_id.edit_form"]['class'] = EditTab::class;
+      $local_tasks["entity.$content_entity_type_id.edit_form"]['entity_type_id'] = $content_entity_type_id;
+    }
+  }
+}
+
+/**
+ * Implements hook_form_alter().
+ */
+function content_moderation_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+  _content_moderation_create_entity_type_info()->bundleFormAlter($form, $form_state, $form_id);
+}
+
+/**
+ * Implements hook_preprocess_HOOK().
+ *
+ * Many default node templates rely on $page to determine whether to output the
+ * node title as part of the node content.
+ */
+function content_moderation_preprocess_node(&$variables) {
+  $content_process = new ContentPreprocess(\Drupal::routeMatch());
+  $content_process->preprocessNode($variables);
+}
+
+/**
+ * Implements hook_entity_extra_field_info().
+ */
+function content_moderation_entity_extra_field_info() {
+  return _content_moderation_create_entity_type_info()->entityExtraFieldInfo();
+}
+
+/**
+ * Implements hook_entity_view().
+ */
+function content_moderation_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
+  _content_moderation_create_entity_operations()->entityView($build, $entity, $display, $view_mode);
+}
+
+/**
+ * Implements hook_node_access().
+ *
+ * Nodes in particular should be viewable if unpublished and the user has
+ * the appropriate permission. This permission is therefore effectively
+ * mandatory for any user that wants to moderate things.
+ */
+function content_moderation_node_access(NodeInterface $node, $operation, AccountInterface $account) {
+  /** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
+  $moderation_info = Drupal::service('content_moderation.moderation_information');
+
+  $access_result = NULL;
+  if ($operation === 'view') {
+    $access_result = (!$node->isPublished())
+      ? AccessResult::allowedIfHasPermission($account, 'view any unpublished content')
+      : AccessResult::neutral();
+
+    $access_result->addCacheableDependency($node);
+  }
+  elseif ($operation === 'update' && $moderation_info->isModeratableEntity($node) && $node->moderation_state && $node->moderation_state->target_id) {
+    /** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */
+    $transition_validation = \Drupal::service('content_moderation.state_transition_validation');
+
+    $valid_transition_targets = $transition_validation->getValidTransitionTargets($node, $account);
+    $access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden();
+
+    $access_result->addCacheableDependency($node);
+    $access_result->addCacheableDependency($account);
+    foreach ($valid_transition_targets as $valid_transition_target) {
+      $access_result->addCacheableDependency($valid_transition_target);
+    }
+  }
+
+  return $access_result;
+}
+
+/**
+ * Implements hook_theme().
+ */
+function content_moderation_theme() {
+  return ['entity_moderation_form' => ['render element' => 'form']];
+}
+
+/**
+ * Implements hook_action_info_alter().
+ */
+function content_moderation_action_info_alter(&$definitions) {
+
+  // The publish/unpublish actions are not valid on moderated entities. So swap
+  // their implementations out for alternates that will become a no-op on a
+  // moderated node. If another module has already swapped out those classes,
+  // though, we'll be polite and do nothing.
+  if (isset($definitions['node_publish_action']['class']) && $definitions['node_publish_action']['class'] == PublishNode::class) {
+    $definitions['node_publish_action']['class'] = ModerationOptOutPublishNode::class;
+  }
+  if (isset($definitions['node_unpublish_action']['class']) && $definitions['node_unpublish_action']['class'] == UnpublishNode::class) {
+    $definitions['node_unpublish_action']['class'] = ModerationOptOutUnpublishNode::class;
+  }
+}
diff --git a/core/modules/content_moderation/content_moderation.permissions.yml b/core/modules/content_moderation/content_moderation.permissions.yml
new file mode 100644
index 0000000000000000000000000000000000000000..293a77d3de0ade58490f03c05a8e931905e2e484
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.permissions.yml
@@ -0,0 +1,24 @@
+view any unpublished content:
+  title: 'View any unpublished content'
+  description: 'This permission is necessary for any users that may moderate content.'
+
+'view moderation states':
+  title: 'View moderation states'
+  description: 'View moderation states.'
+
+'administer moderation states':
+  title: 'Administer moderation states'
+  description: 'Create and edit moderation states.'
+  'restrict access': TRUE
+
+'administer moderation state transitions':
+  title: 'Administer content moderation state transitions'
+  description: 'Create and edit content moderation state transitions.'
+  'restrict access': TRUE
+
+view latest version:
+  title: 'View the latest version'
+  description: 'View the latest version of an entity. (Also requires "View any unpublished content" permission)'
+
+permission_callbacks:
+  - \Drupal\content_moderation\Permissions::transitionPermissions
diff --git a/core/modules/content_moderation/content_moderation.routing.yml b/core/modules/content_moderation/content_moderation.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cc7a5ff5d1e757592bf75f945a44ef9c10a365b6
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.routing.yml
@@ -0,0 +1,73 @@
+content_moderation.overview:
+  path: '/admin/config/workflow/moderation'
+  defaults:
+    _controller: '\Drupal\system\Controller\SystemController::systemAdminMenuBlockPage'
+    _title: 'Content moderation'
+  requirements:
+    _permission: 'access administration pages'
+
+# ModerationState routing definition
+entity.moderation_state.collection:
+  path: '/admin/config/workflow/moderation/states'
+  defaults:
+    _entity_list: 'moderation_state'
+    _title: 'Moderation states'
+  requirements:
+    _permission: 'administer moderation states'
+
+entity.moderation_state.add_form:
+  path: '/admin/config/workflow/moderation/states/add'
+  defaults:
+    _entity_form: 'moderation_state.add'
+    _title: 'Add Moderation state'
+  requirements:
+    _permission: 'administer moderation states'
+
+entity.moderation_state.edit_form:
+  path: '/admin/config/workflow/moderation/states/{moderation_state}'
+  defaults:
+    _entity_form: 'moderation_state.edit'
+    _title: 'Edit Moderation state'
+  requirements:
+    _permission: 'administer moderation states'
+
+entity.moderation_state.delete_form:
+  path: '/admin/config/workflow/moderation/states/{moderation_state}/delete'
+  defaults:
+    _entity_form: 'moderation_state.delete'
+    _title: 'Delete Moderation state'
+  requirements:
+    _permission: 'administer moderation states'
+
+# ModerationStateTransition routing definition
+entity.moderation_state_transition.collection:
+  path: '/admin/config/workflow/moderation/transitions'
+  defaults:
+    _entity_list: 'moderation_state_transition'
+    _title: 'Moderation state transitions'
+  requirements:
+    _permission: 'administer moderation state transitions'
+
+entity.moderation_state_transition.add_form:
+  path: '/admin/config/workflow/moderation/transitions/add'
+  defaults:
+    _entity_form: 'moderation_state_transition.add'
+    _title: 'Add Moderation state transition'
+  requirements:
+    _permission: 'administer moderation state transitions'
+
+entity.moderation_state_transition.edit_form:
+  path: '/admin/config/workflow/moderation/transitions/{moderation_state_transition}'
+  defaults:
+    _entity_form: 'moderation_state_transition.edit'
+    _title: 'Edit Moderation state transition'
+  requirements:
+    _permission: 'administer moderation state transitions'
+
+entity.moderation_state_transition.delete_form:
+  path: '/admin/config/workflow/moderation/transitions/{moderation_state_transition}/delete'
+  defaults:
+    _entity_form: 'moderation_state_transition.delete'
+    _title: 'Delete Moderation state transition'
+  requirements:
+    _permission: 'administer moderation state transitions'
diff --git a/core/modules/content_moderation/content_moderation.services.yml b/core/modules/content_moderation/content_moderation.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..02008ead71444a2bc1f9ed3f83f80a372542cd9b
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.services.yml
@@ -0,0 +1,22 @@
+services:
+  paramconverter.latest_revision:
+    class: Drupal\content_moderation\ParamConverter\EntityRevisionConverter
+    arguments: ['@entity.manager', '@content_moderation.moderation_information']
+    tags:
+      - { name: paramconverter, priority: 5 }
+  content_moderation.state_transition_validation:
+    class: \Drupal\content_moderation\StateTransitionValidation
+    arguments: ['@entity_type.manager', '@entity.query']
+  content_moderation.moderation_information:
+    class: Drupal\content_moderation\ModerationInformation
+    arguments: ['@entity_type.manager', '@current_user']
+  access_check.latest_revision:
+    class: Drupal\content_moderation\Access\LatestRevisionCheck
+    arguments: ['@content_moderation.moderation_information']
+    tags:
+      - { name: access_check, applies_to: _content_moderation_latest_version }
+  content_moderation.revision_tracker:
+    class: Drupal\content_moderation\RevisionTracker
+    arguments: ['@database']
+    tags:
+     - { name: backend_overridable }
diff --git a/core/modules/content_moderation/content_moderation.views.inc b/core/modules/content_moderation/content_moderation.views.inc
new file mode 100644
index 0000000000000000000000000000000000000000..faabc6aaeca9f0a7bbb5c77bb1f20cc1ee434129
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.views.inc
@@ -0,0 +1,37 @@
+<?php
+
+/**
+ * @file
+ * Provide views data for content_moderation.module.
+ *
+ * @ingroup views_module_handlers
+ */
+
+use Drupal\content_moderation\ViewsData;
+
+/**
+ * Implements hook_views_data().
+ */
+function content_moderation_views_data() {
+  return _content_moderation_views_data_object()->getViewsData();
+}
+
+/**
+ * Implements hook_views_data_alter().
+ */
+function content_moderation_views_data_alter(array &$data) {
+  _content_moderation_views_data_object()->alterViewsData($data);
+}
+
+/**
+ * Creates a ViewsData object to respond to views hooks.
+ *
+ * @return \Drupal\content_moderation\ViewsData
+ *   The content moderation ViewsData object.
+ */
+function _content_moderation_views_data_object() {
+  return new ViewsData(
+    \Drupal::service('entity_type.manager'),
+    \Drupal::service('content_moderation.moderation_information')
+  );
+}
diff --git a/core/modules/content_moderation/css/entity-moderation-form.css b/core/modules/content_moderation/css/entity-moderation-form.css
new file mode 100644
index 0000000000000000000000000000000000000000..ec094074edd1a4166aa118c8a576febaf5e0a196
--- /dev/null
+++ b/core/modules/content_moderation/css/entity-moderation-form.css
@@ -0,0 +1,16 @@
+ul.entity-moderation-form {
+  list-style: none;
+  display: -webkit-flex; /* Safari */
+  display: flex;
+  -webkit-flex-wrap: wrap; /* Safari */
+  flex-wrap:         wrap;
+  -webkit-justify-content: space-around; /* Safari */
+  justify-content:         space-around;
+  -webkit-align-items: flex-end; /* Safari */
+  align-items:         flex-end;
+  border-bottom: 1px solid gray;
+}
+
+ul.entity-moderation-form input[type=submit] {
+  margin-bottom: 1.2em;
+}
diff --git a/core/modules/content_moderation/src/Access/LatestRevisionCheck.php b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php
new file mode 100644
index 0000000000000000000000000000000000000000..528d1950a578da05574b8b265465f6f7343bcb11
--- /dev/null
+++ b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\content_moderation\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Routing\Access\AccessInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Access check for the entity moderation tab.
+ */
+class LatestRevisionCheck implements AccessInterface {
+
+  /**
+   * The moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * Constructs a new LatestRevisionCheck.
+   *
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+   *   The moderation information service.
+   */
+  public function __construct(ModerationInformationInterface $moderation_information) {
+    $this->moderationInfo = $moderation_information;
+  }
+
+  /**
+   * Checks that there is a forward revision available.
+   *
+   * This checker assumes the presence of an '_entity_access' requirement key
+   * in the same form as used by EntityAccessCheck.
+   *
+   * @param \Symfony\Component\Routing\Route $route
+   *   The route to check against.
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The parametrized route.
+   *
+   * @return \Drupal\Core\Access\AccessResultInterface
+   *   The access result.
+   *
+   * @see \Drupal\Core\Entity\EntityAccessCheck
+   */
+  public function access(Route $route, RouteMatchInterface $route_match) {
+    // This tab should not show up unless there's a reason to show it.
+    $entity = $this->loadEntity($route, $route_match);
+    return $this->moderationInfo->hasForwardRevision($entity)
+      ? AccessResult::allowed()->addCacheableDependency($entity)
+      : AccessResult::forbidden()->addCacheableDependency($entity);
+  }
+
+  /**
+   * Returns the default revision of the entity this route is for.
+   *
+   * @param \Symfony\Component\Routing\Route $route
+   *   The route to check against.
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   The parametrized route.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface
+   *   returns the Entity in question.
+   *
+   * @throws \Exception
+   *   A generic exception is thrown if the entity couldn't be loaded. This
+   *   almost always implies a developer error, so it should get turned into
+   *   an HTTP 500.
+   */
+  protected function loadEntity(Route $route, RouteMatchInterface $route_match) {
+    $entity_type = $route->getOption('_content_moderation_entity_type');
+
+    if ($entity = $route_match->getParameter($entity_type)) {
+      if ($entity instanceof EntityInterface) {
+        return $entity;
+      }
+    }
+    throw new \Exception(sprintf('%s is not a valid entity route. The LatestRevisionCheck access checker may only be used with a route that has a single entity parameter.', $route_match->getRouteName()));
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ContentModerationStateInterface.php b/core/modules/content_moderation/src/ContentModerationStateInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..5b7ee2ed2e2cb997da3b70b94ced6a47d4f08d4b
--- /dev/null
+++ b/core/modules/content_moderation/src/ContentModerationStateInterface.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\user\EntityOwnerInterface;
+
+/**
+ * An interface for Content moderation state entity.
+ *
+ * Content moderation state entities track the moderation state of other content
+ * entities.
+ */
+interface ContentModerationStateInterface extends ContentEntityInterface, EntityOwnerInterface {
+
+}
diff --git a/core/modules/content_moderation/src/ContentModerationStateStorageSchema.php b/core/modules/content_moderation/src/ContentModerationStateStorageSchema.php
new file mode 100644
index 0000000000000000000000000000000000000000..19ec324c1a92833b8dc914ab0d7bc2fee5a997b5
--- /dev/null
+++ b/core/modules/content_moderation/src/ContentModerationStateStorageSchema.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+use Drupal\Core\Entity\Sql\SqlContentEntityStorageSchema;
+
+/**
+ * Defines the content moderation state schema handler.
+ */
+class ContentModerationStateStorageSchema extends SqlContentEntityStorageSchema {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getEntitySchema(ContentEntityTypeInterface $entity_type, $reset = FALSE) {
+    $schema = parent::getEntitySchema($entity_type, $reset);
+
+    // Creates an index to ensure that the lookup in
+    // \Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList::getModerationState()
+    // is performant.
+    $schema['content_moderation_state_field_data']['indexes'] += array(
+      'content_moderation_state__lookup' => array('content_entity_type_id', 'content_entity_id', 'content_entity_revision_id'),
+    );
+
+    return $schema;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ContentPreprocess.php b/core/modules/content_moderation/src/ContentPreprocess.php
new file mode 100644
index 0000000000000000000000000000000000000000..b3b73375eab7f0bb8f871190752d2620ab41428f
--- /dev/null
+++ b/core/modules/content_moderation/src/ContentPreprocess.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\node\Entity\Node;
+
+/**
+ * Service to determine whether a route is the "Latest version" tab of a node.
+ */
+class ContentPreprocess {
+
+  /**
+   * The route match service.
+   *
+   * @var \Drupal\Core\Routing\RouteMatchInterface $routeMatch
+   */
+  protected $routeMatch;
+
+  /**
+   * Constructor.
+   *
+   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
+   *   Current route match service.
+   */
+  public function __construct(RouteMatchInterface $route_match) {
+    $this->routeMatch = $route_match;
+  }
+
+  /**
+   * Wrapper for hook_preprocess_HOOK().
+   *
+   * @param array $variables
+   *   Theme variables to preprocess.
+   */
+  public function preprocessNode(array &$variables) {
+    // Set the 'page' template variable when the node is being displayed on the
+    // "Latest version" tab provided by content_moderation.
+    $variables['page'] = $variables['page'] || $this->isLatestVersionPage($variables['node']);
+  }
+
+  /**
+   * Checks whether a route is the "Latest version" tab of a node.
+   *
+   * @param \Drupal\node\Entity\Node $node
+   *   A node.
+   *
+   * @return bool
+   *   True if the current route is the latest version tab of the given node.
+   */
+  public function isLatestVersionPage(Node $node) {
+    return $this->routeMatch->getRouteName() == 'entity.node.latest_version'
+           && ($pageNode = $this->routeMatch->getParameter('node'))
+           && $pageNode->id() == $node->id();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/ContentModerationState.php b/core/modules/content_moderation/src/Entity/ContentModerationState.php
new file mode 100644
index 0000000000000000000000000000000000000000..1ff7f2a882e9d555bf43be0977cb6e1a535f63cf
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/ContentModerationState.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace Drupal\content_moderation\Entity;
+
+use Drupal\content_moderation\ContentModerationStateInterface;
+use Drupal\Core\Entity\ContentEntityBase;
+use Drupal\Core\Entity\EntityChangedTrait;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\TypedData\TranslatableInterface;
+use Drupal\user\UserInterface;
+
+/**
+ * Defines the Content moderation state entity.
+ *
+ * @ContentEntityType(
+ *   id = "content_moderation_state",
+ *   label = @Translation("Content moderation state"),
+ *   label_singular = @Translation("content moderation state"),
+ *   label_plural = @Translation("content moderation states"),
+ *   label_count = @PluralTranslation(
+ *     singular = "@count content moderation state",
+ *     plural = "@count content moderation states"
+ *   ),
+ *   handlers = {
+ *     "storage_schema" = "Drupal\content_moderation\ContentModerationStateStorageSchema",
+ *     "views_data" = "\Drupal\views\EntityViewsData",
+ *   },
+ *   base_table = "content_moderation_state",
+ *   revision_table = "content_moderation_state_revision",
+ *   data_table = "content_moderation_state_field_data",
+ *   revision_data_table = "content_moderation_state_field_revision",
+ *   translatable = TRUE,
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "revision" = "revision_id",
+ *     "uuid" = "uuid",
+ *     "uid" = "uid",
+ *     "langcode" = "langcode",
+ *   }
+ * )
+ */
+class ContentModerationState extends ContentEntityBase implements ContentModerationStateInterface {
+
+  use EntityChangedTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields = parent::baseFieldDefinitions($entity_type);
+
+    $fields['uid'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(t('User'))
+      ->setDescription(t('The username of the entity creator.'))
+      ->setSetting('target_type', 'user')
+      ->setDefaultValueCallback('Drupal\content_moderation\Entity\ContentModerationState::getCurrentUserId')
+      ->setTranslatable(TRUE)
+      ->setRevisionable(TRUE);
+
+    $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(t('Moderation state'))
+      ->setDescription(t('The moderation state of the referenced content.'))
+      ->setSetting('target_type', 'moderation_state')
+      ->setRequired(TRUE)
+      ->setTranslatable(TRUE)
+      ->setRevisionable(TRUE)
+      ->addConstraint('ModerationState', []);
+
+    $fields['content_entity_type_id'] = BaseFieldDefinition::create('string')
+      ->setLabel(t('Content entity type ID'))
+      ->setDescription(t('The ID of the content entity type this moderation state is for.'))
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    $fields['content_entity_id'] = BaseFieldDefinition::create('integer')
+      ->setLabel(t('Content entity ID'))
+      ->setDescription(t('The ID of the content entity this moderation state is for.'))
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    // @todo https://www.drupal.org/node/2779931 Add constraint that enforces
+    //   unique content_entity_type_id, content_entity_id and
+    //   content_entity_revision_id.
+
+    $fields['content_entity_revision_id'] = BaseFieldDefinition::create('integer')
+      ->setLabel(t('Content entity revision ID'))
+      ->setDescription(t('The revision ID of the content entity this moderation state is for.'))
+      ->setRequired(TRUE)
+      ->setRevisionable(TRUE);
+
+    return $fields;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOwner() {
+    return $this->get('uid')->entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getOwnerId() {
+    return $this->getEntityKey('uid');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOwnerId($uid) {
+    $this->set('uid', $uid);
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setOwner(UserInterface $account) {
+    $this->set('uid', $account->id());
+    return $this;
+  }
+
+  /**
+   * Creates or updates an entity's moderation state whilst saving that entity.
+   *
+   * @param \Drupal\content_moderation\Entity\ContentModerationState $content_moderation_state
+   *   The content moderation entity content entity to create or save.
+   *
+   * @internal
+   *   This method should only be called as a result of saving the related
+   *   content entity.
+   */
+  public static function updateOrCreateFromEntity(ContentModerationState $content_moderation_state) {
+    $content_moderation_state->realSave();
+  }
+
+  /**
+   * Default value callback for the 'uid' base field definition.
+   *
+   * @see \Drupal\content_moderation\Entity\ContentModerationState::baseFieldDefinitions()
+   *
+   * @return array
+   *   An array of default values.
+   */
+  public static function getCurrentUserId() {
+    return array(\Drupal::currentUser()->id());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save() {
+    $related_entity = \Drupal::entityTypeManager()
+      ->getStorage($this->content_entity_type_id->value)
+      ->loadRevision($this->content_entity_revision_id->value);
+    if ($related_entity instanceof TranslatableInterface) {
+      $related_entity = $related_entity->getTranslation($this->activeLangcode);
+    }
+    $related_entity->moderation_state->target_id = $this->moderation_state->target_id;
+    return $related_entity->save();
+  }
+
+  /**
+   * Saves an entity permanently.
+   *
+   * When saving existing entities, the entity is assumed to be complete,
+   * partial updates of entities are not supported.
+   *
+   * @return int
+   *   Either SAVED_NEW or SAVED_UPDATED, depending on the operation performed.
+   *
+   * @throws \Drupal\Core\Entity\EntityStorageException
+   *   In case of failures an exception is thrown.
+   */
+  protected function realSave() {
+    return parent::save();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..b88b415182ec473042cb444de6149725797163fb
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Customizations for block content entities.
+ */
+class BlockContentModerationHandler extends ModerationHandler {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    $form['revision_information']['revision']['#default_value'] = TRUE;
+    $form['revision_information']['revision']['#disabled'] = TRUE;
+    $form['revision_information']['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    $form['revision']['#default_value'] = 1;
+    $form['revision']['#disabled'] = TRUE;
+    $form['revision']['#description'] = $this->t('Revisions must be required when moderation is enabled.');
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..9d89253ce5eed624f1cac3e85ff2f98fb021e364
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityHandlerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Common customizations for most/all entities.
+ *
+ * This class is intended primarily as a base class.
+ */
+class ModerationHandler implements ModerationHandlerInterface, EntityHandlerInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
+    // This is probably not necessary if configuration is setup correctly.
+    $entity->setNewRevision(TRUE);
+    $entity->isDefaultRevision($default_revision);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onBundleModerationConfigurationFormSubmit(ConfigEntityInterface $bundle) {
+    // The Revisions portion of Entity API is not uniformly applied or
+    // consistent. Until that's fixed, we'll make a best-attempt to apply it to
+    // the common entity patterns so as to avoid every entity type needing to
+    // implement this method, although some will still need to do so for now.
+    // This is the API that should be universal, but isn't yet.
+    // @see \Drupal\node\Entity\NodeType
+    if (method_exists($bundle, 'setNewRevision')) {
+      $bundle->setNewRevision(TRUE);
+    }
+    // This is the raw property used by NodeType, and likely others.
+    elseif ($bundle->get('new_revision') !== NULL) {
+      $bundle->set('new_revision', TRUE);
+    }
+    // This is the raw property used by BlockContentType, and maybe others.
+    elseif ($bundle->get('revision') !== NULL) {
+      $bundle->set('revision', TRUE);
+    }
+
+    $bundle->save();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..e897cf4a76f77cc9bf67f407de7a38f9c6a1fc65
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Defines operations that need to vary by entity type.
+ *
+ * Much of the logic contained in this handler is an indication of flaws
+ * in the Entity API that are insufficiently standardized between entity types.
+ * Hopefully over time functionality can be removed from this interface.
+ */
+interface ModerationHandlerInterface {
+
+  /**
+   * Operates on moderatable content entities preSave().
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity to modify.
+   * @param bool $default_revision
+   *   Whether the new revision should be made the default revision.
+   * @param bool $published_state
+   *   Whether the state being transitioned to is a published state or not.
+   */
+  public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state);
+
+  /**
+   * Operates on the bundle definition that has been marked as moderatable.
+   *
+   * Note: The values on the EntityModerationForm itself are already saved
+   * so do not need to be saved here. If any changes are made to the bundle
+   * object here it is this method's responsibility to call save() on it.
+   *
+   * The most common use case is to force revisions on for this bundle if
+   * moderation is enabled. That, sadly, does not have a common API in core.
+   *
+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle
+   *   The bundle definition that is being saved.
+   */
+  public function onBundleModerationConfigurationFormSubmit(ConfigEntityInterface $bundle);
+
+  /**
+   * Alters entity forms to enforce revision handling.
+   *
+   * @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 enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id);
+
+  /**
+   * Alters bundle forms to enforce revision handling.
+   *
+   * @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 enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id);
+
+}
diff --git a/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..83de1874cf7bdfd83e38c622b3df45c2596e7f6d
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Customizations for node entities.
+ */
+class NodeModerationHandler extends ModerationHandler {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
+    if ($this->shouldModerate($entity, $published_state)) {
+      parent::onPresave($entity, $default_revision, $published_state);
+      // Only nodes have a concept of published.
+      /** @var \Drupal\node\NodeInterface $entity */
+      $entity->setPublished($published_state);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    $form['revision']['#disabled'] = TRUE;
+    $form['revision']['#default_value'] = TRUE;
+    $form['revision']['#description'] = $this->t('Revisions are required.');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    /* @var \Drupal\node\Entity\NodeType $entity */
+    $entity = $form_state->getFormObject()->getEntity();
+
+    if ($entity->getThirdPartySetting('content_moderation', 'enabled', FALSE)) {
+      // Force the revision checkbox on.
+      $form['workflow']['options']['#default_value']['revision'] = 'revision';
+      $form['workflow']['options']['revision']['#disabled'] = TRUE;
+    }
+  }
+
+  /**
+   * Check if an entity's default revision and/or state needs adjusting.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity to check.
+   * @param bool $published_state
+   *   Whether the state being transitioned to is a published state or not.
+   *
+   * @return bool
+   *   TRUE when either the default revision or the state needs to be updated.
+   */
+  protected function shouldModerate(ContentEntityInterface $entity, $published_state) {
+    // @todo clarify the first condition.
+    // First condition is needed so you can add a translation.
+    // Second condition checks to see if the published status has changed.
+    return $entity->isDefaultTranslation() || $entity->isPublished() !== $published_state;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/ModerationState.php b/core/modules/content_moderation/src/Entity/ModerationState.php
new file mode 100644
index 0000000000000000000000000000000000000000..0522e7d6c7917774d0bb94f1ebc858ddbb2f26aa
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/ModerationState.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\content_moderation\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\content_moderation\ModerationStateInterface;
+
+/**
+ * Defines the Moderation state entity.
+ *
+ * @ConfigEntityType(
+ *   id = "moderation_state",
+ *   label = @Translation("Moderation state"),
+ *   handlers = {
+ *     "access" = "Drupal\content_moderation\ModerationStateAccessControlHandler",
+ *     "list_builder" = "Drupal\content_moderation\ModerationStateListBuilder",
+ *     "form" = {
+ *       "add" = "Drupal\content_moderation\Form\ModerationStateForm",
+ *       "edit" = "Drupal\content_moderation\Form\ModerationStateForm",
+ *       "delete" = "Drupal\content_moderation\Form\ModerationStateDeleteForm"
+ *     },
+ *   },
+ *   config_prefix = "state",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "label" = "label",
+ *     "uuid" = "uuid",
+ *     "weight" = "weight",
+ *   },
+ *   links = {
+ *     "edit-form" = "/admin/config/workflow/moderation/states/{moderation_state}/edit",
+ *     "delete-form" = "/admin/config/workflow/moderation/states/{moderation_state}/delete",
+ *     "collection" = "/admin/config/workflow/moderation/states"
+ *   },
+ *   config_export = {
+ *     "id",
+ *     "label",
+ *     "published",
+ *     "default_revision",
+ *     "weight",
+ *   },
+ * )
+ */
+class ModerationState extends ConfigEntityBase implements ModerationStateInterface {
+
+  /**
+   * The Moderation state ID.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * The Moderation state label.
+   *
+   * @var string
+   */
+  protected $label;
+
+  /**
+   * Whether this state represents a published node.
+   *
+   * @var bool
+   */
+  protected $published;
+
+  /**
+   * Relative weight of this state.
+   *
+   * @var int
+   */
+  protected $weight;
+
+  /**
+   * Whether this state represents a default revision of the node.
+   *
+   * If this is a published state, then this property is ignored.
+   *
+   * @var bool
+   */
+  protected $default_revision;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isPublishedState() {
+    return $this->published;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isDefaultRevisionState() {
+    return $this->published || $this->default_revision;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Entity/ModerationStateTransition.php b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php
new file mode 100644
index 0000000000000000000000000000000000000000..99dbf93887886f4c0ae52a3230c29b04d3c6595a
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\content_moderation\Entity;
+
+use Drupal\Core\Config\Entity\ConfigEntityBase;
+use Drupal\content_moderation\ModerationStateTransitionInterface;
+
+/**
+ * Defines the Moderation state transition entity.
+ *
+ * @ConfigEntityType(
+ *   id = "moderation_state_transition",
+ *   label = @Translation("Moderation state transition"),
+ *   handlers = {
+ *     "list_builder" = "Drupal\content_moderation\ModerationStateTransitionListBuilder",
+ *     "form" = {
+ *       "add" = "Drupal\content_moderation\Form\ModerationStateTransitionForm",
+ *       "edit" = "Drupal\content_moderation\Form\ModerationStateTransitionForm",
+ *       "delete" = "Drupal\content_moderation\Form\ModerationStateTransitionDeleteForm"
+ *     },
+ *   },
+ *   config_prefix = "state_transition",
+ *   admin_permission = "administer moderation state transitions",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "label" = "label",
+ *     "uuid" = "uuid",
+ *     "weight" = "weight"
+ *   },
+ *   links = {
+ *     "edit-form" = "/admin/config/workflow/moderation/transitions/{moderation_state_transition}/edit",
+ *     "delete-form" = "/admin/config/workflow/moderation/transitions/{moderation_state_transition}/delete",
+ *     "collection" = "/admin/config/workflow/moderation/transitions"
+ *   }
+ * )
+ */
+class ModerationStateTransition extends ConfigEntityBase implements ModerationStateTransitionInterface {
+
+  /**
+   * The Moderation state transition ID.
+   *
+   * @var string
+   */
+  protected $id;
+
+  /**
+   * The Moderation state transition label.
+   *
+   * @var string
+   */
+  protected $label;
+
+  /**
+   * ID of from state.
+   *
+   * @var string
+   */
+  protected $stateFrom;
+
+  /**
+   * ID of to state.
+   *
+   * @var string
+   */
+  protected $stateTo;
+
+  /**
+   * Relative weight of this transition.
+   *
+   * @var int
+   */
+  protected $weight;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    parent::calculateDependencies();
+
+    if ($this->stateFrom) {
+      $this->addDependency('config', ModerationState::load($this->stateFrom)->getConfigDependencyName());
+    }
+    if ($this->stateTo) {
+      $this->addDependency('config', ModerationState::load($this->stateTo)->getConfigDependencyName());
+    }
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFromState() {
+    return $this->stateFrom;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getToState() {
+    return $this->stateTo;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getWeight() {
+    return $this->weight;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/EntityOperations.php b/core/modules/content_moderation/src/EntityOperations.php
new file mode 100644
index 0000000000000000000000000000000000000000..618fda83395b60a5cb93350eac5282183ea05cb4
--- /dev/null
+++ b/core/modules/content_moderation/src/EntityOperations.php
@@ -0,0 +1,279 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\content_moderation\Entity\ContentModerationState;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBuilderInterface;
+use Drupal\Core\TypedData\TranslatableInterface;
+use Drupal\content_moderation\Form\EntityModerationForm;
+
+/**
+ * Defines a class for reacting to entity events.
+ */
+class EntityOperations {
+
+  /**
+   * The Moderation Information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * The Entity Type Manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The Form Builder service.
+   *
+   * @var \Drupal\Core\Form\FormBuilderInterface
+   */
+  protected $formBuilder;
+
+  /**
+   * The Revision Tracker service.
+   *
+   * @var \Drupal\content_moderation\RevisionTrackerInterface
+   */
+  protected $tracker;
+
+  /**
+   * Constructs a new EntityOperations object.
+   *
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+   *   Moderation information service.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   Entity type manager service.
+   * @param \Drupal\Core\Form\FormBuilderInterface $form_builder
+   *   The form builder.
+   * @param \Drupal\content_moderation\RevisionTrackerInterface $tracker
+   *   The revision tracker.
+   */
+  public function __construct(ModerationInformationInterface $moderation_info, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, RevisionTrackerInterface $tracker) {
+    $this->moderationInfo = $moderation_info;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->formBuilder = $form_builder;
+    $this->tracker = $tracker;
+  }
+
+  /**
+   * Determines the default moderation state on load for an entity.
+   *
+   * This method is only applicable when an entity is loaded that has
+   * no moderation state on it, but should. In those cases, failing to set
+   * one may result in NULL references elsewhere when other code tries to check
+   * the moderation state of the entity.
+   *
+   * The amount of indirection here makes performance a concern, but
+   * given how Entity API works I don't know how else to do it.
+   * This reliably gets us *A* valid state. However, that state may be
+   * not the ideal one. Suggestions on how to better select the default
+   * state here are welcome.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity for which we want a default state.
+   *
+   * @return string
+   *   The default state for the given entity.
+   */
+  protected function getDefaultLoadStateId(ContentEntityInterface $entity) {
+    return $this->moderationInfo
+      ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle())
+      ->getThirdPartySetting('content_moderation', 'default_moderation_state');
+  }
+
+  /**
+   * Acts on an entity and set published status based on the moderation state.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity being saved.
+   */
+  public function entityPresave(EntityInterface $entity) {
+    if (!$this->moderationInfo->isModeratableEntity($entity)) {
+      return;
+    }
+    if ($entity->moderation_state->target_id) {
+      $moderation_state = $this->entityTypeManager
+        ->getStorage('moderation_state')
+        ->load($entity->moderation_state->target_id);
+      $published_state = $moderation_state->isPublishedState();
+
+      // This entity is default if it is new, the default revision, or the
+      // default revision is not published.
+      $update_default_revision = $entity->isNew()
+        || $moderation_state->isDefaultRevisionState()
+        || !$this->isDefaultRevisionPublished($entity);
+
+      // Fire per-entity-type logic for handling the save process.
+      $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->onPresave($entity, $update_default_revision, $published_state);
+    }
+  }
+
+  /**
+   * Hook bridge.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity that was just saved.
+   *
+   * @see hook_entity_insert()
+   */
+  public function entityInsert(EntityInterface $entity) {
+    if (!$this->moderationInfo->isModeratableEntity($entity)) {
+      return;
+    }
+    $this->updateOrCreateFromEntity($entity);
+    $this->setLatestRevision($entity);
+  }
+
+  /**
+   * Hook bridge.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity that was just saved.
+   *
+   * @see hook_entity_update()
+   */
+  public function entityUpdate(EntityInterface $entity) {
+    if (!$this->moderationInfo->isModeratableEntity($entity)) {
+      return;
+    }
+    $this->updateOrCreateFromEntity($entity);
+    $this->setLatestRevision($entity);
+  }
+
+  /**
+   * Creates or updates the moderation state of an entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to update or create a moderation state for.
+   */
+  protected function updateOrCreateFromEntity(EntityInterface $entity) {
+    $moderation_state = $entity->moderation_state->target_id;
+    /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    if (!$moderation_state) {
+      $moderation_state = $this->moderationInfo
+        ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle())
+        ->getThirdPartySetting('content_moderation', 'default_moderation_state');
+    }
+
+    // @todo what if $entity->moderation_state->target_id is null at this point?
+    $entity_type_id = $entity->getEntityTypeId();
+    $entity_id = $entity->id();
+    $entity_revision_id = $entity->getRevisionId();
+    $entity_langcode = $entity->language()->getId();
+
+    $storage = $this->entityTypeManager->getStorage('content_moderation_state');
+    $entities = $storage->loadByProperties([
+      'content_entity_type_id' => $entity_type_id,
+      'content_entity_id' => $entity_id,
+    ]);
+
+    /** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */
+    $content_moderation_state = reset($entities);
+    if (!($content_moderation_state instanceof ContentModerationStateInterface)) {
+      $content_moderation_state = $storage->create([
+        'content_entity_type_id' => $entity_type_id,
+        'content_entity_id' => $entity_id,
+      ]);
+    }
+    else {
+      // Create a new revision.
+      $content_moderation_state->setNewRevision(TRUE);
+    }
+
+    // Sync translations.
+    if (!$content_moderation_state->hasTranslation($entity_langcode)) {
+      $content_moderation_state->addTranslation($entity_langcode);
+    }
+    if ($content_moderation_state->language()->getId() !== $entity_langcode) {
+      $content_moderation_state = $content_moderation_state->getTranslation($entity_langcode);
+    }
+
+    // Create the ContentModerationState entity for the inserted entity.
+    $content_moderation_state->set('content_entity_revision_id', $entity_revision_id);
+    $content_moderation_state->set('moderation_state', $moderation_state);
+    ContentModerationState::updateOrCreateFromEntity($content_moderation_state);
+  }
+
+  /**
+   * Set the latest revision.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The content entity to create content_moderation_state entity for.
+   */
+  protected function setLatestRevision(EntityInterface $entity) {
+    /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    $this->tracker->setLatestRevision(
+      $entity->getEntityTypeId(),
+      $entity->id(),
+      $entity->language()->getId(),
+      $entity->getRevisionId()
+    );
+  }
+
+  /**
+   * Act on entities being assembled before rendering.
+   *
+   * This is a hook bridge.
+   *
+   * @see hook_entity_view()
+   * @see EntityFieldManagerInterface::getExtraFields()
+   */
+  public function entityView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
+    if (!$this->moderationInfo->isModeratableEntity($entity)) {
+      return;
+    }
+    if (!$this->moderationInfo->isLatestRevision($entity)) {
+      return;
+    }
+    /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    if ($entity->isDefaultRevision()) {
+      return;
+    }
+
+    $component = $display->getComponent('content_moderation_control');
+    if ($component) {
+      $build['content_moderation_control'] = $this->formBuilder->getForm(EntityModerationForm::class, $entity);
+      $build['content_moderation_control']['#weight'] = $component['weight'];
+    }
+  }
+
+  /**
+   * Check if the default revision for the given entity is published.
+   *
+   * The default revision is the same as the entity retrieved by "default" from
+   * the storage handler. If the entity is translated, use the default revision
+   * of the same language as the given entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity being saved.
+   *
+   * @return bool
+   *   TRUE if the default revision is published. FALSE otherwise.
+   */
+  protected function isDefaultRevisionPublished(EntityInterface $entity) {
+    $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId());
+    $default_revision = $storage->load($entity->id());
+
+    // Ensure we are comparing the same translation as the current entity.
+    if ($default_revision instanceof TranslatableInterface && $default_revision->isTranslatable()) {
+      // If there is no translation, then there is no default revision and is
+      // therefore not published.
+      if (!$default_revision->hasTranslation($entity->language()->getId())) {
+        return FALSE;
+      }
+
+      $default_revision = $default_revision->getTranslation($entity->language()->getId());
+    }
+
+    return $default_revision && $default_revision->moderation_state->entity->isPublishedState();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/EntityTypeInfo.php b/core/modules/content_moderation/src/EntityTypeInfo.php
new file mode 100644
index 0000000000000000000000000000000000000000..64c79461f04404bd7f810e590fe2c0d34d4482c3
--- /dev/null
+++ b/core/modules/content_moderation/src/EntityTypeInfo.php
@@ -0,0 +1,361 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList;
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Core\Url;
+use Drupal\content_moderation\Entity\Handler\BlockContentModerationHandler;
+use Drupal\content_moderation\Entity\Handler\ModerationHandler;
+use Drupal\content_moderation\Entity\Handler\NodeModerationHandler;
+use Drupal\content_moderation\Form\BundleModerationConfigurationForm;
+use Drupal\content_moderation\Routing\EntityModerationRouteProvider;
+use Drupal\content_moderation\Routing\EntityTypeModerationRouteProvider;
+
+/**
+ * Service class for manipulating entity type information.
+ *
+ * This class contains primarily bridged hooks for compile-time or
+ * cache-clear-time hooks. Runtime hooks should be placed in EntityOperations.
+ */
+class EntityTypeInfo {
+
+  use StringTranslationTrait;
+
+  /**
+   * The moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * A keyed array of custom moderation handlers for given entity types.
+   *
+   * Any entity not specified will use a common default.
+   *
+   * @var array
+   */
+  protected $moderationHandlers = [
+    'node' => NodeModerationHandler::class,
+    'block_content' => BlockContentModerationHandler::class,
+  ];
+
+  /**
+   * EntityTypeInfo constructor.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service. for form alters.
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+   *   The moderation information service.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   Entity type manager.
+   */
+  public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager) {
+    $this->stringTranslation = $translation;
+    $this->moderationInfo = $moderation_information;
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * Adds Moderation configuration to appropriate entity types.
+   *
+   * This is an alter hook bridge.
+   *
+   * @param EntityTypeInterface[] $entity_types
+   *   The master entity type list to alter.
+   *
+   * @see hook_entity_type_alter()
+   */
+  public function entityTypeAlter(array &$entity_types) {
+    foreach ($this->moderationInfo->selectRevisionableEntityTypes($entity_types) as $type_name => $type) {
+      $entity_types[$type_name] = $this->addModerationToEntityType($type);
+      $entity_types[$type->get('bundle_of')] = $this->addModerationToEntity($entity_types[$type->get('bundle_of')]);
+    }
+  }
+
+  /**
+   * Modifies an entity definition to include moderation support.
+   *
+   * This primarily just means an extra handler. A Generic one is provided,
+   * but individual entity types can provide their own as appropriate.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityTypeInterface $type
+   *   The content entity definition to modify.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityTypeInterface
+   *   The modified content entity definition.
+   */
+  protected function addModerationToEntity(ContentEntityTypeInterface $type) {
+    if (!$type->hasHandlerClass('moderation')) {
+      $handler_class = !empty($this->moderationHandlers[$type->id()]) ? $this->moderationHandlers[$type->id()] : ModerationHandler::class;
+      $type->setHandlerClass('moderation', $handler_class);
+    }
+
+    if (!$type->hasLinkTemplate('latest-version') && $type->hasLinkTemplate('canonical')) {
+      $type->setLinkTemplate('latest-version', $type->getLinkTemplate('canonical') . '/latest');
+    }
+
+    // @todo Core forgot to add a direct way to manipulate route_provider, so
+    // we have to do it the sloppy way for now.
+    $providers = $type->getRouteProviderClasses() ?: [];
+    if (empty($providers['moderation'])) {
+      $providers['moderation'] = EntityModerationRouteProvider::class;
+      $type->setHandlerClass('route_provider', $providers);
+    }
+
+    return $type;
+  }
+
+  /**
+   * Configures moderation configuration support on a entity type definition.
+   *
+   * That "configuration support" includes a configuration form, a hypermedia
+   * link, and a route provider to tie it all together. There's also a
+   * moderation handler for per-entity-type variation.
+   *
+   * @param \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $type
+   *   The config entity definition to modify.
+   *
+   * @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface
+   *   The modified config entity definition.
+   */
+  protected function addModerationToEntityType(ConfigEntityTypeInterface $type) {
+    if ($type->hasLinkTemplate('edit-form') && !$type->hasLinkTemplate('moderation-form')) {
+      $type->setLinkTemplate('moderation-form', $type->getLinkTemplate('edit-form') . '/moderation');
+    }
+
+    if (!$type->getFormClass('moderation')) {
+      $type->setFormClass('moderation', BundleModerationConfigurationForm::class);
+    }
+
+    // @todo Core forgot to add a direct way to manipulate route_provider, so
+    // we have to do it the sloppy way for now.
+    $providers = $type->getRouteProviderClasses() ?: [];
+    if (empty($providers['moderation'])) {
+      $providers['moderation'] = EntityTypeModerationRouteProvider::class;
+      $type->setHandlerClass('route_provider', $providers);
+    }
+
+    return $type;
+  }
+
+  /**
+   * Adds an operation on bundles that should have a Moderation form.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity on which to define an operation.
+   *
+   * @return array
+   *   An array of operation definitions.
+   *
+   * @see hook_entity_operation()
+   */
+  public function entityOperation(EntityInterface $entity) {
+    $operations = [];
+    $type = $entity->getEntityType();
+
+    if ($this->moderationInfo->isBundleForModeratableEntity($entity)) {
+      $operations['manage-moderation'] = [
+        'title' => t('Manage moderation'),
+        'weight' => 27,
+        'url' => Url::fromRoute("entity.{$type->id()}.moderation", [$entity->getEntityTypeId() => $entity->id()]),
+      ];
+    }
+
+    return $operations;
+  }
+
+  /**
+   * Gets the "extra fields" for a bundle.
+   *
+   * This is a hook bridge.
+   *
+   * @see hook_entity_extra_field_info()
+   *
+   * @return array
+   *   A nested array of 'pseudo-field' elements. Each list is nested within the
+   *   following keys: entity type, bundle name, context (either 'form' or
+   *   'display'). The keys are the name of the elements as appearing in the
+   *   renderable array (either the entity form or the displayed entity). The
+   *   value is an associative array:
+   *   - label: The human readable name of the element. Make sure you sanitize
+   *     this appropriately.
+   *   - description: A short description of the element contents.
+   *   - weight: The default weight of the element.
+   *   - visible: (optional) The default visibility of the element. Defaults to
+   *     TRUE.
+   *   - edit: (optional) String containing markup (normally a link) used as the
+   *     element's 'edit' operation in the administration interface. Only for
+   *     'form' context.
+   *   - delete: (optional) String containing markup (normally a link) used as
+   *     the element's 'delete' operation in the administration interface. Only
+   *     for 'form' context.
+   */
+  public function entityExtraFieldInfo() {
+    $return = [];
+    foreach ($this->getModeratedBundles() as $bundle) {
+      $return[$bundle['entity']][$bundle['bundle']]['display']['content_moderation_control'] = [
+        'label' => $this->t('Moderation control'),
+        'description' => $this->t("Status listing and form for the entity's moderation state."),
+        'weight' => -20,
+        'visible' => TRUE,
+      ];
+    }
+
+    return $return;
+  }
+
+  /**
+   * Returns an iterable list of entity names and bundle names under moderation.
+   *
+   * That is, this method returns a list of bundles that have Content
+   * Moderation enabled on them.
+   *
+   * @return \Generator
+   *   A generator, yielding a 2 element associative array:
+   *   - entity: The machine name of an entity type, such as "node" or
+   *     "block_content".
+   *   - bundle: The machine name of a bundle, such as "page" or "article".
+   */
+  protected function getModeratedBundles() {
+    $revisionable_types = $this->moderationInfo->selectRevisionableEntityTypes($this->entityTypeManager->getDefinitions());
+    /** @var ConfigEntityTypeInterface $type */
+    foreach ($revisionable_types as $type_name => $type) {
+      $result = $this->entityTypeManager
+        ->getStorage($type_name)
+        ->getQuery()
+        ->condition('third_party_settings.content_moderation.enabled', TRUE)
+        ->execute();
+
+      foreach ($result as $bundle_name) {
+        yield ['entity' => $type->getBundleOf(), 'bundle' => $bundle_name];
+      }
+    }
+  }
+
+  /**
+   * Adds base field info to an entity type.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   Entity type for adding base fields to.
+   *
+   * @return \Drupal\Core\Field\BaseFieldDefinition[]
+   *   New fields added by moderation state.
+   */
+  public function entityBaseFieldInfo(EntityTypeInterface $entity_type) {
+    if (!$this->moderationInfo->isModeratableEntityType($entity_type)) {
+      return [];
+    }
+
+    $fields = [];
+    $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(t('Moderation state'))
+      ->setDescription(t('The moderation state of this piece of content.'))
+      ->setComputed(TRUE)
+      ->setClass(ModerationStateFieldItemList::class)
+      ->setSetting('target_type', 'moderation_state')
+      ->setDisplayOptions('view', [
+        'label' => 'hidden',
+        'type' => 'hidden',
+        'weight' => -5,
+      ])
+      ->setDisplayOptions('form', [
+        'type' => 'moderation_state_default',
+        'weight' => 5,
+        'settings' => [],
+      ])
+      ->addConstraint('ModerationState', [])
+      ->setDisplayConfigurable('form', FALSE)
+      ->setDisplayConfigurable('view', FALSE)
+      ->setTranslatable(TRUE);
+
+    return $fields;
+  }
+
+  /**
+   * Adds the ModerationState constraint to bundles that are moderatable.
+   *
+   * @param \Drupal\Core\Field\FieldDefinitionInterface[] $fields
+   *   The array of bundle field definitions.
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition.
+   * @param string $bundle
+   *   The bundle.
+   *
+   * @see hook_entity_bundle_field_info_alter();
+   */
+  public function entityBundleFieldInfoAlter(&$fields, EntityTypeInterface $entity_type, $bundle) {
+    if (!empty($fields['moderation_state']) && $this->moderationInfo->isModeratableBundle($entity_type, $bundle)) {
+      $fields['moderation_state']->addConstraint('ModerationState', []);
+    }
+  }
+
+  /**
+   * Alters bundle forms to enforce revision handling.
+   *
+   * @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 bundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    if ($this->moderationInfo->isRevisionableBundleForm($form_state->getFormObject())) {
+      /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */
+      $bundle = $form_state->getFormObject()->getEntity();
+
+      $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id);
+    }
+    elseif ($this->moderationInfo->isModeratedEntityForm($form_state->getFormObject())) {
+      /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+      $entity = $form_state->getFormObject()->getEntity();
+
+      $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->enforceRevisionsEntityFormAlter($form, $form_state, $form_id);
+
+      // Submit handler to redirect to the latest version, if available.
+      $form['actions']['submit']['#submit'][] = [EntityTypeInfo::class, 'bundleFormRedirect'];
+    }
+  }
+
+  /**
+   * Redirect content entity edit forms on save, if there is a forward revision.
+   *
+   * When saving their changes, editors should see those changes displayed on
+   * the next page.
+   *
+   * @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 static function bundleFormRedirect(array &$form, FormStateInterface $form_state) {
+    /* @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+    $entity = $form_state->getFormObject()->getEntity();
+
+    $moderation_info = \Drupal::getContainer()->get('content_moderation.moderation_information');
+    if ($moderation_info->hasForwardRevision($entity) && $entity->hasLinkTemplate('latest-version')) {
+      $entity_type_id = $entity->getEntityTypeId();
+      $form_state->setRedirect("entity.$entity_type_id.latest_version", [$entity_type_id => $entity->id()]);
+    }
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..58dd334362fb4d92e64915329637c0860121a481
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php
@@ -0,0 +1,195 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\content_moderation\Entity\ModerationState;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Form for configuring moderation usage on a given entity bundle.
+ */
+class BundleModerationConfigurationForm extends EntityForm {
+
+  /**
+   * Entity Type Manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  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}
+   *
+   * Blank out the base form ID so that form alters that use the base form ID to
+   * target both add and edit forms don't pick up this form.
+   */
+  public function getBaseFormId() {
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */
+    $bundle = $form_state->getFormObject()->getEntity();
+    $form['enable_moderation_state'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Enable moderation states.'),
+      '#description' => $this->t('Content of this type must transition through moderation states in order to be published.'),
+      '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE),
+    ];
+
+    // Add a special message when moderation is being disabled.
+    if ($bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)) {
+      $form['enable_moderation_state_note'] = [
+        '#type' => 'item',
+        '#description' => $this->t('After disabling moderation, any existing forward drafts will be accessible via the "Revisions" tab.'),
+        '#states' => [
+          'visible' => [
+            ':input[name=enable_moderation_state]' => ['checked' => FALSE],
+          ],
+        ],
+      ];
+    }
+
+    $states = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple();
+    $label = function(ModerationState $state) {
+      return $state->label();
+    };
+
+    $options_published = array_map($label, array_filter($states, function(ModerationState $state) {
+      return $state->isPublishedState();
+    }));
+
+    $options_unpublished = array_map($label, array_filter($states, function(ModerationState $state) {
+      return !$state->isPublishedState();
+    }));
+
+    $form['allowed_moderation_states_unpublished'] = [
+      '#type' => 'checkboxes',
+      '#title' => $this->t('Allowed moderation states (Unpublished)'),
+      '#description' => $this->t('The allowed unpublished moderation states this content-type can be assigned.'),
+      '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_unpublished)),
+      '#options' => $options_unpublished,
+      '#required' => TRUE,
+      '#states' => [
+        'visible' => [
+          ':input[name=enable_moderation_state]' => ['checked' => TRUE],
+        ],
+      ],
+    ];
+
+    $form['allowed_moderation_states_published'] = [
+      '#type' => 'checkboxes',
+      '#title' => $this->t('Allowed moderation states (Published)'),
+      '#description' => $this->t('The allowed published moderation states this content-type can be assigned.'),
+      '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_published)),
+      '#options' => $options_published,
+      '#required' => TRUE,
+      '#states' => [
+        'visible' => [
+          ':input[name=enable_moderation_state]' => ['checked' => TRUE],
+        ],
+      ],
+    ];
+
+    // The key of the array needs to be a user-facing string so we have to fully
+    // render the translatable string to a real string, or else PHP errors on an
+    // object used as an array key.
+    $options = [
+      $this->t('Unpublished')->render() => $options_unpublished,
+      $this->t('Published')->render() => $options_published,
+    ];
+
+    $form['default_moderation_state'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Default moderation state'),
+      '#options' => $options,
+      '#description' => $this->t('Select the moderation state for new content'),
+      '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'),
+      '#states' => [
+        'visible' => [
+          ':input[name=enable_moderation_state]' => ['checked' => TRUE],
+        ],
+      ],
+    ];
+    $form['#entity_builders'][] = [$this, 'formBuilderCallback'];
+
+    return parent::form($form, $form_state);
+  }
+
+  /**
+   * Form builder callback.
+   *
+   * @todo This should be folded into the form method.
+   *
+   * @param string $entity_type_id
+   *   The entity type identifier.
+   * @param \Drupal\Core\Entity\EntityInterface $bundle
+   *   The bundle entity updated with the submitted values.
+   * @param array $form
+   *   The complete form array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  public function formBuilderCallback($entity_type_id, EntityInterface $bundle, &$form, FormStateInterface $form_state) {
+    // @todo https://www.drupal.org/node/2779933 write a test for this.
+    if ($bundle instanceof ThirdPartySettingsInterface) {
+      $bundle->setThirdPartySetting('content_moderation', 'enabled', $form_state->getValue('enable_moderation_state'));
+      $bundle->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished'))));
+      $bundle->setThirdPartySetting('content_moderation', 'default_moderation_state', $form_state->getValue('default_moderation_state'));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    if ($form_state->getValue('enable_moderation_state')) {
+      $allowed = array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished')));
+
+      if (($default = $form_state->getValue('default_moderation_state')) && !in_array($default, $allowed, TRUE)) {
+        $form_state->setErrorByName('default_moderation_state', $this->t('The default moderation state must be one of the allowed states.'));
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    // If moderation is enabled, revisions MUST be enabled as well. Otherwise we
+    // can't have forward revisions.
+    if ($form_state->getValue('enable_moderation_state')) {
+      /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */
+      $bundle = $form_state->getFormObject()->getEntity();
+
+      $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->onBundleModerationConfigurationFormSubmit($bundle);
+    }
+
+    parent::submitForm($form, $form_state);
+
+    drupal_set_message($this->t('Your settings have been saved.'));
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Form/EntityModerationForm.php b/core/modules/content_moderation/src/Form/EntityModerationForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..39baec028cc9f1d480a5dd8fbfdd0eb6681bb760
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/EntityModerationForm.php
@@ -0,0 +1,161 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Drupal\content_moderation\StateTransitionValidation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * The EntityModerationForm provides a simple UI for changing moderation state.
+ */
+class EntityModerationForm extends FormBase {
+
+  /**
+   * The moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * The moderation state transition validation service.
+   *
+   * @var \Drupal\content_moderation\StateTransitionValidation
+   */
+  protected $validation;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * EntityModerationForm constructor.
+   *
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+   *   The moderation information service.
+   * @param \Drupal\content_moderation\StateTransitionValidation $validation
+   *   The moderation state transition validation service.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   */
+  public function __construct(ModerationInformationInterface $moderation_info, StateTransitionValidation $validation, EntityTypeManagerInterface $entity_type_manager) {
+    $this->moderationInfo = $moderation_info;
+    $this->validation = $validation;
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('content_moderation.moderation_information'),
+      $container->get('content_moderation.state_transition_validation'),
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'content_moderation_entity_moderation_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state, ContentEntityInterface $entity = NULL) {
+    /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
+    $current_state = $entity->moderation_state->entity;
+
+    $transitions = $this->validation->getValidTransitions($entity, $this->currentUser());
+
+    // Exclude self-transitions.
+    $transitions = array_filter($transitions, function(ModerationStateTransition $transition) use ($current_state) {
+      return $transition->getToState() != $current_state->id();
+    });
+
+    $target_states = [];
+    /** @var ModerationStateTransition $transition */
+    foreach ($transitions as $transition) {
+      $target_states[$transition->getToState()] = $transition->label();
+    }
+
+    if (!count($target_states)) {
+      return $form;
+    }
+
+    if ($current_state) {
+      $form['current'] = [
+        '#type' => 'item',
+        '#title' => $this->t('Status'),
+        '#markup' => $current_state->label(),
+      ];
+    }
+
+    // Persist the entity so we can access it in the submit handler.
+    $form_state->set('entity', $entity);
+
+    $form['new_state'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Moderate'),
+      '#options' => $target_states,
+    ];
+
+    $form['revision_log'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Log message'),
+      '#size' => 30,
+    ];
+
+    $form['submit'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Apply'),
+    ];
+
+    $form['#theme'] = ['entity_moderation_form'];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    /** @var ContentEntityInterface $entity */
+    $entity = $form_state->get('entity');
+
+    $new_state = $form_state->getValue('new_state');
+
+    // @todo should we just just be updating the content moderation state
+    //   entity? That would prevent setting the revision log.
+    $entity->moderation_state->target_id = $new_state;
+    $entity->revision_log = $form_state->getValue('revision_log');
+
+    $entity->save();
+
+    drupal_set_message($this->t('The moderation state has been updated.'));
+
+    /** @var \Drupal\content_moderation\Entity\ModerationState $state */
+    $state = $this->entityTypeManager->getStorage('moderation_state')->load($new_state);
+
+    // The page we're on likely won't be visible if we just set the entity to
+    // the default state, as we hide that latest-revision tab if there is no
+    // forward revision. Redirect to the canonical URL instead, since that will
+    // still exist.
+    if ($state->isDefaultRevisionState()) {
+      $form_state->setRedirectUrl($entity->toUrl('canonical'));
+    }
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..43e2b36d9666de41320016dd54a3d37f62cdf4a6
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Builds the form to delete Moderation state entities.
+ */
+class ModerationStateDeleteForm extends EntityConfirmFormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Are you sure you want to delete %name?', array('%name' => $this->entity->label()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl() {
+    return new Url('entity.moderation_state.collection');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText() {
+    return $this->t('Delete');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $this->entity->delete();
+
+    drupal_set_message($this->t(
+      'Moderation state %label deleted.',
+      ['%label' => $this->entity->label()]
+    ));
+
+    $form_state->setRedirectUrl($this->getCancelUrl());
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Form/ModerationStateForm.php b/core/modules/content_moderation/src/Form/ModerationStateForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..32d7a48373fa184682ade6eb842e896c547b3f0c
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateForm.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Class ModerationStateForm.
+ */
+class ModerationStateForm extends EntityForm {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $form = parent::form($form, $form_state);
+
+    /* @var \Drupal\content_moderation\ModerationStateInterface $moderation_state */
+    $moderation_state = $this->entity;
+    $form['label'] = array(
+      '#type' => 'textfield',
+      '#title' => $this->t('Label'),
+      '#maxlength' => 255,
+      '#default_value' => $moderation_state->label(),
+      '#description' => $this->t('Label for the Moderation state.'),
+      '#required' => TRUE,
+    );
+
+    $form['id'] = array(
+      '#type' => 'machine_name',
+      '#default_value' => $moderation_state->id(),
+      '#machine_name' => array(
+        'exists' => [ModerationState::class, 'load'],
+      ),
+      '#disabled' => !$moderation_state->isNew(),
+    );
+
+    $form['published'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Published'),
+      '#description' => $this->t('When content reaches this state it should be published.'),
+      '#default_value' => $moderation_state->isPublishedState(),
+    ];
+
+    $form['default_revision'] = [
+      '#type' => 'checkbox',
+      '#title' => $this->t('Default revision'),
+      '#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'),
+      '#default_value' => $moderation_state->isDefaultRevisionState(),
+      // @todo Add form #state to force "make default" on when "published" is
+      // on for a state.
+      // @see https://www.drupal.org/node/2645614
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    $moderation_state = $this->entity;
+    $status = $moderation_state->save();
+
+    switch ($status) {
+      case SAVED_NEW:
+        drupal_set_message($this->t('Created the %label Moderation state.', [
+          '%label' => $moderation_state->label(),
+        ]));
+        break;
+
+      default:
+        drupal_set_message($this->t('Saved the %label Moderation state.', [
+          '%label' => $moderation_state->label(),
+        ]));
+    }
+    $form_state->setRedirectUrl($moderation_state->toUrl('collection'));
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..f153f1f938272764b9d71f1b45d4b7b3f8801d9e
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\EntityConfirmFormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Builds the form to delete Moderation state transition entities.
+ */
+class ModerationStateTransitionDeleteForm extends EntityConfirmFormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getQuestion() {
+    return $this->t('Are you sure you want to delete %name?', array('%name' => $this->entity->label()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCancelUrl() {
+    return new Url('entity.moderation_state_transition.collection');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfirmText() {
+    return $this->t('Delete');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $this->entity->delete();
+
+    drupal_set_message($this->t(
+      'Moderation transition %label deleted.',
+      ['%label' => $this->entity->label()]
+    ));
+
+    $form_state->setRedirectUrl($this->getCancelUrl());
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..8322c18e77d5fb785a9071417c47d2f7f2bca617
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryFactory;
+use Drupal\Core\Form\FormStateInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Class ModerationStateTransitionForm.
+ *
+ * @package Drupal\content_moderation\Form
+ */
+class ModerationStateTransitionForm extends EntityForm {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The entity query factory.
+   *
+   * @var \Drupal\Core\Entity\Query\QueryFactory
+   */
+  protected $queryFactory;
+
+  /**
+   * Constructs a new ModerationStateTransitionForm.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
+   *   The entity query factory.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, QueryFactory $query_factory) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->queryFactory = $query_factory;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static($container->get('entity_type.manager'), $container->get('entity.query'));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    $form = parent::form($form, $form_state);
+
+    /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $moderation_state_transition */
+    $moderation_state_transition = $this->entity;
+    $form['label'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Label'),
+      '#maxlength' => 255,
+      '#default_value' => $moderation_state_transition->label(),
+      '#description' => $this->t('Label for the Moderation state transition.'),
+      '#required' => TRUE,
+    ];
+
+    $form['id'] = [
+      '#type' => 'machine_name',
+      '#default_value' => $moderation_state_transition->id(),
+      '#machine_name' => [
+        'exists' => '\Drupal\content_moderation\Entity\ModerationStateTransition::load',
+      ],
+      '#disabled' => !$moderation_state_transition->isNew(),
+    ];
+
+    $options = [];
+    foreach ($this->entityTypeManager->getStorage('moderation_state')
+               ->loadMultiple() as $moderation_state) {
+      $options[$moderation_state->id()] = $moderation_state->label();
+    }
+
+    $form['container'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'class' => ['container-inline'],
+      ],
+    ];
+
+    $form['container']['stateFrom'] = [
+      '#type' => 'select',
+      '#title' => $this->t('Transition from'),
+      '#options' => $options,
+      '#required' => TRUE,
+      '#empty_option' => $this->t('-- Select --'),
+      '#default_value' => $moderation_state_transition->getFromState(),
+    ];
+
+    $form['container']['stateTo'] = [
+      '#type' => 'select',
+      '#options' => $options,
+      '#required' => TRUE,
+      '#title' => $this->t('Transition to'),
+      '#empty_option' => $this->t('-- Select --'),
+      '#default_value' => $moderation_state_transition->getToState(),
+    ];
+
+    // Make sure there's always at least a wide enough delta on weight to cover
+    // the current value or the total number of transitions. That way we
+    // never end up forcing a transition to change its weight needlessly.
+    $num_transitions = $this->queryFactory->get('moderation_state_transition')
+      ->count()
+      ->execute();
+    $delta = max(abs($moderation_state_transition->getWeight()), $num_transitions);
+
+    $form['weight'] = [
+      '#type' => 'weight',
+      '#delta' => $delta,
+      '#options' => $options,
+      '#title' => $this->t('Weight'),
+      '#default_value' => $moderation_state_transition->getWeight(),
+      '#description' => $this->t('Orders the transitions in moderation forms and the administrative listing. Heavier items will sink and the lighter items will be positioned nearer the top.'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save(array $form, FormStateInterface $form_state) {
+    $moderation_state_transition = $this->entity;
+    $status = $moderation_state_transition->save();
+
+    switch ($status) {
+      case SAVED_NEW:
+        drupal_set_message($this->t('Created the %label Moderation state transition.', [
+          '%label' => $moderation_state_transition->label(),
+        ]));
+        break;
+
+      default:
+        drupal_set_message($this->t('Saved the %label Moderation state transition.', [
+          '%label' => $moderation_state_transition->label(),
+        ]));
+    }
+    $form_state->setRedirectUrl($moderation_state_transition->toUrl('collection'));
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php
new file mode 100644
index 0000000000000000000000000000000000000000..cf79bc1cc82fc1519a0ae21fb2c0f8e86dc5f56d
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationInformation.php
@@ -0,0 +1,208 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\BundleEntityFormBase;
+use Drupal\Core\Entity\ContentEntityFormInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * General service for moderation-related questions about Entity API.
+ */
+class ModerationInformation implements ModerationInformationInterface {
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * Creates a new ModerationInformation instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   The current user.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->currentUser = $current_user;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isModeratableEntity(EntityInterface $entity) {
+    if (!$entity instanceof ContentEntityInterface) {
+      return FALSE;
+    }
+
+    return $this->isModeratableBundle($entity->getEntityType(), $entity->bundle());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isModeratableEntityType(EntityTypeInterface $entity_type) {
+    return $entity_type->hasHandlerClass('moderation');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function loadBundleEntity($bundle_entity_type_id, $bundle_id) {
+    if ($bundle_entity_type_id) {
+      return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isModeratableBundle(EntityTypeInterface $entity_type, $bundle) {
+    if ($bundle_entity = $this->loadBundleEntity($entity_type->getBundleEntityType(), $bundle)) {
+      return $bundle_entity->getThirdPartySetting('content_moderation', 'enabled', FALSE);
+    }
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function selectRevisionableEntityTypes(array $entity_types) {
+    return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) {
+      return ($type instanceof ConfigEntityTypeInterface)
+      && ($bundle_of = $type->get('bundle_of'))
+      && $entity_types[$bundle_of]->isRevisionable();
+    });
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function selectRevisionableEntities(array $entity_types) {
+    return array_filter($entity_types, function (EntityTypeInterface $type) use ($entity_types) {
+      return ($type instanceof ContentEntityTypeInterface)
+      && $type->isRevisionable()
+      && $type->getBundleEntityType();
+    });
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isBundleForModeratableEntity(EntityInterface $entity) {
+    $type = $entity->getEntityType();
+
+    return
+      $type instanceof ConfigEntityTypeInterface
+      && ($bundle_of = $type->get('bundle_of'))
+      && $this->entityTypeManager->getDefinition($bundle_of)->isRevisionable()
+      && $this->currentUser->hasPermission('administer moderation states');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isModeratedEntityForm(FormInterface $form_object) {
+    return $form_object instanceof ContentEntityFormInterface
+    && $this->isModeratableEntity($form_object->getEntity());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isRevisionableBundleForm(FormInterface $form_object) {
+    if ($form_object instanceof BundleEntityFormBase) {
+      $bundle_of = $form_object->getEntity()->getEntityType()->getBundleOf();
+      $type = $this->entityTypeManager->getDefinition($bundle_of);
+      return $type->isRevisionable();
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLatestRevision($entity_type_id, $entity_id) {
+    if ($latest_revision_id = $this->getLatestRevisionId($entity_type_id, $entity_id)) {
+      return $this->entityTypeManager->getStorage($entity_type_id)->loadRevision($latest_revision_id);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLatestRevisionId($entity_type_id, $entity_id) {
+    if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) {
+      $revision_ids = $storage->getQuery()
+        ->allRevisions()
+        ->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id)
+        ->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC')
+        ->range(0, 1)
+        ->execute();
+      if ($revision_ids) {
+        return array_keys($revision_ids)[0];
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDefaultRevisionId($entity_type_id, $entity_id) {
+    if ($storage = $this->entityTypeManager->getStorage($entity_type_id)) {
+      $revision_ids = $storage->getQuery()
+        ->condition($this->entityTypeManager->getDefinition($entity_type_id)->getKey('id'), $entity_id)
+        ->sort($this->entityTypeManager->getDefinition($entity_type_id)->getKey('revision'), 'DESC')
+        ->range(0, 1)
+        ->execute();
+      if ($revision_ids) {
+        return array_keys($revision_ids)[0];
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isLatestRevision(ContentEntityInterface $entity) {
+    return $entity->getRevisionId() == $this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function hasForwardRevision(ContentEntityInterface $entity) {
+    return $this->isModeratableEntity($entity)
+      && !($this->getLatestRevisionId($entity->getEntityTypeId(), $entity->id()) == $this->getDefaultRevisionId($entity->getEntityTypeId(), $entity->id()));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isLiveRevision(ContentEntityInterface $entity) {
+    return $this->isLatestRevision($entity)
+      && $entity->isDefaultRevision()
+      && $entity->moderation_state->entity
+      && $entity->moderation_state->entity->isPublishedState();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationInformationInterface.php b/core/modules/content_moderation/src/ModerationInformationInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..b2037925d13909417bcde9ceed801f83a6f3b3f4
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationInformationInterface.php
@@ -0,0 +1,212 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Form\FormInterface;
+
+/**
+ * Interface for moderation_information service.
+ */
+interface ModerationInformationInterface {
+
+  /**
+   * Loads a specific bundle entity.
+   *
+   * @param string $bundle_entity_type_id
+   *   The bundle entity type ID.
+   * @param string $bundle_id
+   *   The bundle ID.
+   *
+   * @return \Drupal\Core\Config\Entity\ConfigEntityInterface|null
+   *   The bundle entity.
+   */
+  public function loadBundleEntity($bundle_entity_type_id, $bundle_id);
+
+  /**
+   * Determines if an entity is one we should be moderating.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity we may be moderating.
+   *
+   * @return bool
+   *   TRUE if this is an entity that we should act upon, FALSE otherwise.
+   */
+  public function isModeratableEntity(EntityInterface $entity);
+
+  /**
+   * Determines if an entity type has been marked as moderatable.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   An entity type object.
+   *
+   * @return bool
+   *   TRUE if this entity type has been marked as moderatable, FALSE otherwise.
+   */
+  public function isModeratableEntityType(EntityTypeInterface $entity_type);
+
+  /**
+   * Determines if an entity type/bundle is one that will be moderated.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type definition to check.
+   * @param string $bundle
+   *   The bundle to check.
+   *
+   * @return bool
+   *   TRUE if this is a bundle we want to moderate, FALSE otherwise.
+   */
+  public function isModeratableBundle(EntityTypeInterface $entity_type, $bundle);
+
+  /**
+   * Filters entity lists to just bundle definitions for revisionable entities.
+   *
+   * @param EntityTypeInterface[] $entity_types
+   *   The master entity type list filter.
+   *
+   * @return \Drupal\Core\Config\Entity\ConfigEntityTypeInterface[]
+   *   An array of only the config entities we want to modify.
+   */
+  public function selectRevisionableEntityTypes(array $entity_types);
+
+  /**
+   * Filters entity lists to just the definitions for moderatable entities.
+   *
+   * An entity type is moderatable only if it is both revisionable and
+   * bundleable.
+   *
+   * @param EntityTypeInterface[] $entity_types
+   *   The master entity type list filter.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityTypeInterface[]
+   *   An array of only the content entity definitions we want to modify.
+   */
+  public function selectRevisionableEntities(array $entity_types);
+
+  /**
+   * Determines if config entity is a bundle for entities that may be moderated.
+   *
+   * This is the same check as exists in selectRevisionableEntityTypes(), but
+   * that one cannot use the entity manager due to recursion and this one
+   * doesn't have the entity list otherwise so must use the entity manager. The
+   * alternative would be to call getDefinitions() on entityTypeManager and use
+   * that in a sub-call, but that would be unnecessarily memory intensive.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to check.
+   *
+   * @return bool
+   *   TRUE if we want to add a Moderation operation to this entity, FALSE
+   *   otherwise.
+   */
+  public function isBundleForModeratableEntity(EntityInterface $entity);
+
+  /**
+   * Determines if this form is for a moderated entity.
+   *
+   * @param \Drupal\Core\Form\FormInterface $form_object
+   *   The form definition object for this form.
+   *
+   * @return bool
+   *   TRUE if the form is for an entity that is subject to moderation, FALSE
+   *   otherwise.
+   */
+  public function isModeratedEntityForm(FormInterface $form_object);
+
+  /**
+   * Determines if the form is the bundle edit of a revisionable entity.
+   *
+   * The logic here is not entirely clear, but seems to work. The form- and
+   * entity-dereference chaining seems excessive but is what works.
+   *
+   * @param \Drupal\Core\Form\FormInterface $form_object
+   *   The form definition object for this form.
+   *
+   * @return bool
+   *   True if the form is the bundle edit form for an entity type that supports
+   *   revisions, false otherwise.
+   */
+  public function isRevisionableBundleForm(FormInterface $form_object);
+
+  /**
+   * Loads the latest revision of a specific entity.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID.
+   * @param int $entity_id
+   *   The entity ID.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface|null
+   *   The latest entity revision or NULL, if the entity type / entity doesn't
+   *   exist.
+   */
+  public function getLatestRevision($entity_type_id, $entity_id);
+
+  /**
+   * Returns the revision ID of the latest revision of the given entity.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID.
+   * @param int $entity_id
+   *   The entity ID.
+   *
+   * @return int
+   *   The revision ID of the latest revision for the specified entity, or
+   *   NULL if there is no such entity.
+   */
+  public function getLatestRevisionId($entity_type_id, $entity_id);
+
+  /**
+   * Returns the revision ID of the default revision for the specified entity.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID.
+   * @param int $entity_id
+   *   The entity ID.
+   *
+   * @return int
+   *   The revision ID of the default revision, or NULL if the entity was
+   *   not found.
+   */
+  public function getDefaultRevisionId($entity_type_id, $entity_id);
+
+  /**
+   * Determines if an entity is a latest revision.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   A revisionable content entity.
+   *
+   * @return bool
+   *   TRUE if the specified object is the latest revision of its entity,
+   *   FALSE otherwise.
+   */
+  public function isLatestRevision(ContentEntityInterface $entity);
+
+  /**
+   * Determines if a forward revision exists for the specified entity.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity which may or may not have a forward revision.
+   *
+   * @return bool
+   *   TRUE if this entity has forward revisions available, FALSE otherwise.
+   */
+  public function hasForwardRevision(ContentEntityInterface $entity);
+
+  /**
+   * Determines if an entity is "live".
+   *
+   * A "live" entity revision is one whose latest revision is also the default,
+   * and whose moderation state, if any, is a published state.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity to check.
+   *
+   * @return bool
+   *   TRUE if the specified entity is a live revision, FALSE otherwise.
+   */
+  public function isLiveRevision(ContentEntityInterface $entity);
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php b/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php
new file mode 100644
index 0000000000000000000000000000000000000000..cc2c977a3c1d78d819ad6f38edc3da2df1ceb10d
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\EntityAccessControlHandler;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Access\AccessResult;
+
+/**
+ * Access controller for the Moderation State entity.
+ *
+ * @see \Drupal\workbench_moderation\Entity\ModerationState.
+ */
+class ModerationStateAccessControlHandler extends EntityAccessControlHandler {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
+    $admin_access = AccessResult::allowedIfHasPermission($account, 'administer moderation states');
+
+    // Allow view with other permission.
+    if ($operation === 'view') {
+      return AccessResult::allowedIfHasPermission($account, 'view moderation states')->orIf($admin_access);
+    }
+
+    return $admin_access;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
+    return AccessResult::allowedIfHasPermission($account, 'administer moderation states');
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateInterface.php b/core/modules/content_moderation/src/ModerationStateInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..99f664fa97cd3dad178df3fcf7530e91ef3962a2
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+
+/**
+ * Provides an interface for defining Moderation state entities.
+ */
+interface ModerationStateInterface extends ConfigEntityInterface {
+
+  /**
+   * Determines if content updated to this state should be published.
+   *
+   * @return bool
+   *   TRUE if content updated to this state should be published.
+   */
+  public function isPublishedState();
+
+  /**
+   * Determines if content updated to this state should be the default revision.
+   *
+   * @return bool
+   *   TRUE if content in this state should be the default revision.
+   */
+  public function isDefaultRevisionState();
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateListBuilder.php b/core/modules/content_moderation/src/ModerationStateListBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..05ba51307988ab1a757e74a93a394018bedb3b05
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateListBuilder.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\DraggableListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Provides a listing of Moderation state entities.
+ */
+class ModerationStateListBuilder extends DraggableListBuilder {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'moderation_state_admin_overview_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildHeader() {
+    $header['label'] = $this->t('Moderation state');
+    $header['id'] = $this->t('Machine name');
+
+    return $header + parent::buildHeader();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildRow(EntityInterface $entity) {
+    $row['label'] = $entity->label();
+    $row['id']['#markup'] = $entity->id();
+
+    return $row + parent::buildRow($entity);
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateTransitionInterface.php b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..91b5b13cafdcc939132019b528c106f6d28f0254
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+
+/**
+ * Provides an interface for defining Moderation state transition entities.
+ */
+interface ModerationStateTransitionInterface extends ConfigEntityInterface {
+
+  /**
+   * Gets the from state for the given transition.
+   *
+   * @return string
+   *   The moderation state ID for the from state.
+   */
+  public function getFromState();
+
+  /**
+   * Gets the to state for the given transition.
+   *
+   * @return string
+   *   The moderation state ID for the to state.
+   */
+  public function getToState();
+
+  /**
+   * Gets the weight for the given transition.
+   *
+   * @return int
+   *   The weight of this transition.
+   */
+  public function getWeight();
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php b/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php
new file mode 100644
index 0000000000000000000000000000000000000000..577283e6f8432c8eded5c96d52c84508e322908a
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\DraggableListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\user\RoleStorageInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a listing of Moderation state transition entities.
+ */
+class ModerationStateTransitionListBuilder extends DraggableListBuilder {
+
+  /**
+   * Moderation state entity storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $stateStorage;
+
+  /**
+   * The role storage.
+   *
+   * @var \Drupal\user\RoleStorageInterface
+   */
+  protected $roleStorage;
+
+  /**
+   * {@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('entity.manager')->getStorage('moderation_state'),
+      $container->get('entity.manager')->getStorage('user_role')
+    );
+  }
+
+  /**
+   * Constructs a new ModerationStateTransitionListBuilder.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   Entity Type.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $transition_storage
+   *   Moderation state transition entity storage.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $state_storage
+   *   Moderation state entity storage.
+   * @param \Drupal\user\RoleStorageInterface $role_storage
+   *   The role storage.
+   */
+  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $transition_storage, EntityStorageInterface $state_storage, RoleStorageInterface $role_storage) {
+    parent::__construct($entity_type, $transition_storage);
+    $this->stateStorage = $state_storage;
+    $this->roleStorage = $role_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'content_moderation_transition_list';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildHeader() {
+    $header['to'] = $this->t('To state');
+    $header['label'] = $this->t('Button label');
+    $header['roles'] = $this->t('Allowed roles');
+
+    return $header + parent::buildHeader();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildRow(EntityInterface $entity) {
+    $row['to']['#markup'] = $this->stateStorage->load($entity->getToState())->label();
+    $row['label'] = $entity->label();
+    $row['roles']['#markup'] = implode(', ', user_role_names(FALSE, 'use ' . $entity->id() . ' transition'));
+
+    return $row + parent::buildRow($entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    $build = parent::render();
+
+    $build['item'] = [
+      '#type' => 'item',
+      '#markup' => $this->t('On this screen you can define <em>transitions</em>. Every time an entity is saved, it undergoes a transition. It is not possible to save an entity if it tries do a transition not defined here. Transitions do not necessarily mean a state change, it is possible to transition from a state to the same state but that transition needs to be defined here as well.'),
+      '#weight' => -5,
+    ];
+
+    return $build;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $this->entities = $this->load();
+
+    // Get all the moderation states and sort them by weight.
+    $states = $this->stateStorage->loadMultiple();
+    uasort($states, array($this->entityType->getClass(), 'sort'));
+
+    /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */
+    $groups = array_fill_keys(array_keys($states), []);
+    foreach ($this->entities as $entity) {
+      $groups[$entity->getFromState()][] = $entity;
+    }
+
+    foreach ($groups as $group_name => $entities) {
+      $form[$group_name] = [
+        '#type' => 'details',
+        '#title' => $this->t('From @state to...', ['@state' => $states[$group_name]->label()]),
+        // Make sure that the first group is always open.
+        '#open' => $group_name === array_keys($groups)[0],
+      ];
+
+      $form[$group_name][$this->entitiesKey] = array(
+        '#type' => 'table',
+        '#header' => $this->buildHeader(),
+        '#empty' => t('There is no @label yet.', array('@label' => $this->entityType->getLabel())),
+        '#tabledrag' => array(
+          array(
+            'action' => 'order',
+            'relationship' => 'sibling',
+            'group' => 'weight',
+          ),
+        ),
+      );
+
+      $delta = 10;
+      // Change the delta of the weight field if have more than 20 entities.
+      if (!empty($this->weightKey)) {
+        $count = count($this->entities);
+        if ($count > 20) {
+          $delta = ceil($count / 2);
+        }
+      }
+      foreach ($entities as $entity) {
+        $row = $this->buildRow($entity);
+        if (isset($row['label'])) {
+          $row['label'] = array('#markup' => $row['label']);
+        }
+        if (isset($row['weight'])) {
+          $row['weight']['#delta'] = $delta;
+        }
+        $form[$group_name][$this->entitiesKey][$entity->id()] = $row;
+      }
+    }
+
+    $form['actions']['#type'] = 'actions';
+    $form['actions']['submit'] = array(
+      '#type' => 'submit',
+      '#value' => t('Save order'),
+      '#button_type' => 'primary',
+    );
+
+    return $form;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php
new file mode 100644
index 0000000000000000000000000000000000000000..263183b907cd5aa2f46691054663e172cb077fe0
--- /dev/null
+++ b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\content_moderation\ParamConverter;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityManagerInterface;
+use Drupal\Core\ParamConverter\EntityConverter;
+use Drupal\Core\TypedData\TranslatableInterface;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\Routing\Route;
+
+/**
+ * Defines a class for making sure the edit-route loads the current draft.
+ */
+class EntityRevisionConverter extends EntityConverter {
+
+  /**
+   * Moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInformation;
+
+  /**
+   * EntityRevisionConverter constructor.
+   *
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
+   *   The entity manager, needed by the parent class.
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+   *   The moderation info utility service.
+   *
+   * @todo: If the parent class is ever cleaned up to use EntityTypeManager
+   *   instead of Entity manager, this method will also need to be adjusted.
+   */
+  public function __construct(EntityManagerInterface $entity_manager, ModerationInformationInterface $moderation_info) {
+    parent::__construct($entity_manager);
+    $this->moderationInformation = $moderation_info;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function applies($definition, $name, Route $route) {
+    return $this->hasForwardRevisionFlag($definition) || $this->isEditFormPage($route);
+  }
+
+  /**
+   * Determines if the route definition includes a forward-revision flag.
+   *
+   * This is a custom flag defined by the Content Moderation module to load
+   * forward revisions rather than the default revision on a given route.
+   *
+   * @param array $definition
+   *   The parameter definition provided in the route options.
+   *
+   * @return bool
+   *   TRUE if the forward revision flag is set, FALSE otherwise.
+   */
+  protected function hasForwardRevisionFlag(array $definition) {
+    return (isset($definition['load_forward_revision']) && $definition['load_forward_revision']);
+  }
+
+  /**
+   * Determines if a given route is the edit-form for an entity.
+   *
+   * @param \Symfony\Component\Routing\Route $route
+   *   The route definition.
+   *
+   * @return bool
+   *   Returns TRUE if the route is the edit form of an entity, FALSE otherwise.
+   */
+  protected function isEditFormPage(Route $route) {
+    if ($default = $route->getDefault('_entity_form')) {
+      // If no operation is provided, use 'default'.
+      $default .= '.default';
+      list($entity_type_id, $operation) = explode('.', $default);
+      if (!$this->entityManager->hasDefinition($entity_type_id)) {
+        return FALSE;
+      }
+      $entity_type = $this->entityManager->getDefinition($entity_type_id);
+      return $operation == 'edit' && $entity_type && $entity_type->isRevisionable();
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function convert($value, $definition, $name, array $defaults) {
+    $entity = parent::convert($value, $definition, $name, $defaults);
+
+    if ($entity && $this->moderationInformation->isModeratableEntity($entity) && !$this->moderationInformation->isLatestRevision($entity)) {
+      $entity_type_id = $this->getEntityTypeFromDefaults($definition, $name, $defaults);
+      $latest_revision = $this->moderationInformation->getLatestRevision($entity_type_id, $value);
+
+      // If the entity type is translatable, ensure we return the proper
+      // translation object for the current context.
+      if ($latest_revision instanceof EntityInterface && $entity instanceof TranslatableInterface) {
+        $latest_revision = $this->entityManager->getTranslationFromContext($latest_revision, NULL, array('operation' => 'entity_upcast'));
+      }
+
+      if ($latest_revision->isRevisionTranslationAffected()) {
+        $entity = $latest_revision;
+      }
+    }
+
+    return $entity;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Permissions.php b/core/modules/content_moderation/src/Permissions.php
new file mode 100644
index 0000000000000000000000000000000000000000..027684c2c6f9fa8ee614dee51e8f04e5c778805f
--- /dev/null
+++ b/core/modules/content_moderation/src/Permissions.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+
+/**
+ * Defines a class for dynamic permissions based on transitions.
+ */
+class Permissions {
+
+  use StringTranslationTrait;
+
+  /**
+   * Returns an array of transition permissions.
+   *
+   * @return array
+   *   The transition permissions.
+   */
+  public function transitionPermissions() {
+    // @todo https://www.drupal.org/node/2779933 write a test for this.
+    $perms = [];
+    /* @var \Drupal\content_moderation\ModerationStateInterface[] $states */
+    $states = ModerationState::loadMultiple();
+    /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */
+    foreach (ModerationStateTransition::loadMultiple() as $id => $transition) {
+      $perms['use ' . $id . ' transition'] = [
+        'title' => $this->t('Use the %transition_name transition', [
+          '%transition_name' => $transition->label(),
+        ]),
+        'description' => $this->t('Move content from %from state to %to state.', [
+          '%from' => $states[$transition->getFromState()]->label(),
+          '%to' => $states[$transition->getToState()]->label(),
+        ]),
+      ];
+    }
+
+    return $perms;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php
new file mode 100644
index 0000000000000000000000000000000000000000..a85bac6e55d0752204f8f12350e5621bd73e110a
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Action;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\node\Plugin\Action\PublishNode;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Alternate action plugin that can opt-out of modifying moderated entities.
+ *
+ * @see \Drupal\node\Plugin\Action\PublishNode
+ */
+class ModerationOptOutPublishNode extends PublishNode implements ContainerFactoryPluginInterface {
+
+  /**
+   * Moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * ModerationOptOutPublishNode constructor.
+   *
+   * @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\content_moderation\ModerationInformationInterface $moderation_info
+   *   The moderation information service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $moderation_info) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->moderationInfo = $moderation_info;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration, $plugin_id, $plugin_definition,
+      $container->get('content_moderation.moderation_information')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function execute($entity = NULL) {
+    if ($entity && $this->moderationInfo->isModeratableEntity($entity)) {
+      drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.'));
+      return;
+    }
+
+    parent::execute($entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) {
+    $result = parent::access($object, $account, TRUE)
+      ->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratableEntity($object))->addCacheableDependency($object));
+
+    return $return_as_object ? $result : $result->isAllowed();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php
new file mode 100644
index 0000000000000000000000000000000000000000..b0fbd8796817e4c355d27cf270116d57ea0717bb
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Action;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\node\Plugin\Action\UnpublishNode;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Alternate action plugin that can opt-out of modifying moderated entities.
+ *
+ * @see \Drupal\node\Plugin\Action\UnpublishNode
+ */
+class ModerationOptOutUnpublishNode extends UnpublishNode implements ContainerFactoryPluginInterface {
+
+  /**
+   * Moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * ModerationOptOutUnpublishNode constructor.
+   *
+   * @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\content_moderation\ModerationInformationInterface $moderation_info
+   *   The moderation information service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $moderation_info) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->moderationInfo = $moderation_info;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration, $plugin_id, $plugin_definition,
+      $container->get('content_moderation.moderation_information')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function execute($entity = NULL) {
+    if ($entity && $this->moderationInfo->isModeratableEntity($entity)) {
+      drupal_set_message($this->t('One or more entities were skipped as they are under moderation and may not be directly published or unpublished.'));
+      return;
+    }
+
+    parent::execute($entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) {
+    $result = parent::access($object, $account, TRUE)
+      ->andif(AccessResult::forbiddenIf($this->moderationInfo->isModeratableEntity($object))->addCacheableDependency($object));
+
+    return $return_as_object ? $result : $result->isAllowed();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php
new file mode 100644
index 0000000000000000000000000000000000000000..39bfe0def1ffd6ba1d182b8e99da00a6c08ca5b4
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Derivative;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Generates moderation-related local tasks.
+ */
+class DynamicLocalTasks extends DeriverBase implements ContainerDeriverInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The base plugin ID.
+   *
+   * @var string
+   */
+  protected $basePluginId;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * Creates an FieldUiLocalTask object.
+   *
+   * @param string $base_plugin_id
+   *   The base plugin ID.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The translation manager.
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+   *   The moderation information service.
+   */
+  public function __construct($base_plugin_id, EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation, ModerationInformationInterface $moderation_information) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->stringTranslation = $string_translation;
+    $this->basePluginId = $base_plugin_id;
+    $this->moderationInfo = $moderation_information;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, $base_plugin_id) {
+    return new static(
+      $base_plugin_id,
+      $container->get('entity_type.manager'),
+      $container->get('string_translation'),
+      $container->get('content_moderation.moderation_information')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getDerivativeDefinitions($base_plugin_definition) {
+    $this->derivatives = [];
+
+    foreach ($this->moderatableEntityTypeDefinitions() as $entity_type_id => $entity_type) {
+      $this->derivatives["$entity_type_id.moderation_tab"] = [
+        'route_name' => "entity.$entity_type_id.moderation",
+        'title' => $this->t('Manage moderation'),
+        // @todo - are we sure they all have an edit_form?
+        'base_route' => "entity.$entity_type_id.edit_form",
+        'weight' => 30,
+      ] + $base_plugin_definition;
+    }
+
+    $latest_version_entities = array_filter($this->moderatableEntityDefinitions(), function (EntityTypeInterface $type) {
+      return $type->hasLinkTemplate('latest-version');
+    });
+
+    foreach ($latest_version_entities as $entity_type_id => $entity_type) {
+      $this->derivatives["$entity_type_id.latest_version_tab"] = [
+        'route_name' => "entity.$entity_type_id.latest_version",
+        'title' => $this->t('Latest version'),
+        'base_route' => "entity.$entity_type_id.canonical",
+        'weight' => 1,
+      ] + $base_plugin_definition;
+    }
+
+    return $this->derivatives;
+  }
+
+  /**
+   * Returns an array of content entities that are potentially moderatable.
+   *
+   * @return EntityTypeInterface[]
+   *   An array of just those entities we care about.
+   */
+  protected function moderatableEntityDefinitions() {
+    return $this->moderationInfo->selectRevisionableEntities($this->entityTypeManager->getDefinitions());
+  }
+
+  /**
+   * Returns entity types that represent bundles that can be moderated.
+   *
+   * @return EntityTypeInterface[]
+   *   An array of entity types that represent bundles that can be moderated.
+   */
+  protected function moderatableEntityTypeDefinitions() {
+    return $this->moderationInfo->selectRevisionableEntityTypes($this->entityTypeManager->getDefinitions());
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php
new file mode 100644
index 0000000000000000000000000000000000000000..75f3d81ff15e38375b77342584544a0ec457523f
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Field\FieldWidget;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryInterface;
+use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Field\FieldItemListInterface;
+use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsSelectWidget;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\content_moderation\ModerationInformation;
+use Drupal\content_moderation\StateTransitionValidation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Plugin implementation of the 'moderation_state_default' widget.
+ *
+ * @FieldWidget(
+ *   id = "moderation_state_default",
+ *   label = @Translation("Moderation state"),
+ *   field_types = {
+ *     "entity_reference"
+ *   }
+ * )
+ */
+class ModerationStateWidget extends OptionsSelectWidget implements ContainerFactoryPluginInterface {
+
+  /**
+   * Current user service.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $currentUser;
+
+  /**
+   * Moderation state transition entity query.
+   *
+   * @var \Drupal\Core\Entity\Query\QueryInterface
+   */
+  protected $moderationStateTransitionEntityQuery;
+
+  /**
+   * Moderation state storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $moderationStateStorage;
+
+  /**
+   * Moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformation
+   */
+  protected $moderationInformation;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Moderation state transition storage.
+   *
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $moderationStateTransitionStorage;
+
+  /**
+   * Moderation state transition validation service.
+   *
+   * @var \Drupal\content_moderation\StateTransitionValidation
+   */
+  protected $validator;
+
+  /**
+   * Constructs a new ModerationStateWidget object.
+   *
+   * @param string $plugin_id
+   *   Plugin id.
+   * @param mixed $plugin_definition
+   *   Plugin definition.
+   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
+   *   Field definition.
+   * @param array $settings
+   *   Field settings.
+   * @param array $third_party_settings
+   *   Third party settings.
+   * @param \Drupal\Core\Session\AccountInterface $current_user
+   *   Current user service.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   Entity type manager.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $moderation_state_storage
+   *   Moderation state storage.
+   * @param \Drupal\Core\Entity\EntityStorageInterface $moderation_state_transition_storage
+   *   Moderation state transition storage.
+   * @param \Drupal\Core\Entity\Query\QueryInterface $entity_query
+   *   Moderation transition entity query service.
+   * @param \Drupal\content_moderation\ModerationInformation $moderation_information
+   *   Moderation information service.
+   * @param \Drupal\content_moderation\StateTransitionValidation $validator
+   *   Moderation state transition validation service
+   */
+  public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, EntityStorageInterface $moderation_state_storage, EntityStorageInterface $moderation_state_transition_storage, QueryInterface $entity_query, ModerationInformation $moderation_information, StateTransitionValidation $validator) {
+    parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings);
+    $this->moderationStateTransitionEntityQuery = $entity_query;
+    $this->moderationStateTransitionStorage = $moderation_state_transition_storage;
+    $this->moderationStateStorage = $moderation_state_storage;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->currentUser = $current_user;
+    $this->moderationInformation = $moderation_information;
+    $this->validator = $validator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $plugin_id,
+      $plugin_definition,
+      $configuration['field_definition'],
+      $configuration['settings'],
+      $configuration['third_party_settings'],
+      $container->get('current_user'),
+      $container->get('entity_type.manager'),
+      $container->get('entity_type.manager')->getStorage('moderation_state'),
+      $container->get('entity_type.manager')->getStorage('moderation_state_transition'),
+      $container->get('entity.query')->get('moderation_state_transition', 'AND'),
+      $container->get('content_moderation.moderation_information'),
+      $container->get('content_moderation.state_transition_validation')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) {
+    /** @var ContentEntityInterface $entity */
+    $entity = $items->getEntity();
+
+    /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle_entity */
+    $bundle_entity = $this->entityTypeManager->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle());
+    if (!$this->moderationInformation->isModeratableEntity($entity)) {
+      // @todo https://www.drupal.org/node/2779933 write a test for this.
+      return $element + ['#access' => FALSE];
+    }
+
+    $default = $items->get($delta)->value ?: $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state', FALSE);
+    /** @var \Drupal\content_moderation\ModerationStateInterface $default_state */
+    $default_state = $this->entityTypeManager->getStorage('moderation_state')->load($default);
+    if (!$default || !$default_state) {
+      throw new \UnexpectedValueException(sprintf('The %s bundle has an invalid moderation state configuration, moderation states are enabled but no default is set.', $bundle_entity->label()));
+    }
+
+    $transitions = $this->validator->getValidTransitions($entity, $this->currentUser);
+
+    $target_states = [];
+    /** @var \Drupal\content_moderation\Entity\ModerationStateTransition $transition */
+    foreach ($transitions as $transition) {
+      $target_states[$transition->getToState()] = $transition->label();
+    }
+
+    // @todo https://www.drupal.org/node/2779933 write a test for this.
+    $element += [
+      '#access' => FALSE,
+      '#type' => 'select',
+      '#options' => $target_states,
+      '#default_value' => $default,
+      '#published' => $default ? $default_state->isPublishedState() : FALSE,
+      '#key_column' => $this->column,
+    ];
+    $element['#element_validate'][] = array(get_class($this), 'validateElement');
+
+    // Use the dropbutton.
+    $element['#process'][] = [get_called_class(), 'processActions'];
+    return $element;
+  }
+
+  /**
+   * Entity builder updating the node moderation state with the submitted value.
+   *
+   * @param string $entity_type_id
+   *   The entity type identifier.
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity updated with the submitted values.
+   * @param array $form
+   *   The complete form array.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   */
+  public static function updateStatus($entity_type_id, ContentEntityInterface $entity, array $form, FormStateInterface $form_state) {
+    $element = $form_state->getTriggeringElement();
+    if (isset($element['#moderation_state'])) {
+      $entity->moderation_state->target_id = $element['#moderation_state'];
+    }
+  }
+
+  /**
+   * Process callback to alter action buttons.
+   */
+  public static function processActions($element, FormStateInterface $form_state, array &$form) {
+
+    // We'll steal most of the button configuration from the default submit
+    // button. However, NodeForm also hides that button for admins (as it adds
+    // its own, too), so we have to restore it.
+    $default_button = $form['actions']['submit'];
+    $default_button['#access'] = TRUE;
+
+    // Add a custom button for each transition we're allowing. The #dropbutton
+    // property tells FAPI to cluster them all together into a single widget.
+    $options = $element['#options'];
+
+    $entity = $form_state->getFormObject()->getEntity();
+    $translatable = !$entity->isNew() && $entity->isTranslatable();
+    foreach ($options as $id => $label) {
+      $button = [
+        '#dropbutton' => 'save',
+        '#moderation_state' => $id,
+        '#weight' => -10,
+      ];
+
+      $button['#value'] = $translatable
+        ? t('Save and @transition (this translation)', ['@transition' => $label])
+        : t('Save and @transition', ['@transition' => $label]);
+
+      $form['actions']['moderation_state_' . $id] = $button + $default_button;
+    }
+
+    // Hide the default buttons, including the specialty ones added by
+    // NodeForm.
+    foreach (['publish', 'unpublish', 'submit'] as $key) {
+      $form['actions'][$key]['#access'] = FALSE;
+      unset($form['actions'][$key]['#dropbutton']);
+    }
+
+    // Setup a callback to translate the button selection back into field
+    // widget, so that it will get saved properly.
+    $form['#entity_builders']['update_moderation_state'] = [get_called_class(), 'updateStatus'];
+    return $element;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function isApplicable(FieldDefinitionInterface $field_definition) {
+    return parent::isApplicable($field_definition) && $field_definition->getName() === 'moderation_state';
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php
new file mode 100644
index 0000000000000000000000000000000000000000..644a76b41a7f664cd774d29f998ba9c18db444a0
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Field;
+
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\Core\Field\EntityReferenceFieldItemList;
+
+/**
+ * A computed field that provides a content entity's moderation state.
+ *
+ * It links content entities to a moderation state configuration entity via a
+ * moderation state content entity.
+ */
+class ModerationStateFieldItemList extends EntityReferenceFieldItemList {
+
+  /**
+   * Gets the moderation state entity linked to a content entity revision.
+   *
+   * @return \Drupal\content_moderation\ModerationStateInterface|null
+   *   The moderation state configuration entity linked to a content entity
+   *   revision.
+   */
+  protected function getModerationState() {
+    $entity = $this->getEntity();
+
+    if ($entity->id() && $entity->getRevisionId()) {
+      $revisions = \Drupal::service('entity.query')->get('content_moderation_state')
+        ->condition('content_entity_type_id', $entity->getEntityTypeId())
+        ->condition('content_entity_id', $entity->id())
+        ->condition('content_entity_revision_id', $entity->getRevisionId())
+        ->allRevisions()
+        ->sort('revision_id', 'DESC')
+        ->execute();
+
+      if ($revision_to_load = key($revisions)) {
+        /** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */
+        $content_moderation_state = \Drupal::entityTypeManager()
+          ->getStorage('content_moderation_state')
+          ->loadRevision($revision_to_load);
+
+        // Return the correct translation.
+        $langcode = $entity->language()->getId();
+        if (!$content_moderation_state->hasTranslation($langcode)) {
+          $content_moderation_state->addTranslation($langcode);
+        }
+        if ($content_moderation_state->language()->getId() !== $langcode) {
+          $content_moderation_state = $content_moderation_state->getTranslation($langcode);
+        }
+
+        return $content_moderation_state->get('moderation_state')->entity;
+      }
+    }
+    // It is possible that the bundle does not exist at this point. For example,
+    // the node type form creates a fake Node entity to get default values.
+    // @see \Drupal\node\NodeTypeForm::form()
+    $bundle_entity = \Drupal::service('content_moderation.moderation_information')
+      ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
+    if ($bundle_entity && ($default = $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state'))) {
+      return ModerationState::load($default);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get($index) {
+    if ($index !== 0) {
+      throw new \InvalidArgumentException('An entity can not have multiple moderation states at the same time.');
+    }
+    // Compute the value of the moderation state.
+    if (!isset($this->list[$index]) || $this->list[$index]->isEmpty()) {
+      $moderation_state = $this->getModerationState();
+      // Do not store NULL values in the static cache.
+      if ($moderation_state) {
+        $this->list[$index] = $this->createItem($index, ['entity' => $moderation_state]);
+      }
+    }
+
+    return isset($this->list[$index]) ? $this->list[$index] : NULL;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Menu/EditTab.php b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php
new file mode 100644
index 0000000000000000000000000000000000000000..b8ccc2dea1ee062ccae9d94355def5977bbd829c
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Menu/EditTab.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Menu;
+
+use Drupal\Core\Menu\LocalTaskDefault;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\content_moderation\ModerationInformation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a class for making the edit tab use 'Edit draft' or 'New draft'.
+ */
+class EditTab extends LocalTaskDefault implements ContainerFactoryPluginInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The moderation information service.
+   *
+   * @var \Drupal\content_moderation\ModerationInformation
+   */
+  protected $moderationInfo;
+
+  /**
+   * The entity.
+   *
+   * @var \Drupal\Core\Entity\ContentEntityInterface
+   */
+  protected $entity;
+
+  /**
+   * Constructs a new EditTab object.
+   *
+   * @param array $configuration
+   *   Plugin configuration.
+   * @param string $plugin_id
+   *   Plugin ID.
+   * @param mixed $plugin_definition
+   *   Plugin definition.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The translation service.
+   * @param \Drupal\content_moderation\ModerationInformation $moderation_information
+   *   The moderation information.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, TranslationInterface $string_translation, ModerationInformation $moderation_information) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+    $this->stringTranslation = $string_translation;
+    $this->moderationInfo = $moderation_information;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('string_translation'),
+      $container->get('content_moderation.moderation_information')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRouteParameters(RouteMatchInterface $route_match) {
+    // Override the node here with the latest revision.
+    $this->entity = $route_match->getParameter($this->pluginDefinition['entity_type_id']);
+    return parent::getRouteParameters($route_match);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getTitle() {
+    if (!$this->moderationInfo->isModeratableEntity($this->entity)) {
+      // Moderation isn't enabled.
+      return parent::getTitle();
+    }
+
+    // @todo https://www.drupal.org/node/2779933 write a test for this.
+    return $this->moderationInfo->isLiveRevision($this->entity)
+      ? $this->t('New draft')
+      : $this->t('Edit draft');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    // @todo https://www.drupal.org/node/2779933 write a test for this.
+    $tags = parent::getCacheTags();
+    // Tab changes if node or node-type is modified.
+    $tags = array_merge($tags, $this->entity->getCacheTags());
+    $tags[] = $this->entity->getEntityType()->getBundleEntityType() . ':' . $this->entity->bundle();
+    return $tags;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..c2c373f32fb3da8cd9ab65f2db5e2657aa80e62a
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraint.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Verifies that nodes have a valid moderation state.
+ *
+ * @Constraint(
+ *   id = "ModerationState",
+ *   label = @Translation("Valid moderation state", context = "Validation")
+ * )
+ */
+class ModerationStateConstraint extends Constraint {
+
+  public $message = 'Invalid state transition from %from to %to';
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..b401b81b4683c7955e157fb342c06521dec059be
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Validation\Constraint;
+
+use Drupal\content_moderation\Entity\ModerationState as ModerationStateEntity;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Drupal\content_moderation\StateTransitionValidation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+
+/**
+ * Checks if a moderation state transition is valid.
+ */
+class ModerationStateConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+  /**
+   * The state transition validation.
+   *
+   * @var \Drupal\content_moderation\StateTransitionValidation
+   */
+  protected $validation;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  private $entityTypeManager;
+
+  /**
+   * The moderation info.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInformation;
+
+  /**
+   * Creates a new ModerationStateConstraintValidator instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\content_moderation\StateTransitionValidation $validation
+   *   The state transition validation.
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+   *   The moderation information.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, StateTransitionValidation $validation, ModerationInformationInterface $moderation_information) {
+    $this->validation = $validation;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->moderationInformation = $moderation_information;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('entity_type.manager'),
+      $container->get('content_moderation.state_transition_validation'),
+      $container->get('content_moderation.moderation_information')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($value, Constraint $constraint) {
+    /** @var \Drupal\Core\Entity\EntityInterface $entity */
+    $entity = $value->getEntity();
+
+    // Ignore entities that are not subject to moderation anyway.
+    if (!$this->moderationInformation->isModeratableEntity($entity)) {
+      return;
+    }
+
+    // Ignore entities that are being created for the first time.
+    if ($entity->isNew()) {
+      return;
+    }
+
+    // Ignore entities that are being moderated for the first time, such as
+    // when they existed before moderation was enabled for this entity type.
+    if ($this->isFirstTimeModeration($entity)) {
+      return;
+    }
+
+    $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id());
+    if (!$entity->isDefaultTranslation() && $original_entity->hasTranslation($entity->language()->getId())) {
+      $original_entity = $original_entity->getTranslation($entity->language()->getId());
+    }
+
+    if ($entity->moderation_state->target_id) {
+      $new_state_id = $entity->moderation_state->target_id;
+    }
+    else {
+      $new_state_id = $default = $this->moderationInformation
+        ->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle())
+        ->getThirdPartySetting('content_moderation', 'default_moderation_state');
+    }
+    if ($new_state_id) {
+      $new_state = ModerationStateEntity::load($new_state_id);
+    }
+    // @todo - what if $new_state_id references something that does not exist or
+    //   is null.
+    if (!$this->validation->isTransitionAllowed($original_entity->moderation_state->entity, $new_state)) {
+      $this->context->addViolation($constraint->message, ['%from' => $original_entity->moderation_state->entity->label(), '%to' => $new_state->label()]);
+    }
+  }
+
+  /**
+   * Determines if this entity is being moderated for the first time.
+   *
+   * If the previous version of the entity has no moderation state, we assume
+   * that means it predates the presence of moderation states.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity being moderated.
+   *
+   * @return bool
+   *   TRUE if this is the entity's first time being moderated, FALSE otherwise.
+   */
+  protected function isFirstTimeModeration(EntityInterface $entity) {
+    $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id());
+
+    $original_id = $original_entity->moderation_state->target_id;
+
+    return !($entity->moderation_state->target_id && $original_entity && $original_id);
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php
new file mode 100644
index 0000000000000000000000000000000000000000..64400197ecb1ae1239768e11ecb3f5ecbf5e6416
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\views\filter;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\views\Plugin\views\filter\FilterPluginBase;
+use Drupal\views\Plugin\ViewsHandlerManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Filter to show only the latest revision of an entity.
+ *
+ * @ingroup views_filter_handlers
+ *
+ * @ViewsFilter("latest_revision")
+ */
+class LatestRevision extends FilterPluginBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * Entity Type Manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Views Handler Plugin Manager.
+   *
+   * @var \Drupal\views\Plugin\ViewsHandlerManager
+   */
+  protected $joinHandler;
+
+  /**
+   * Database Connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  /**
+   * Constructs a new LatestRevision.
+   *
+   * @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
+   *   Entity Type Manager Service.
+   * @param \Drupal\views\Plugin\ViewsHandlerManager $join_handler
+   *   Views Handler Plugin Manager.
+   * @param \Drupal\Core\Database\Connection $connection
+   *   Database Connection.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, ViewsHandlerManager $join_handler, Connection $connection) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->entityTypeManager = $entity_type_manager;
+    $this->joinHandler = $join_handler;
+    $this->connection = $connection;
+  }
+
+  /**
+   * {@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('plugin.manager.views.join'),
+      $container->get('database')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function adminSummary() {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function operatorForm(&$form, FormStateInterface $form_state) {
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function canExpose() {
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query() {
+    // The table doesn't exist until a moderated node has been saved at least
+    // once. Just in case, disable this filter until then. Note that this means
+    // the view will still show all revisions, not just latest, but this is
+    // sufficiently edge-case-y that it's probably not worth the time to
+    // handle more robustly.
+    if (!$this->connection->schema()->tableExists('content_revision_tracker')) {
+      return;
+    }
+
+    $table = $this->ensureMyTable();
+
+    /** @var \Drupal\views\Plugin\views\query\Sql $query */
+    $query = $this->query;
+
+    $definition = $this->entityTypeManager->getDefinition($this->getEntityType());
+    $keys = $definition->getKeys();
+
+    $definition = [
+      'table' => 'content_revision_tracker',
+      'type' => 'INNER',
+      'field' => 'entity_id',
+      'left_table' => $table,
+      'left_field' => $keys['id'],
+      'extra' => [
+        ['left_field' => $keys['langcode'], 'field' => 'langcode'],
+        ['left_field' => $keys['revision'], 'field' => 'revision_id'],
+        ['field' => 'entity_type', 'value' => $this->getEntityType()],
+      ],
+    ];
+
+    $join = $this->joinHandler->createInstance('standard', $definition);
+
+    $query->ensureTable('content_revision_tracker', $this->relationship, $join);
+  }
+
+}
diff --git a/core/modules/content_moderation/src/RevisionTracker.php b/core/modules/content_moderation/src/RevisionTracker.php
new file mode 100644
index 0000000000000000000000000000000000000000..201123784337bd80ea1c4f7eabfb123e0224c9be
--- /dev/null
+++ b/core/modules/content_moderation/src/RevisionTracker.php
@@ -0,0 +1,152 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\DatabaseExceptionWrapper;
+use Drupal\Core\Database\SchemaObjectExistsException;
+
+/**
+ * Tracks metadata about revisions across entities.
+ */
+class RevisionTracker implements RevisionTrackerInterface {
+
+  /**
+   * The name of the SQL table we use for tracking.
+   *
+   * @var string
+   */
+  protected $tableName;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  /**
+   * Constructs a new RevisionTracker.
+   *
+   * @param \Drupal\Core\Database\Connection $connection
+   *   The database connection.
+   * @param string $table
+   *   The table that should be used for tracking.
+   */
+  public function __construct(Connection $connection, $table = 'content_revision_tracker') {
+    $this->connection = $connection;
+    $this->tableName = $table;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id) {
+    try {
+      $this->recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
+    }
+    catch (DatabaseExceptionWrapper $e) {
+      $this->ensureTableExists();
+      $this->recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
+    }
+
+    return $this;
+  }
+
+  /**
+   * Records the latest revision of a given entity.
+   *
+   * @param string $entity_type_id
+   *   The machine name of the type of entity.
+   * @param string $entity_id
+   *   The Entity ID in question.
+   * @param string $langcode
+   *   The langcode of the revision we're saving. Each language has its own
+   *   effective tree of entity revisions, so in different languages
+   *   different revisions will be "latest".
+   * @param int $revision_id
+   *   The revision ID that is now the latest revision.
+   *
+   * @return int
+   *   One of the valid returns from a merge query's execute method.
+   */
+  protected function recordLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id) {
+    return $this->connection->merge($this->tableName)
+      ->keys([
+        'entity_type' => $entity_type_id,
+        'entity_id' => $entity_id,
+        'langcode' => $langcode,
+      ])
+      ->fields([
+        'revision_id' => $revision_id,
+      ])
+      ->execute();
+  }
+
+  /**
+   * Checks if the table exists and create it if not.
+   *
+   * @return bool
+   *   TRUE if the table was created, FALSE otherwise.
+   */
+  protected function ensureTableExists() {
+    try {
+      if (!$this->connection->schema()->tableExists($this->tableName)) {
+        $this->connection->schema()->createTable($this->tableName, $this->schemaDefinition());
+        return TRUE;
+      }
+    }
+    catch (SchemaObjectExistsException $e) {
+      // If another process has already created the table, attempting to
+      // recreate it will throw an exception. In this case just catch the
+      // exception and do nothing.
+      return TRUE;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Defines the schema for the tracker table.
+   *
+   * @return array
+   *   The schema API definition for the SQL storage table.
+   */
+  protected function schemaDefinition() {
+    $schema = [
+      'description' => 'Tracks the latest revision for any entity',
+      'fields' => [
+        'entity_type' => [
+          'description' => 'The entity type',
+          'type' => 'varchar_ascii',
+          'length' => 255,
+          'not null' => TRUE,
+          'default' => '',
+        ],
+        'entity_id' => [
+          'description' => 'The entity ID',
+          'type' => 'int',
+          'length' => 255,
+          'not null' => TRUE,
+          'default' => 0,
+        ],
+        'langcode' => [
+          'description' => 'The language of the entity revision',
+          'type' => 'varchar',
+          'length' => 12,
+          'not null' => TRUE,
+          'default' => '',
+        ],
+        'revision_id' => [
+          'description' => 'The latest revision ID for this entity',
+          'type' => 'int',
+          'not null' => TRUE,
+          'default' => 0,
+        ],
+      ],
+      'primary key' => ['entity_type', 'entity_id', 'langcode'],
+    ];
+
+    return $schema;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/RevisionTrackerInterface.php b/core/modules/content_moderation/src/RevisionTrackerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..2b7cf95156580ee32505c68351cac22f1944fdaa
--- /dev/null
+++ b/core/modules/content_moderation/src/RevisionTrackerInterface.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+/**
+ * Tracks metadata about revisions across content entities.
+ */
+interface RevisionTrackerInterface {
+
+  /**
+   * Sets the latest revision of a given entity.
+   *
+   * @param string $entity_type_id
+   *   The machine name of the type of entity.
+   * @param string $entity_id
+   *   The Entity ID in question.
+   * @param string $langcode
+   *   The langcode of the revision we're saving. Each language has its own
+   *   effective tree of entity revisions, so in different languages
+   *   different revisions will be "latest".
+   * @param int $revision_id
+   *   The revision ID that is now the latest revision.
+   *
+   * @return static
+   */
+  public function setLatestRevision($entity_type_id, $entity_id, $langcode, $revision_id);
+
+}
diff --git a/core/modules/content_moderation/src/Routing/EntityModerationRouteProvider.php b/core/modules/content_moderation/src/Routing/EntityModerationRouteProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..f953d803062e27d84261ba8ab977decfc70e8559
--- /dev/null
+++ b/core/modules/content_moderation/src/Routing/EntityModerationRouteProvider.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Drupal\content_moderation\Routing;
+
+use Drupal\Core\Entity\EntityFieldManagerInterface;
+use Drupal\Core\Entity\EntityHandlerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Dynamic route provider for the Content moderation module.
+ *
+ * Provides the following routes:
+ * - The latest version tab, showing the latest revision of an entity, not the
+ *   default one.
+ */
+class EntityModerationRouteProvider implements EntityRouteProviderInterface, EntityHandlerInterface {
+
+  /**
+   * The entity manager.
+   *
+   * @var \Drupal\Core\Entity\EntityFieldManagerInterface
+   */
+  protected $entityFieldManager;
+
+  /**
+   * Constructs a new DefaultHtmlRouteProvider.
+   *
+   * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_manager
+   *   The entity manager.
+   */
+  public function __construct(EntityFieldManagerInterface $entity_manager) {
+    $this->entityFieldManager = $entity_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static(
+      $container->get('entity_field.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRoutes(EntityTypeInterface $entity_type) {
+    $collection = new RouteCollection();
+
+    if ($moderation_route = $this->getLatestVersionRoute($entity_type)) {
+      $entity_type_id = $entity_type->id();
+      $collection->add("entity.{$entity_type_id}.latest_version", $moderation_route);
+    }
+
+    return $collection;
+  }
+
+  /**
+   * Gets the moderation-form route.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type.
+   *
+   * @return \Symfony\Component\Routing\Route|null
+   *   The generated route, if available.
+   */
+  protected function getLatestVersionRoute(EntityTypeInterface $entity_type) {
+    if ($entity_type->hasLinkTemplate('latest-version') && $entity_type->hasViewBuilderClass()) {
+      $entity_type_id = $entity_type->id();
+      $route = new Route($entity_type->getLinkTemplate('latest-version'));
+      $route
+        ->addDefaults([
+          '_entity_view' => "{$entity_type_id}.full",
+          '_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::title',
+        ])
+        // If the entity type is a node, unpublished content will be visible
+        // if the user has the "view all unpublished content" permission.
+        ->setRequirement('_entity_access', "{$entity_type_id}.view")
+        ->setRequirement('_permission', 'view latest version,view any unpublished content')
+        ->setRequirement('_content_moderation_latest_version', 'TRUE')
+        ->setOption('_content_moderation_entity_type', $entity_type_id)
+        ->setOption('parameters', [
+          $entity_type_id => [
+            'type' => 'entity:' . $entity_type_id,
+            'load_forward_revision' => 1,
+          ],
+        ]);
+
+      // Entity types with serial IDs can specify this in their route
+      // requirements, improving the matching process.
+      if ($this->getEntityTypeIdKeyType($entity_type) === 'integer') {
+        $route->setRequirement($entity_type_id, '\d+');
+      }
+      return $route;
+    }
+  }
+
+  /**
+   * Gets the type of the ID key for a given entity type.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   An entity type.
+   *
+   * @return string|null
+   *   The type of the ID key for a given entity type, or NULL if the entity
+   *   type does not support fields.
+   */
+  protected function getEntityTypeIdKeyType(EntityTypeInterface $entity_type) {
+    if (!$entity_type->isSubclassOf(FieldableEntityInterface::class)) {
+      return NULL;
+    }
+
+    $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type->id());
+    return $field_storage_definitions[$entity_type->getKey('id')]->getType();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..c722a67ee110ddba8e30ab2f1af47cd7abd18597
--- /dev/null
+++ b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\content_moderation\Routing;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\Routing\EntityRouteProviderInterface;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Provides the moderation configuration routes for config entities.
+ */
+class EntityTypeModerationRouteProvider implements EntityRouteProviderInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRoutes(EntityTypeInterface $entity_type) {
+    $collection = new RouteCollection();
+
+    if ($moderation_route = $this->getModerationFormRoute($entity_type)) {
+      $entity_type_id = $entity_type->id();
+      $collection->add("entity.{$entity_type_id}.moderation", $moderation_route);
+    }
+
+    return $collection;
+  }
+
+  /**
+   * Gets the moderation-form route.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type.
+   *
+   * @return \Symfony\Component\Routing\Route|null
+   *   The generated route, if available.
+   */
+  protected function getModerationFormRoute(EntityTypeInterface $entity_type) {
+    if ($entity_type->hasLinkTemplate('moderation-form') && $entity_type->getFormClass('moderation')) {
+      $entity_type_id = $entity_type->id();
+
+      $route = new Route($entity_type->getLinkTemplate('moderation-form'));
+
+      // @todo Come up with a new permission.
+      $route
+        ->setDefaults([
+          '_entity_form' => "{$entity_type_id}.moderation",
+          '_title' => 'Moderation',
+        ])
+        ->setRequirement('_permission', 'administer moderation states')
+        ->setOption('parameters', [
+          $entity_type_id => ['type' => 'entity:' . $entity_type_id],
+        ]);
+
+      return $route;
+    }
+  }
+
+}
diff --git a/core/modules/content_moderation/src/StateTransitionValidation.php b/core/modules/content_moderation/src/StateTransitionValidation.php
new file mode 100644
index 0000000000000000000000000000000000000000..2e2a4e24506e6c291d4d730f26630af542594cad
--- /dev/null
+++ b/core/modules/content_moderation/src/StateTransitionValidation.php
@@ -0,0 +1,247 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryFactory;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+
+/**
+ * Validates whether a certain state transition is allowed.
+ */
+class StateTransitionValidation implements StateTransitionValidationInterface {
+
+  /**
+   * Entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * Entity query factory.
+   *
+   * @var \Drupal\Core\Entity\Query\QueryFactory
+   */
+  protected $queryFactory;
+
+  /**
+   * Stores the possible state transitions.
+   *
+   * @var array
+   */
+  protected $possibleTransitions = [];
+
+  /**
+   * Constructs a new StateTransitionValidation.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager service.
+   * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory
+   *   The entity query factory.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, QueryFactory $query_factory) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->queryFactory = $query_factory;
+  }
+
+  /**
+   * Computes a mapping of possible transitions.
+   *
+   * This method is uncached and will recalculate the list on every request.
+   * In most cases you want to use getPossibleTransitions() instead.
+   *
+   * @see static::getPossibleTransitions()
+   *
+   * @return array[]
+   *   An array containing all possible transitions. Each entry is keyed by the
+   *   "from" state, and the value is an array of all legal "to" states based
+   *   on the currently defined transition objects.
+   */
+  protected function calculatePossibleTransitions() {
+    $transitions = $this->transitionStorage()->loadMultiple();
+
+    $possible_transitions = [];
+    /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */
+    foreach ($transitions as $transition) {
+      $possible_transitions[$transition->getFromState()][] = $transition->getToState();
+    }
+    return $possible_transitions;
+  }
+
+  /**
+   * Returns a mapping of possible transitions.
+   *
+   * @return array[]
+   *   An array containing all possible transitions. Each entry is keyed by the
+   *   "from" state, and the value is an array of all legal "to" states based
+   *   on the currently defined transition objects.
+   */
+  protected function getPossibleTransitions() {
+    if (empty($this->possibleTransitions)) {
+      $this->possibleTransitions = $this->calculatePossibleTransitions();
+    }
+    return $this->possibleTransitions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user) {
+    $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
+
+    $states_for_bundle = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []);
+
+    /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
+    $current_state = $entity->moderation_state->entity;
+
+    $all_transitions = $this->getPossibleTransitions();
+    $destination_ids = $all_transitions[$current_state->id()];
+
+    $destination_ids = array_intersect($states_for_bundle, $destination_ids);
+    $destinations = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple($destination_ids);
+
+    return array_filter($destinations, function(ModerationStateInterface $destination_state) use ($current_state, $user) {
+      return $this->userMayTransition($current_state, $destination_state, $user);
+    });
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) {
+    $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
+
+    /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */
+    $current_state = $entity->moderation_state->entity;
+    $current_state_id = $current_state ? $current_state->id() : $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state');
+
+    // Determine the states that are legal on this bundle.
+    $legal_bundle_states = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []);
+
+    // Legal transitions include those that are possible from the current state,
+    // filtered by those whose target is legal on this bundle and that the
+    // user has access to execute.
+    $transitions = array_filter($this->getTransitionsFrom($current_state_id), function(ModerationStateTransition $transition) use ($legal_bundle_states, $user) {
+      return in_array($transition->getToState(), $legal_bundle_states, TRUE)
+        && $user->hasPermission('use ' . $transition->id() . ' transition');
+    });
+
+    return $transitions;
+  }
+
+  /**
+   * Returns a list of possible transitions from a given state.
+   *
+   * This list is based only on those transitions that exist, not what
+   * transitions are legal in a given context.
+   *
+   * @param string $state_name
+   *   The machine name of the state from which we are transitioning.
+   *
+   * @return ModerationStateTransition[]
+   *   A list of possible transitions from a given state.
+   */
+  protected function getTransitionsFrom($state_name) {
+    $result = $this->transitionStateQuery()
+      ->condition('stateFrom', $state_name)
+      ->sort('weight')
+      ->execute();
+
+    return $this->transitionStorage()->loadMultiple($result);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user) {
+    if ($transition = $this->getTransitionFromStates($from, $to)) {
+      return $user->hasPermission('use ' . $transition->id() . ' transition');
+    }
+    return FALSE;
+  }
+
+  /**
+   * Returns the transition object that transitions from one state to another.
+   *
+   * @param \Drupal\content_moderation\ModerationStateInterface $from
+   *   The origin state.
+   * @param \Drupal\content_moderation\ModerationStateInterface $to
+   *   The destination state.
+   *
+   * @return ModerationStateTransition|null
+   *   A transition object, or NULL if there is no such transition.
+   */
+  protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) {
+    $from = $this->transitionStateQuery()
+      ->condition('stateFrom', $from->id())
+      ->condition('stateTo', $to->id())
+      ->execute();
+
+    $transitions = $this->transitionStorage()->loadMultiple($from);
+
+    if ($transitions) {
+      return current($transitions);
+    }
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to) {
+    $allowed_transitions = $this->calculatePossibleTransitions();
+    if (isset($allowed_transitions[$from->id()])) {
+      return in_array($to->id(), $allowed_transitions[$from->id()], TRUE);
+    }
+    return FALSE;
+  }
+
+  /**
+   * Returns a transition state entity query.
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   A transition state entity query.
+   */
+  protected function transitionStateQuery() {
+    return $this->queryFactory->get('moderation_state_transition', 'AND');
+  }
+
+  /**
+   * Returns the transition entity storage service.
+   *
+   * @return \Drupal\Core\Entity\EntityStorageInterface
+   *   The transition state entity storage.
+   */
+  protected function transitionStorage() {
+    return $this->entityTypeManager->getStorage('moderation_state_transition');
+  }
+
+  /**
+   * Returns the state entity storage service.
+   *
+   * @return \Drupal\Core\Entity\EntityStorageInterface
+   *   The moderation state entity storage.
+   */
+  protected function stateStorage() {
+    return $this->entityTypeManager->getStorage('moderation_state');
+  }
+
+  /**
+   * Loads a specific bundle entity.
+   *
+   * @param string $bundle_entity_type_id
+   *   The bundle entity type ID.
+   * @param string $bundle_id
+   *   The bundle ID.
+   *
+   * @return \Drupal\Core\Config\Entity\ConfigEntityInterface|null
+   *   The specific bundle entity.
+   */
+  protected function loadBundleEntity($bundle_entity_type_id, $bundle_id) {
+    return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id);
+  }
+
+}
diff --git a/core/modules/content_moderation/src/StateTransitionValidationInterface.php b/core/modules/content_moderation/src/StateTransitionValidationInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..5ef0dd17d24b5f876509e442b249b328b47225ce
--- /dev/null
+++ b/core/modules/content_moderation/src/StateTransitionValidationInterface.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Session\AccountInterface;
+
+/**
+ * Validates whether a certain state transition is allowed.
+ */
+interface StateTransitionValidationInterface {
+
+  /**
+   * Gets a list of states a user may transition an entity to.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity to be transitioned.
+   * @param \Drupal\Core\Session\AccountInterface $user
+   *   The account that wants to perform a transition.
+   *
+   * @return \Drupal\content_moderation\Entity\ModerationState[]
+   *   Returns an array of States to which the specified user may transition the
+   *   entity.
+   */
+  public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user);
+
+  /**
+   * Gets a list of transitions that are legal for this user on this entity.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity to be transitioned.
+   * @param \Drupal\Core\Session\AccountInterface $user
+   *   The account that wants to perform a transition.
+   *
+   * @return \Drupal\content_moderation\Entity\ModerationStateTransition[]
+   *   The list of transitions that are legal for this user on this entity.
+   */
+  public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user);
+
+  /**
+   * Determines if a user is allowed to transition from one state to another.
+   *
+   * This method will also return FALSE if there is no transition between the
+   * specified states at all.
+   *
+   * @param \Drupal\content_moderation\ModerationStateInterface $from
+   *   The origin state.
+   * @param \Drupal\content_moderation\ModerationStateInterface $to
+   *   The destination state.
+   * @param \Drupal\Core\Session\AccountInterface $user
+   *   The user to validate.
+   *
+   * @return bool
+   *   TRUE if the given user may transition between those two states.
+   */
+  public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user);
+
+  /**
+   * Determines a transition allowed.
+   *
+   * @param \Drupal\content_moderation\ModerationStateInterface $from
+   *   The origin state.
+   * @param \Drupal\content_moderation\ModerationStateInterface $to
+   *   The destination state.
+   *
+   * @return bool
+   *   Is the transition allowed.
+   */
+  public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to);
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationFormTest.php b/core/modules/content_moderation/src/Tests/ModerationFormTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d6c92b96a8a96d2d21d3d2f0aad20cd829b424b5
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationFormTest.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Tests the moderation form, specifically on nodes.
+ *
+ * @group content_moderation
+ */
+class ModerationFormTest extends ModerationStateTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->drupalLogin($this->adminUser);
+    $this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE, [
+      'draft',
+      'published',
+    ], 'draft');
+    $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
+  }
+
+  /**
+   * Tests the moderation form that shows on the latest version page.
+   *
+   * The latest version page only shows if there is a forward revision. There
+   * is only a forward revision if a draft revision is created on a node where
+   * the default revision is not a published moderation state.
+   *
+   * @see \Drupal\content_moderation\EntityOperations
+   * @see \Drupal\content_moderation\Tests\ModerationStateBlockTest::testCustomBlockModeration
+   */
+  public function testModerationForm() {
+    // Create new moderated content in draft.
+    $this->drupalPostForm('node/add/moderated_content', [
+      'title[0][value]' => 'Some moderated content',
+      'body[0][value]' => 'First version of the content.',
+    ], t('Save and Create New Draft'));
+
+    $node = $this->drupalGetNodeByTitle('Some moderated content');
+    $canonical_path = sprintf('node/%d', $node->id());
+    $edit_path = sprintf('node/%d/edit', $node->id());
+    $latest_version_path = sprintf('node/%d/latest', $node->id());
+
+    $this->assertTrue($this->adminUser->hasPermission('edit any moderated_content content'));
+
+    // The latest version page should not show, because there is no forward
+    // revision.
+    $this->drupalGet($latest_version_path);
+    $this->assertResponse(403);
+
+    // Update the draft.
+    $this->drupalPostForm($edit_path, [
+      'body[0][value]' => 'Second version of the content.',
+    ], t('Save and Create New Draft'));
+
+    // The latest version page should not show, because there is still no
+    // forward revision.
+    $this->drupalGet($latest_version_path);
+    $this->assertResponse(403);
+
+    // Publish the draft.
+    $this->drupalPostForm($edit_path, [
+      'body[0][value]' => 'Third version of the content.',
+    ], t('Save and Publish'));
+
+    // The published view should not have a moderation form, because it is the
+    // default revision.
+    $this->drupalGet($canonical_path);
+    $this->assertResponse(200);
+    $this->assertNoText('Status', 'The node view page has no moderation form.');
+
+    // The latest version page should not show, because there is still no
+    // forward revision.
+    $this->drupalGet($latest_version_path);
+    $this->assertResponse(403);
+
+    // Make a forward revision.
+    $this->drupalPostForm($edit_path, [
+      'body[0][value]' => 'Fourth version of the content.',
+    ], t('Save and Create New Draft'));
+
+    // The published view should not have a moderation form, because it is the
+    // default revision.
+    $this->drupalGet($canonical_path);
+    $this->assertResponse(200);
+    $this->assertNoText('Status', 'The node view page has no moderation form.');
+
+    // The latest version page should show the moderation form and have "Draft"
+    // status, because the forward revision is in "Draft".
+    $this->drupalGet($latest_version_path);
+    $this->assertResponse(200);
+    $this->assertText('Status', 'Form text found on the latest-version page.');
+    $this->assertText('Draft', 'Correct status found on the latest-version page.');
+
+    // Submit the moderation form to change status to published.
+    $this->drupalPostForm($latest_version_path, [
+      'new_state' => 'published',
+    ], t('Apply'));
+
+    // The latest version page should not show, because there is no
+    // forward revision.
+    $this->drupalGet($latest_version_path);
+    $this->assertResponse(403);
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0d403563664f83dcfa3f9f4f06d050c46797c15f
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php
@@ -0,0 +1,221 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Test content_moderation functionality with localization and translation.
+ *
+ * @group content_moderation
+ */
+class ModerationLocaleTest extends ModerationStateTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'node',
+    'content_moderation',
+    'locale',
+    'content_translation',
+  ];
+
+  /**
+   * Tests article translations can be moderated separately.
+   */
+  public function testTranslateModeratedContent() {
+    $this->drupalLogin($this->rootUser);
+
+    // Enable moderation on Article node type.
+    $this->createContentTypeFromUi(
+      'Article',
+      'article',
+      TRUE,
+      ['draft', 'published', 'archived'],
+      'draft'
+    );
+
+    // Add French language.
+    $edit = [
+      'predefined_langcode' => 'fr',
+    ];
+    $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add language'));
+
+    // Enable content translation on articles.
+    $this->drupalGet('admin/config/regional/content-language');
+    $edit = [
+      'entity_types[node]' => TRUE,
+      'settings[node][article][translatable]' => TRUE,
+      'settings[node][article][settings][language][language_alterable]' => TRUE,
+    ];
+    $this->drupalPostForm(NULL, $edit, t('Save configuration'));
+
+    // Adding languages requires a container rebuild in the test running
+    // environment so that multilingual services are used.
+    $this->rebuildContainer();
+
+    // Create a published article in English.
+    $edit = [
+      'title[0][value]' => 'Published English node',
+      'langcode[0][value]' => 'en',
+    ];
+    $this->drupalPostForm('node/add/article', $edit, t('Save and Publish'));
+    $this->assertText(t('Article Published English node has been created.'));
+    $english_node = $this->drupalGetNodeByTitle('Published English node');
+
+    // Add a French translation.
+    $this->drupalGet('node/' . $english_node->id() . '/translations');
+    $this->clickLink(t('Add'));
+    $edit = [
+      'title[0][value]' => 'French node Draft',
+    ];
+    $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
+    // Here the error has occurred "The website encountered an unexpected error.
+    // Please try again later."
+    // If the translation has got lost.
+    $this->assertText(t('Article French node Draft has been updated.'));
+
+    // Create an article in English.
+    $edit = [
+      'title[0][value]' => 'English node',
+      'langcode[0][value]' => 'en',
+    ];
+    $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
+    $this->assertText(t('Article English node has been created.'));
+    $english_node = $this->drupalGetNodeByTitle('English node');
+
+    // Add a French translation.
+    $this->drupalGet('node/' . $english_node->id() . '/translations');
+    $this->clickLink(t('Add'));
+    $edit = [
+      'title[0][value]' => 'French node',
+    ];
+    $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
+    $this->assertText(t('Article French node has been updated.'));
+    $english_node = $this->drupalGetNodeByTitle('English node', TRUE);
+
+    // Publish the English article and check that the translation stays
+    // unpublished.
+    $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
+    $this->assertText(t('Article English node has been updated.'));
+    $english_node = $this->drupalGetNodeByTitle('English node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+    $this->assertEqual('French node', $french_node->label());
+
+    $this->assertEqual($english_node->moderation_state->target_id, 'published');
+    $this->assertTrue($english_node->isPublished());
+    $this->assertEqual($french_node->moderation_state->target_id, 'draft');
+    $this->assertFalse($french_node->isPublished());
+
+    // Create another article with its translation. This time we will publish
+    // the translation first.
+    $edit = [
+      'title[0][value]' => 'Another node',
+    ];
+    $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
+    $this->assertText(t('Article Another node has been created.'));
+    $english_node = $this->drupalGetNodeByTitle('Another node');
+
+    // Add a French translation.
+    $this->drupalGet('node/' . $english_node->id() . '/translations');
+    $this->clickLink(t('Add'));
+    $edit = [
+      'title[0][value]' => 'Translated node',
+    ];
+    $this->drupalPostForm(NULL, $edit, t('Save and Create New Draft (this translation)'));
+    $this->assertText(t('Article Translated node has been updated.'));
+    $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+
+    // Publish the translation and check that the source language version stays
+    // unpublished.
+    $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
+    $this->assertText(t('Article Translated node has been updated.'));
+    $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+    $this->assertEqual($french_node->moderation_state->target_id, 'published');
+    $this->assertTrue($french_node->isPublished());
+    $this->assertEqual($english_node->moderation_state->target_id, 'draft');
+    $this->assertFalse($english_node->isPublished());
+
+    // Now check that we can create a new draft of the translation.
+    $edit = [
+      'title[0][value]' => 'New draft of translated node',
+    ];
+    $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', $edit, t('Save and Create New Draft (this translation)'));
+    $this->assertText(t('Article New draft of translated node has been updated.'));
+    $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+    $this->assertEqual($french_node->moderation_state->target_id, 'published');
+    $this->assertTrue($french_node->isPublished());
+    $this->assertEqual($french_node->getTitle(), 'Translated node', 'The default revision of the published translation remains the same.');
+
+    // Publish the draft.
+    $edit = [
+      'new_state' => 'published',
+    ];
+    $this->drupalPostForm('fr/node/' . $english_node->id() . '/latest', $edit, t('Apply'));
+    $this->assertText(t('The moderation state has been updated.'));
+    $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+    $this->assertEqual($french_node->moderation_state->target_id, 'published');
+    $this->assertTrue($french_node->isPublished());
+    $this->assertEqual($french_node->getTitle(), 'New draft of translated node', 'The draft has replaced the published revision.');
+
+    // Publish the English article before testing the archive transition.
+    $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
+    $this->assertText(t('Article Another node has been updated.'));
+    $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+    $this->assertEqual($english_node->moderation_state->target_id, 'published');
+
+    // Archive the node and its translation.
+    $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)'));
+    $this->assertText(t('Article Another node has been updated.'));
+    $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)'));
+    $this->assertText(t('Article New draft of translated node has been updated.'));
+    $english_node = $this->drupalGetNodeByTitle('Another node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+    $this->assertEqual($english_node->moderation_state->target_id, 'archived');
+    $this->assertFalse($english_node->isPublished());
+    $this->assertEqual($french_node->moderation_state->target_id, 'archived');
+    $this->assertFalse($french_node->isPublished());
+
+    // Create another article with its translation. This time publishing english
+    // after creating a forward french revision.
+    $edit = [
+      'title[0][value]' => 'An english node',
+    ];
+    $this->drupalPostForm('node/add/article', $edit, t('Save and Create New Draft'));
+    $this->assertText(t('Article An english node has been created.'));
+    $english_node = $this->drupalGetNodeByTitle('An english node');
+    $this->assertFalse($english_node->isPublished());
+
+    // Add a French translation.
+    $this->drupalGet('node/' . $english_node->id() . '/translations');
+    $this->clickLink(t('Add'));
+    $edit = [
+      'title[0][value]' => 'A french node',
+    ];
+    $this->drupalPostForm(NULL, $edit, t('Save and Publish (this translation)'));
+    $english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+    $this->assertTrue($french_node->isPublished());
+    $this->assertFalse($english_node->isPublished());
+
+    // Create a forward revision
+    $this->drupalPostForm('fr/node/' . $english_node->id() . '/edit', [], t('Save and Create New Draft (this translation)'));
+    $english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+    $this->assertTrue($french_node->isPublished());
+    $this->assertFalse($english_node->isPublished());
+
+    // Publish the english node and the default french node not the latest
+    // french node should be used.
+    $this->drupalPostForm('/node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)'));
+    $english_node = $this->drupalGetNodeByTitle('An english node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+    $this->assertTrue($french_node->isPublished());
+    $this->assertTrue($english_node->isPublished());
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..001d6b55ffad1e3304f365d192ea3da1c447717d
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\block_content\Entity\BlockContentType;
+
+/**
+ * Tests general content moderation workflow for blocks.
+ *
+ * @group content_moderation
+ */
+class ModerationStateBlockTest extends ModerationStateTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    // Create the "basic" block type.
+    $bundle = BlockContentType::create([
+      'id' => 'basic',
+      'label' => 'basic',
+      'revision' => FALSE,
+    ]);
+    $bundle->save();
+
+    // Add the body field to it.
+    block_content_add_body_field($bundle->id());
+  }
+
+  /**
+   * Tests moderating custom blocks.
+   *
+   * Blocks and any non-node-type-entities do not have a concept of
+   * "published". As such, we must use the "default revision" to know what is
+   * going to be "published", i.e. visible to the user.
+   *
+   * The one exception is a block that has never been "published". When a block
+   * is first created, it becomes the "default revision". For each edit of the
+   * block after that, Content Moderation checks the "default revision" to
+   * see if it is set to a published moderation state. If it is not, the entity
+   * being saved will become the "default revision".
+   *
+   * The test below is intended, in part, to make this behavior clear.
+   *
+   * @see \Drupal\content_moderation\EntityOperations::entityPresave
+   * @see \Drupal\content_moderation\Tests\ModerationFormTest::testModerationForm
+   */
+  public function testCustomBlockModeration() {
+    $this->drupalLogin($this->rootUser);
+
+    // Enable moderation for custom blocks at
+    // admin/structure/block/block-content/manage/basic/moderation.
+    $edit = [
+      'enable_moderation_state' => TRUE,
+      'allowed_moderation_states_unpublished[draft]' => TRUE,
+      'allowed_moderation_states_published[published]' => TRUE,
+      'default_moderation_state' => 'draft',
+    ];
+    $this->drupalPostForm('admin/structure/block/block-content/manage/basic/moderation', $edit, t('Save'));
+    $this->assertText(t('Your settings have been saved.'));
+
+    // Create a custom block at block/add and save it as draft.
+    $body = 'Body of moderated block';
+    $edit = [
+      'info[0][value]' => 'Moderated block',
+      'body[0][value]' => $body,
+    ];
+    $this->drupalPostForm('block/add', $edit, t('Save and Create New Draft'));
+    $this->assertText(t('basic Moderated block has been created.'));
+
+    // Place the block in the Sidebar First region.
+    $instance = array(
+      'id' => 'moderated_block',
+      'settings[label]' => $edit['info[0][value]'],
+      'region' => 'sidebar_first',
+    );
+    $block = BlockContent::load(1);
+    $url = 'admin/structure/block/add/block_content:' . $block->uuid() . '/' . $this->config('system.theme')->get('default');
+    $this->drupalPostForm($url, $instance, t('Save block'));
+
+    // Navigate to home page and check that the block is visible. It should be
+    // visible because it is the default revision.
+    $this->drupalGet('');
+    $this->assertText($body);
+
+    // Update the block.
+    $updated_body = 'This is the new body value';
+    $edit = [
+      'body[0][value]' => $updated_body,
+    ];
+    $this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft'));
+    $this->assertText(t('basic Moderated block has been updated.'));
+
+    // Navigate to the home page and check that the block shows the updated
+    // content. It should show the updated content because the block's default
+    // revision is not a published moderation state.
+    $this->drupalGet('');
+    $this->assertText($updated_body);
+
+    // Publish the block so we can create a forward revision.
+    $this->drupalPostForm('block/' . $block->id(), [], t('Save and Publish'));
+
+    // Create a forward revision.
+    $forward_revision_body = 'This is the forward revision body value';
+    $edit = [
+      'body[0][value]' => $forward_revision_body,
+    ];
+    $this->drupalPostForm('block/' . $block->id(), $edit, t('Save and Create New Draft'));
+    $this->assertText(t('basic Moderated block has been updated.'));
+
+    // Navigate to home page and check that the forward revision doesn't show,
+    // since it should not be set as the default revision.
+    $this->drupalGet('');
+    $this->assertText($updated_body);
+
+    // Open the latest tab and publish the new draft.
+    $edit = [
+      'new_state' => 'published',
+    ];
+    $this->drupalPostForm('block/' . $block->id() . '/latest', $edit, t('Apply'));
+    $this->assertText(t('The moderation state has been updated.'));
+
+    // Navigate to home page and check that the forward revision is now the
+    // default revision and therefore visible.
+    $this->drupalGet('');
+    $this->assertText($forward_revision_body);
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a819cafc393787c4e9144b6cab6fce136f81edbd
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+use Drupal\Core\Url;
+use Drupal\node\Entity\Node;
+
+/**
+ * Tests general content moderation workflow for nodes.
+ *
+ * @group content_moderation
+ */
+class ModerationStateNodeTest extends ModerationStateTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->drupalLogin($this->adminUser);
+    $this->createContentTypeFromUi(
+      'Moderated content',
+      'moderated_content',
+      TRUE,
+      ['draft', 'needs_review', 'published'],
+      'draft'
+    );
+    $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
+  }
+
+  /**
+   * Tests creating and deleting content.
+   */
+  public function testCreatingContent() {
+    $this->drupalPostForm('node/add/moderated_content', [
+      'title[0][value]' => 'moderated content',
+    ], t('Save and Create New Draft'));
+    $nodes = \Drupal::entityTypeManager()
+      ->getStorage('node')
+      ->loadByProperties([
+        'title' => 'moderated content',
+      ]);
+
+    if (!$nodes) {
+      $this->fail('Test node was not saved correctly.');
+      return;
+    }
+
+    $node = reset($nodes);
+
+    $path = 'node/' . $node->id() . '/edit';
+    // Set up published revision.
+    $this->drupalPostForm($path, [], t('Save and Publish'));
+    \Drupal::entityTypeManager()->getStorage('node')->resetCache([$node->id()]);
+    /* @var \Drupal\node\NodeInterface $node */
+    $node = \Drupal::entityTypeManager()->getStorage('node')->load($node->id());
+    $this->assertTrue($node->isPublished());
+
+    // Verify that the state field is not shown.
+    $this->assertNoText('Published');
+
+    // Delete the node.
+    $this->drupalPostForm('node/' . $node->id() . '/delete', array(), t('Delete'));
+    $this->assertText(t('The Moderated content moderated content has been deleted.'));
+  }
+
+  /**
+   * Tests edit form destinations.
+   */
+  public function testFormSaveDestination() {
+    // Create new moderated content in draft.
+    $this->drupalPostForm('node/add/moderated_content', [
+      'title[0][value]' => 'Some moderated content',
+      'body[0][value]' => 'First version of the content.',
+    ], t('Save and Create New Draft'));
+
+    $node = $this->drupalGetNodeByTitle('Some moderated content');
+    $edit_path = sprintf('node/%d/edit', $node->id());
+
+    // After saving, we should be at the canonical URL and viewing the first
+    // revision.
+    $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
+    $this->assertText('First version of the content.');
+
+    // Create a new draft; after saving, we should still be on the canonical
+    // URL, but viewing the second revision.
+    $this->drupalPostForm($edit_path, [
+      'body[0][value]' => 'Second version of the content.',
+    ], t('Save and Create New Draft'));
+    $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
+    $this->assertText('Second version of the content.');
+
+    // Make a new published revision; after saving, we should be at the
+    // canonical URL.
+    $this->drupalPostForm($edit_path, [
+      'body[0][value]' => 'Third version of the content.',
+    ], t('Save and Publish'));
+    $this->assertUrl(Url::fromRoute('entity.node.canonical', ['node' => $node->id()]));
+    $this->assertText('Third version of the content.');
+
+    // Make a new forward revision; after saving, we should be on the "Latest
+    // version" tab.
+    $this->drupalPostForm($edit_path, [
+      'body[0][value]' => 'Fourth version of the content.',
+    ], t('Save and Create New Draft'));
+    $this->assertUrl(Url::fromRoute('entity.node.latest_version', ['node' => $node->id()]));
+    $this->assertText('Fourth version of the content.');
+  }
+
+  /**
+   * Tests pagers aren't broken by content_moderation.
+   */
+  public function testPagers() {
+    // Create 51 nodes to force the pager.
+    foreach (range(1, 51) as $delta) {
+      Node::create([
+        'type' => 'moderated_content',
+        'uid' => $this->adminUser->id(),
+        'title' => 'Node ' . $delta,
+        'status' => 1,
+        'moderation_state' => 'published',
+      ])->save();
+    }
+    $this->drupalLogin($this->adminUser);
+    $this->drupalGet('admin/content');
+    $element = $this->cssSelect('nav.pager li.is-active a');
+    $url = (string) $element[0]['href'];
+    $query = [];
+    parse_str(parse_url($url, PHP_URL_QUERY), $query);
+    $this->assertEqual(0, $query['page']);
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..debb32c81ad6eff34028a9eb9c74a6aed8204321
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+
+/**
+ * Tests moderation state node type integration.
+ *
+ * @group content_moderation
+ */
+class ModerationStateNodeTypeTest extends ModerationStateTestBase {
+
+  /**
+   * A node type without moderation state disabled.
+   */
+  public function testNotModerated() {
+    $this->drupalLogin($this->adminUser);
+    $this->createContentTypeFromUi('Not moderated', 'not_moderated');
+    $this->assertText('The content type Not moderated has been added.');
+    $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated');
+    $this->drupalGet('node/add/not_moderated');
+    $this->assertRaw('Save as unpublished');
+    $this->drupalPostForm(NULL, [
+      'title[0][value]' => 'Test',
+    ], t('Save and publish'));
+    $this->assertText('Not moderated Test has been created.');
+  }
+
+  /**
+   * Tests enabling moderation on an existing node-type, with content.
+   */
+  public function testEnablingOnExistingContent() {
+    // Create a node type that is not moderated.
+    $this->drupalLogin($this->adminUser);
+    $this->createContentTypeFromUi('Not moderated', 'not_moderated');
+    $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'not_moderated');
+
+    // Create content.
+    $this->drupalGet('node/add/not_moderated');
+    $this->drupalPostForm(NULL, [
+      'title[0][value]' => 'Test',
+    ], t('Save and publish'));
+    $this->assertText('Not moderated Test has been created.');
+
+    // Now enable moderation state.
+    $this->enableModerationThroughUi(
+      'not_moderated',
+      ['draft', 'needs_review', 'published'],
+      'draft'
+    );
+
+    // And make sure it works.
+    $nodes = \Drupal::entityTypeManager()->getStorage('node')
+      ->loadByProperties(['title' => 'Test']);
+    if (empty($nodes)) {
+      $this->fail('Could not load node with title Test');
+      return;
+    }
+    $node = reset($nodes);
+    $this->drupalGet('node/' . $node->id());
+    $this->assertResponse(200);
+    $this->assertLinkByHref('node/' . $node->id() . '/edit');
+    $this->drupalGet('node/' . $node->id() . '/edit');
+    $this->assertResponse(200);
+    $this->assertRaw('Save and Create New Draft');
+    $this->assertNoRaw('Save and publish');
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1b394f935c81f66038f80cee9901a7fd7e990276
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Tests moderation state config entity.
+ *
+ * @group content_moderation
+ */
+class ModerationStateStatesTest extends ModerationStateTestBase {
+
+  /**
+   * Tests route access/permissions.
+   */
+  public function testAccess() {
+    $paths = [
+      'admin/config/workflow/moderation',
+      'admin/config/workflow/moderation/states',
+      'admin/config/workflow/moderation/states/add',
+      'admin/config/workflow/moderation/states/draft',
+      'admin/config/workflow/moderation/states/draft/delete',
+    ];
+
+    foreach ($paths as $path) {
+      $this->drupalGet($path);
+      // No access.
+      $this->assertResponse(403);
+    }
+    $this->drupalLogin($this->adminUser);
+    foreach ($paths as $path) {
+      $this->drupalGet($path);
+      // User has access.
+      $this->assertResponse(200);
+    }
+  }
+
+  /**
+   * Tests administration of moderation state entity.
+   */
+  public function testStateAdministration() {
+    $this->drupalLogin($this->adminUser);
+    $this->drupalGet('admin/config/workflow/moderation');
+    $this->assertLink('Moderation states');
+    $this->assertLink('Moderation state transitions');
+    $this->clickLink('Moderation states');
+    $this->assertLink('Add Moderation state');
+    $this->assertText('Draft');
+    // Edit the draft.
+    $this->clickLink('Edit', 0);
+    $this->assertFieldByName('label', 'Draft');
+    $this->assertNoFieldChecked('edit-published');
+    $this->drupalPostForm(NULL, [
+      'label' => 'Drafty',
+    ], t('Save'));
+    $this->assertText('Saved the Drafty Moderation state.');
+    $this->drupalGet('admin/config/workflow/moderation/states/draft');
+    $this->assertFieldByName('label', 'Drafty');
+    $this->drupalPostForm(NULL, [
+      'label' => 'Draft',
+    ], t('Save'));
+    $this->assertText('Saved the Draft Moderation state.');
+    $this->clickLink(t('Add Moderation state'));
+    $this->drupalPostForm(NULL, [
+      'label' => 'Expired',
+      'id' => 'expired',
+    ], t('Save'));
+    $this->assertText('Created the Expired Moderation state.');
+    $this->drupalGet('admin/config/workflow/moderation/states/expired');
+    $this->clickLink('Delete');
+    $this->assertText('Are you sure you want to delete Expired?');
+    $this->drupalPostForm(NULL, [], t('Delete'));
+    $this->assertText('Moderation state Expired deleted');
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..f03de121811b5fa5cdc1d5e2e0c48ae5accb2e11
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+use Drupal\Core\Session\AccountInterface;
+use Drupal\simpletest\WebTestBase;
+use Drupal\user\Entity\Role;
+use Drupal\content_moderation\Entity\ModerationState;
+
+/**
+ * Defines a base class for moderation state tests.
+ */
+abstract class ModerationStateTestBase extends WebTestBase {
+
+  /**
+   * Profile to use.
+   */
+  protected $profile = 'testing';
+
+  /**
+   * Admin user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $adminUser;
+
+  /**
+   * Permissions to grant admin user.
+   *
+   * @var array
+   */
+  protected $permissions = [
+    'administer moderation states',
+    'administer moderation state transitions',
+    'use draft_draft transition',
+    'use draft_published transition',
+    'use published_draft transition',
+    'use published_archived transition',
+    'access administration pages',
+    'administer content types',
+    'administer nodes',
+    'view latest version',
+    'view any unpublished content',
+    'access content overview',
+  ];
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'content_moderation',
+    'block',
+    'block_content',
+    'node',
+  ];
+
+  /**
+   * Sets the test up.
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->adminUser = $this->drupalCreateUser($this->permissions);
+    $this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']);
+    $this->drupalPlaceBlock('page_title_block');
+    $this->drupalPlaceBlock('local_actions_block', ['id' => 'actions_block']);
+  }
+
+  /**
+   * Creates a content-type from the UI.
+   *
+   * @param string $content_type_name
+   *   Content type human name.
+   * @param string $content_type_id
+   *   Machine name.
+   * @param bool $moderated
+   *   TRUE if should be moderated.
+   * @param string[] $allowed_states
+   *   Array of allowed state IDs.
+   * @param string $default_state
+   *   Default state.
+   */
+  protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, array $allowed_states = [], $default_state = NULL) {
+    $this->drupalGet('admin/structure/types');
+    $this->clickLink('Add content type');
+    $edit = [
+      'name' => $content_type_name,
+      'type' => $content_type_id,
+    ];
+    $this->drupalPostForm(NULL, $edit, t('Save content type'));
+
+    if ($moderated) {
+      $this->enableModerationThroughUi($content_type_id, $allowed_states, $default_state);
+    }
+  }
+
+  /**
+   * Enable moderation for a specified content type, using the UI.
+   *
+   * @param string $content_type_id
+   *   Machine name.
+   * @param string[] $allowed_states
+   *   Array of allowed state IDs.
+   * @param string $default_state
+   *   Default state.
+   */
+  protected function enableModerationThroughUi($content_type_id, array $allowed_states, $default_state) {
+    $this->drupalGet('admin/structure/types/manage/' . $content_type_id . '/moderation');
+    $this->assertFieldByName('enable_moderation_state');
+    $this->assertNoFieldChecked('edit-enable-moderation-state');
+
+    $edit['enable_moderation_state'] = 1;
+
+    /** @var ModerationState $state */
+    foreach (ModerationState::loadMultiple() as $state) {
+      $key = $state->isPublishedState() ? 'allowed_moderation_states_published[' . $state->id() . ']' : 'allowed_moderation_states_unpublished[' . $state->id() . ']';
+      $edit[$key] = in_array($state->id(), $allowed_states, TRUE) ? $state->id() : FALSE;
+    }
+
+    $edit['default_moderation_state'] = $default_state;
+
+    $this->drupalPostForm(NULL, $edit, t('Save'));
+  }
+
+  /**
+   * Grants given user permission to create content of given type.
+   *
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   User to grant permission to.
+   * @param string $content_type_id
+   *   Content type ID.
+   */
+  protected function grantUserPermissionToCreateContentOfType(AccountInterface $account, $content_type_id) {
+    $role_ids = $account->getRoles(TRUE);
+    /* @var \Drupal\user\RoleInterface $role */
+    $role_id = reset($role_ids);
+    $role = Role::load($role_id);
+    $role->grantPermission(sprintf('create %s content', $content_type_id));
+    $role->grantPermission(sprintf('edit any %s content', $content_type_id));
+    $role->grantPermission(sprintf('delete any %s content', $content_type_id));
+    $role->save();
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0495e48fa6c75350ec60eab952d5e152b90dbbcf
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Tests moderation state transition config entity.
+ *
+ * @group content_moderation
+ */
+class ModerationStateTransitionsTest extends ModerationStateTestBase {
+
+  /**
+   * Tests route access/permissions.
+   */
+  public function testAccess() {
+    $paths = [
+      'admin/config/workflow/moderation/transitions',
+      'admin/config/workflow/moderation/transitions/add',
+      'admin/config/workflow/moderation/transitions/draft_published',
+      'admin/config/workflow/moderation/transitions/draft_published/delete',
+    ];
+
+    foreach ($paths as $path) {
+      $this->drupalGet($path);
+      // No access.
+      $this->assertResponse(403);
+    }
+    $this->drupalLogin($this->adminUser);
+    foreach ($paths as $path) {
+      $this->drupalGet($path);
+      // User has access.
+      $this->assertResponse(200);
+    }
+  }
+
+  /**
+   * Tests administration of moderation state transition entity.
+   */
+  public function testTransitionAdministration() {
+    $this->drupalLogin($this->adminUser);
+
+    $this->drupalGet('admin/config/workflow/moderation');
+    $this->clickLink('Moderation state transitions');
+    $this->assertLink('Add Moderation state transition');
+    $this->assertText('Create New Draft');
+
+    // Edit the Draft » Draft review.
+    $this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft');
+    $this->assertFieldByName('label', 'Create New Draft');
+    $this->assertFieldByName('stateFrom', 'draft');
+    $this->assertFieldByName('stateTo', 'draft');
+    $this->drupalPostForm(NULL, [
+      'label' => 'Create Draft',
+    ], t('Save'));
+    $this->assertText('Saved the Create Draft Moderation state transition.');
+    $this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft');
+    $this->assertFieldByName('label', 'Create Draft');
+    // Now set it back.
+    $this->drupalPostForm(NULL, [
+      'label' => 'Create New Draft',
+    ], t('Save'));
+    $this->assertText('Saved the Create New Draft Moderation state transition.');
+
+    // Add a new state.
+    $this->drupalGet('admin/config/workflow/moderation/states/add');
+    $this->drupalPostForm(NULL, [
+      'label' => 'Expired',
+      'id' => 'expired',
+    ], t('Save'));
+    $this->assertText('Created the Expired Moderation state.');
+
+    // Add a new transition.
+    $this->drupalGet('admin/config/workflow/moderation/transitions');
+    $this->clickLink(t('Add Moderation state transition'));
+    $this->drupalPostForm(NULL, [
+      'label' => 'Published » Expired',
+      'id' => 'published_expired',
+      'stateFrom' => 'published',
+      'stateTo' => 'expired',
+    ], t('Save'));
+    $this->assertText('Created the Published » Expired Moderation state transition.');
+
+    // Delete the new transition.
+    $this->drupalGet('admin/config/workflow/moderation/transitions/published_expired');
+    $this->clickLink('Delete');
+    $this->assertText('Are you sure you want to delete Published » Expired?');
+    $this->drupalPostForm(NULL, [], t('Delete'));
+    $this->assertText('Moderation transition Published » Expired deleted');
+  }
+
+}
diff --git a/core/modules/content_moderation/src/Tests/NodeAccessTest.php b/core/modules/content_moderation/src/Tests/NodeAccessTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1b054067bddd048dc8cc877f67c2e51974740103
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/NodeAccessTest.php
@@ -0,0 +1,108 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+/**
+ * Tests permission access control around nodes.
+ *
+ * @group content_moderation
+ */
+class NodeAccessTest extends ModerationStateTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->drupalLogin($this->adminUser);
+    $this->createContentTypeFromUi(
+      'Moderated content',
+      'moderated_content',
+      TRUE,
+      ['draft', 'published'],
+      'draft'
+    );
+    $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content');
+  }
+
+  /**
+   * Verifies that a non-admin user can still access the appropriate pages.
+   */
+  public function testPageAccess() {
+    $this->drupalLogin($this->adminUser);
+
+    // Create a node to test with.
+    $this->drupalPostForm('node/add/moderated_content', [
+      'title[0][value]' => 'moderated content',
+    ], t('Save and Create New Draft'));
+    $nodes = \Drupal::entityTypeManager()
+      ->getStorage('node')
+      ->loadByProperties([
+        'title' => 'moderated content',
+      ]);
+
+    if (!$nodes) {
+      $this->fail('Test node was not saved correctly.');
+      return;
+    }
+
+    /** @var \Drupal\node\NodeInterface $node */
+    $node = reset($nodes);
+
+    $view_path = 'node/' . $node->id();
+    $edit_path = 'node/' . $node->id() . '/edit';
+    $latest_path = 'node/' . $node->id() . '/latest';
+
+    // Publish the node.
+    $this->drupalPostForm($edit_path, [], t('Save and Publish'));
+
+    // Ensure access works correctly for anonymous users.
+    $this->drupalLogout();
+
+    $this->drupalGet($edit_path);
+    $this->assertResponse(403);
+
+    $this->drupalGet($latest_path);
+    $this->assertResponse(403);
+    $this->drupalGet($view_path);
+    $this->assertResponse(200);
+
+    // Create a forward revision for the 'Latest revision' tab.
+    $this->drupalLogin($this->adminUser);
+    $this->drupalPostForm($edit_path, [
+      'title[0][value]' => 'moderated content revised',
+    ], t('Save and Create New Draft'));
+
+    // Now make a new user and verify that the new user's access is correct.
+    $user = $this->createUser([
+      'use draft_draft transition',
+      'use published_draft transition',
+      'view latest version',
+      'view any unpublished content',
+    ]);
+    $this->drupalLogin($user);
+
+    $this->drupalGet($edit_path);
+    $this->assertResponse(403);
+
+    $this->drupalGet($latest_path);
+    $this->assertResponse(200);
+    $this->drupalGet($view_path);
+    $this->assertResponse(200);
+
+    // Now make another user, who should not be able to see forward revisions.
+    $user = $this->createUser([
+      'use published_draft transition',
+    ]);
+    $this->drupalLogin($user);
+
+    $this->drupalGet($edit_path);
+    $this->assertResponse(403);
+
+    $this->drupalGet($latest_path);
+    $this->assertResponse(403);
+    $this->drupalGet($view_path);
+    $this->assertResponse(200);
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ViewsData.php b/core/modules/content_moderation/src/ViewsData.php
new file mode 100644
index 0000000000000000000000000000000000000000..cad1187797a9ec9bcba6c1c8d4d1c5500e8b855b
--- /dev/null
+++ b/core/modules/content_moderation/src/ViewsData.php
@@ -0,0 +1,260 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Provides the content_moderation views integration.
+ */
+class ViewsData {
+
+  use StringTranslationTrait;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The moderation information.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInformation;
+
+  /**
+   * Creates a new ViewsData instance.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_information
+   *   The moderation information.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_information) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->moderationInformation = $moderation_information;
+  }
+
+  /**
+   * Returns the views data.
+   *
+   * @return array
+   *   The views data.
+   */
+  public function getViewsData() {
+    $data = [];
+
+    $data['content_revision_tracker']['table']['group'] = $this->t('Content moderation (tracker)');
+
+    $data['content_revision_tracker']['entity_type'] = [
+      'title' => $this->t('Entity type'),
+      'field' => [
+        'id' => 'standard',
+      ],
+      'filter' => [
+        'id' => 'string',
+      ],
+      'argument' => [
+        'id' => 'string',
+      ],
+      'sort' => [
+        'id' => 'standard',
+      ],
+    ];
+
+    $data['content_revision_tracker']['entity_id'] = [
+      'title' => $this->t('Entity ID'),
+      'field' => [
+        'id' => 'standard',
+      ],
+      'filter' => [
+        'id' => 'numeric',
+      ],
+      'argument' => [
+        'id' => 'numeric',
+      ],
+      'sort' => [
+        'id' => 'standard',
+      ],
+    ];
+
+    $data['content_revision_tracker']['langcode'] = [
+      'title' => $this->t('Entity language'),
+      'field' => [
+        'id' => 'standard',
+      ],
+      'filter' => [
+        'id' => 'language',
+      ],
+      'argument' => [
+        'id' => 'language',
+      ],
+      'sort' => [
+        'id' => 'standard',
+      ],
+    ];
+
+    $data['content_revision_tracker']['revision_id'] = [
+      'title' => $this->t('Latest revision ID'),
+      'field' => [
+        'id' => 'standard',
+      ],
+      'filter' => [
+        'id' => 'numeric',
+      ],
+      'argument' => [
+        'id' => 'numeric',
+      ],
+      'sort' => [
+        'id' => 'standard',
+      ],
+    ];
+
+    // Add a join for each entity type to the content_revision_tracker table.
+    foreach ($this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions()) as $entity_type_id => $entity_type) {
+      /** @var \Drupal\views\EntityViewsDataInterface $views_data */
+      // We need the views_data handler in order to get the table name later.
+      if ($this->entityTypeManager->hasHandler($entity_type_id, 'views_data') && $views_data = $this->entityTypeManager->getHandler($entity_type_id, 'views_data')) {
+        // Add a join from the entity base table to the revision tracker table.
+        $base_table = $views_data->getViewsTableForEntityType($entity_type);
+        $data['content_revision_tracker']['table']['join'][$base_table] = [
+          'left_field' => $entity_type->getKey('id'),
+          'field' => 'entity_id',
+          'extra' => [
+            [
+              'field' => 'entity_type',
+              'value' => $entity_type_id,
+            ],
+          ],
+        ];
+
+        // Some entity types might not be translatable.
+        if ($entity_type->hasKey('langcode')) {
+          $data['content_revision_tracker']['table']['join'][$base_table]['extra'][] = [
+            'field' => 'langcode',
+            'left_field' => $entity_type->getKey('langcode'),
+            'operation' => '=',
+          ];
+        }
+
+        // Add a relationship between the revision tracker table to the latest
+        // revision on the entity revision table.
+        $data['content_revision_tracker']['latest_revision__' . $entity_type_id] = [
+          'title' => $this->t('@label latest revision', ['@label' => $entity_type->getLabel()]),
+          'group' => $this->t('@label revision', ['@label' => $entity_type->getLabel()]),
+          'relationship' => [
+            'id' => 'standard',
+            'label' => $this->t('@label latest revision', ['@label' => $entity_type->getLabel()]),
+            'base' => $this->getRevisionViewsTableForEntityType($entity_type),
+            'base field' => $entity_type->getKey('revision'),
+            'relationship field' => 'revision_id',
+            'extra' => [
+              [
+                'left_field' => 'entity_type',
+                'value' => $entity_type_id,
+              ],
+            ],
+          ],
+        ];
+
+        // Some entity types might not be translatable.
+        if ($entity_type->hasKey('langcode')) {
+          $data['content_revision_tracker']['latest_revision__' . $entity_type_id]['relationship']['extra'][] = [
+            'left_field' => 'langcode',
+            'field' => $entity_type->getKey('langcode'),
+            'operation' => '=',
+          ];
+        }
+      }
+    }
+
+    // Provides a relationship from moderated entity to its moderation state
+    // entity.
+    $content_moderation_state_entity_type = \Drupal::entityTypeManager()->getDefinition('content_moderation_state');
+    $content_moderation_state_entity_base_table = $content_moderation_state_entity_type->getDataTable() ?: $content_moderation_state_entity_type->getBaseTable();
+    $content_moderation_state_entity_revision_base_table = $content_moderation_state_entity_type->getRevisionDataTable() ?: $content_moderation_state_entity_type->getRevisionTable();
+    foreach ($this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions()) as $entity_type_id => $entity_type) {
+      $table = $entity_type->getDataTable() ?: $entity_type->getBaseTable();
+
+      $data[$table]['moderation_state'] = [
+        'title' => t('Moderation state'),
+        'relationship' => [
+          'id' => 'standard',
+          'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
+          'base' => $content_moderation_state_entity_base_table,
+          'base field' => 'content_entity_id',
+          'relationship field' => $entity_type->getKey('id'),
+          'join_extra' => [
+            [
+              'field' => 'content_entity_type_id',
+              'value' => $entity_type_id,
+            ],
+            [
+              'field' => 'content_entity_revision_id',
+              'left_field' => $entity_type->getKey('revision'),
+            ],
+          ],
+        ],
+      ];
+
+      $revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
+      $data[$revision_table]['moderation_state'] = [
+        'title' => t('Moderation state'),
+        'relationship' => [
+          'id' => 'standard',
+          'label' => $this->t('@label moderation state', ['@label' => $entity_type->getLabel()]),
+          'base' => $content_moderation_state_entity_revision_base_table,
+          'base field' => 'content_entity_revision_id',
+          'relationship field' => $entity_type->getKey('revision'),
+          'join_extra' => [
+            [
+              'field' => 'content_entity_type_id',
+              'value' => $entity_type_id,
+            ],
+          ],
+        ],
+      ];
+    }
+
+    return $data;
+  }
+
+  /**
+   * Alters the table and field information from hook_views_data().
+   *
+   * @param array $data
+   *   An array of all information about Views tables and fields, collected from
+   *   hook_views_data(), passed by reference.
+   *
+   * @see hook_views_data()
+   */
+  public function alterViewsData(array &$data) {
+    $revisionable_types = $this->moderationInformation->selectRevisionableEntities($this->entityTypeManager->getDefinitions());
+    foreach ($revisionable_types as $type) {
+      $data[$type->getRevisionTable()]['latest_revision'] = [
+        'title' => t('Is Latest Revision'),
+        'help' => t('Restrict the view to only revisions that are the latest revision of their entity.'),
+        'filter' => ['id' => 'latest_revision'],
+      ];
+    }
+  }
+
+  /**
+   * Gets the table of an entity type to be used as revision table in views.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   *   The entity type.
+   *
+   * @return string
+   *   The revision base table.
+   */
+  protected function getRevisionViewsTableForEntityType(EntityTypeInterface $entity_type) {
+    return $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable();
+  }
+
+}
diff --git a/core/modules/content_moderation/templates/entity-moderation-form.html.twig b/core/modules/content_moderation/templates/entity-moderation-form.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..403f5f09c6847e3bb0c0255c7e5516e074311542
--- /dev/null
+++ b/core/modules/content_moderation/templates/entity-moderation-form.html.twig
@@ -0,0 +1,8 @@
+{{ attach_library('content_moderation/entity-moderation-form') }}
+<ul class="entity-moderation-form">
+  <li>{{ form.current }}</li>
+  <li>{{ form.new_state }}</li>
+  <li>{{ form.revision_log }}</li>
+  <li>{{ form.submit }}</li>
+</ul>
+{{ form|without('current', 'new_state', 'revision_log', 'submit') }}
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml
new file mode 100644
index 0000000000000000000000000000000000000000..46a64ab583c2571f37f0c4c69992ff1db9df107c
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml
@@ -0,0 +1,409 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - system.menu.main
+  module:
+    - content_moderation
+    - user
+id: latest
+label: Latest
+module: views
+description: ''
+tag: ''
+base_table: node_field_revision
+base_field: vid
+core: 8.x
+display:
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: perm
+        options:
+          perm: 'view all revisions'
+      cache:
+        type: tag
+        options: {  }
+      query:
+        type: views_query
+        options:
+          disable_sql_rewrite: false
+          distinct: false
+          replica: false
+          query_comment: ''
+          query_tags: {  }
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Apply
+          reset_button: false
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: full
+        options:
+          items_per_page: 10
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 25, 50'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: '‹ Previous'
+            next: 'Next ›'
+            first: '« First'
+            last: 'Last »'
+          quantity: 9
+      style:
+        type: table
+      row:
+        type: fields
+      fields:
+        nid:
+          id: nid
+          table: node_field_revision
+          field: nid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Node ID'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: number_integer
+          settings:
+            thousand_separator: ''
+            prefix_suffix: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: node
+          entity_field: nid
+          plugin_id: field
+        vid:
+          id: vid
+          table: node_field_revision
+          field: vid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: 'Revision ID'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: number_integer
+          settings:
+            thousand_separator: ''
+            prefix_suffix: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: node
+          entity_field: vid
+          plugin_id: field
+        title:
+          id: title
+          table: node_field_revision
+          field: title
+          entity_type: node
+          entity_field: title
+          alter:
+            alter_text: false
+            make_link: false
+            absolute: false
+            trim: false
+            word_boundary: false
+            ellipsis: false
+            strip_tags: false
+            html: false
+          hide_empty: false
+          empty_zero: false
+          settings:
+            link_to_entity: false
+          plugin_id: field
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: Title
+          exclude: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_alter_empty: true
+          click_sort_column: value
+          type: string
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+        moderation_state:
+          id: moderation_state
+          table: content_moderation_state_field_revision
+          field: moderation_state
+          relationship: moderation_state
+          group_type: group
+          admin_label: ''
+          label: 'Moderation state'
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: true
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_label
+          settings:
+            link: true
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: content_moderation_state
+          entity_field: moderation_state
+          plugin_id: field
+      filters:
+        latest_revision:
+          id: latest_revision
+          table: node_revision
+          field: latest_revision
+          relationship: none
+          group_type: group
+          admin_label: ''
+          operator: '='
+          value: ''
+          group: 1
+          exposed: false
+          expose:
+            operator_id: ''
+            label: ''
+            description: ''
+            use_operator: false
+            operator: ''
+            identifier: ''
+            required: false
+            remember: false
+            multiple: false
+            remember_roles:
+              authenticated: authenticated
+          is_grouped: false
+          group_info:
+            label: ''
+            description: ''
+            identifier: ''
+            optional: true
+            widget: select
+            multiple: false
+            remember: false
+            default_group: All
+            default_group_multiple: {  }
+            group_items: {  }
+          entity_type: node
+          plugin_id: latest_revision
+      sorts: {  }
+      title: Latest
+      header: {  }
+      footer: {  }
+      empty: {  }
+      relationships:
+        moderation_state:
+          id: moderation_state
+          table: node_field_revision
+          field: moderation_state
+          relationship: none
+          group_type: group
+          admin_label: 'Content moderation state'
+          required: false
+          entity_type: node
+          plugin_id: standard
+      arguments: {  }
+      display_extenders: {  }
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url.query_args
+        - 'user.node_grants:view'
+        - user.permissions
+      tags: {  }
+  page_1:
+    display_plugin: page
+    id: page_1
+    display_title: Page
+    position: 1
+    display_options:
+      display_extenders: {  }
+      path: latest
+      menu:
+        type: normal
+        title: Drafts
+        description: ''
+        expanded: false
+        parent: ''
+        weight: 0
+        context: '0'
+        menu_name: main
+    cache_metadata:
+      max-age: 0
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url.query_args
+        - 'user.node_grants:view'
+        - user.permissions
+      tags: {  }
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6f9525116646181f4ced4dcc69f4e4000d3f6c3e
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml
@@ -0,0 +1,406 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - content_moderation
+    - node
+    - user
+id: test_content_moderation_base_table_test
+label: test_content_moderation_base_table_test
+module: views
+description: ''
+tag: ''
+base_table: node_field_data
+base_field: nid
+core: 8.x
+display:
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: perm
+        options:
+          perm: 'access content'
+      cache:
+        type: tag
+        options: {  }
+      query:
+        type: views_query
+        options:
+          disable_sql_rewrite: false
+          distinct: false
+          replica: false
+          query_comment: ''
+          query_tags: {  }
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Apply
+          reset_button: false
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: mini
+        options:
+          items_per_page: 10
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 25, 50'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: ‹‹
+            next: ››
+      style:
+        type: default
+        options:
+          grouping: {  }
+          row_class: ''
+          default_row_class: true
+          uses_fields: false
+      row:
+        type: fields
+        options:
+          inline: {  }
+          separator: ''
+          hide_empty: false
+          default_field_elements: true
+      fields:
+        nid:
+          id: nid
+          table: node_field_data
+          field: nid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: number_integer
+          settings:
+            thousand_separator: ''
+            prefix_suffix: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: node
+          entity_field: nid
+          plugin_id: field
+        moderation_state:
+          id: moderation_state
+          table: content_moderation_state_field_data
+          field: moderation_state
+          relationship: moderation_state
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_label
+          settings:
+            link: false
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: content_moderation_state
+          entity_field: moderation_state
+          plugin_id: field
+        moderation_state_1:
+          id: moderation_state_1
+          table: content_moderation_state_field_revision
+          field: moderation_state
+          relationship: moderation_state
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_label
+          settings:
+            link: false
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: content_moderation_state
+          entity_field: moderation_state
+          plugin_id: field
+        moderation_state_2:
+          id: moderation_state_2
+          table: content_moderation_state_field_revision
+          field: moderation_state
+          relationship: moderation_state_1
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_entity_id
+          settings: {  }
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: content_moderation_state
+          entity_field: moderation_state
+          plugin_id: field
+      filters: {  }
+      sorts:
+        created:
+          id: created
+          table: node_field_data
+          field: created
+          order: DESC
+          entity_type: node
+          entity_field: created
+          plugin_id: date
+          relationship: none
+          group_type: group
+          admin_label: ''
+          exposed: false
+          expose:
+            label: ''
+          granularity: second
+        vid:
+          id: vid
+          table: node_field_data
+          field: vid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          order: ASC
+          exposed: false
+          expose:
+            label: ''
+          entity_type: node
+          entity_field: vid
+          plugin_id: standard
+      header: {  }
+      footer: {  }
+      empty: {  }
+      relationships:
+        moderation_state:
+          id: moderation_state
+          table: node_field_data
+          field: moderation_state
+          relationship: none
+          group_type: group
+          admin_label: 'Content moderation state'
+          required: false
+          entity_type: node
+          plugin_id: standard
+        moderation_state_1:
+          id: moderation_state_1
+          table: node_field_revision
+          field: moderation_state
+          relationship: none
+          group_type: group
+          admin_label: 'Content moderation state (revision)'
+          required: false
+          entity_type: node
+          plugin_id: standard
+      arguments: {  }
+      display_extenders: {  }
+    cache_metadata:
+      max-age: -1
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url.query_args
+        - 'user.node_grants:view'
+        - user.permissions
+      tags: {  }
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml
new file mode 100644
index 0000000000000000000000000000000000000000..76733940b50d84f1ce28908678afaf7e0b97970b
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml
@@ -0,0 +1,447 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - node
+    - user
+id: test_content_moderation_latest_revision
+label: test_content_moderation_latest_revision
+module: views
+description: ''
+tag: ''
+base_table: node_field_data
+base_field: nid
+core: 8.x
+display:
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: perm
+        options:
+          perm: 'access content'
+      cache:
+        type: tag
+        options: {  }
+      query:
+        type: views_query
+        options:
+          disable_sql_rewrite: false
+          distinct: false
+          replica: false
+          query_comment: ''
+          query_tags: {  }
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Apply
+          reset_button: false
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: mini
+        options:
+          items_per_page: 10
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 25, 50'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: ‹‹
+            next: ››
+      style:
+        type: default
+        options:
+          grouping: {  }
+          row_class: ''
+          default_row_class: true
+          uses_fields: false
+      row:
+        type: fields
+        options:
+          inline: {  }
+          separator: ''
+          hide_empty: false
+          default_field_elements: true
+      fields:
+        nid:
+          id: nid
+          table: node_field_data
+          field: nid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: number_integer
+          settings:
+            thousand_separator: ''
+            prefix_suffix: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: node
+          entity_field: nid
+          plugin_id: field
+        revision_id:
+          id: revision_id
+          table: content_revision_tracker
+          field: revision_id
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          plugin_id: standard
+        title:
+          id: title
+          table: node_field_revision
+          field: title
+          relationship: latest_revision__node
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: string
+          settings:
+            link_to_entity: false
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: node
+          entity_field: title
+          plugin_id: field
+        moderation_state:
+          id: moderation_state
+          table: content_moderation_state_field_revision
+          field: moderation_state
+          relationship: moderation_state
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_entity_id
+          settings: {  }
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: content_moderation_state
+          entity_field: moderation_state
+          plugin_id: field
+        moderation_state_1:
+          id: moderation_state_1
+          table: content_moderation_state_field_revision
+          field: moderation_state
+          relationship: moderation_state_1
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_entity_id
+          settings: {  }
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: content_moderation_state
+          entity_field: moderation_state
+          plugin_id: field
+      filters: {  }
+      sorts:
+        nid:
+          id: nid
+          table: node_field_data
+          field: nid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          order: ASC
+          exposed: false
+          expose:
+            label: ''
+          entity_type: node
+          entity_field: nid
+          plugin_id: standard
+      header: {  }
+      footer: {  }
+      empty: {  }
+      relationships:
+        latest_revision__node:
+          id: latest_revision__node
+          table: content_revision_tracker
+          field: latest_revision__node
+          relationship: none
+          group_type: group
+          admin_label: 'Content latest revision'
+          required: false
+          plugin_id: standard
+        moderation_state_1:
+          id: moderation_state_1
+          table: node_field_revision
+          field: moderation_state
+          relationship: latest_revision__node
+          group_type: group
+          admin_label: 'Content moderation state (latest revision)'
+          required: false
+          entity_type: node
+          plugin_id: standard
+        moderation_state:
+          id: moderation_state
+          table: node_field_revision
+          field: moderation_state
+          relationship: none
+          group_type: group
+          admin_label: 'Content moderation state'
+          required: false
+          entity_type: node
+          plugin_id: standard
+      arguments: {  }
+      display_extenders: {  }
+      rendering_language: '***LANGUAGE_entity_default***'
+    cache_metadata:
+      max-age: -1
+      contexts:
+        - 'languages:language_interface'
+        - url.query_args
+        - 'user.node_grants:view'
+        - user.permissions
+      tags: {  }
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2362098d604cda9fe2b8bfb964d17e929fb595c1
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml
@@ -0,0 +1,315 @@
+langcode: en
+status: true
+dependencies:
+  module:
+    - user
+id: test_content_moderation_revision_test
+label: test_content_moderation_revision_test
+module: views
+description: ''
+tag: ''
+base_table: node_field_revision
+base_field: vid
+core: 8.x
+display:
+  default:
+    display_plugin: default
+    id: default
+    display_title: Master
+    position: 0
+    display_options:
+      access:
+        type: perm
+        options:
+          perm: 'view all revisions'
+      cache:
+        type: tag
+        options: {  }
+      query:
+        type: views_query
+        options:
+          disable_sql_rewrite: false
+          distinct: false
+          replica: false
+          query_comment: ''
+          query_tags: {  }
+      exposed_form:
+        type: basic
+        options:
+          submit_button: Apply
+          reset_button: false
+          reset_button_label: Reset
+          exposed_sorts_label: 'Sort by'
+          expose_sort_order: true
+          sort_asc_label: Asc
+          sort_desc_label: Desc
+      pager:
+        type: mini
+        options:
+          items_per_page: 10
+          offset: 0
+          id: 0
+          total_pages: null
+          expose:
+            items_per_page: false
+            items_per_page_label: 'Items per page'
+            items_per_page_options: '5, 10, 25, 50'
+            items_per_page_options_all: false
+            items_per_page_options_all_label: '- All -'
+            offset: false
+            offset_label: Offset
+          tags:
+            previous: ‹‹
+            next: ››
+      style:
+        type: default
+        options:
+          grouping: {  }
+          row_class: ''
+          default_row_class: true
+          uses_fields: false
+      row:
+        type: fields
+        options:
+          inline: {  }
+          separator: ''
+          hide_empty: false
+          default_field_elements: true
+      fields:
+        vid:
+          id: vid
+          table: node_field_revision
+          field: vid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: number_integer
+          settings:
+            thousand_separator: ''
+            prefix_suffix: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: node
+          entity_field: vid
+          plugin_id: field
+        moderation_state:
+          id: moderation_state
+          table: content_moderation_state_field_revision
+          field: moderation_state
+          relationship: moderation_state
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: target_id
+          type: entity_reference_entity_id
+          settings: {  }
+          group_column: target_id
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: content_moderation_state
+          entity_field: moderation_state
+          plugin_id: field
+        revision_id:
+          id: revision_id
+          table: content_moderation_state_field_revision
+          field: revision_id
+          relationship: moderation_state
+          group_type: group
+          admin_label: ''
+          label: ''
+          exclude: false
+          alter:
+            alter_text: false
+            text: ''
+            make_link: false
+            path: ''
+            absolute: false
+            external: false
+            replace_spaces: false
+            path_case: none
+            trim_whitespace: false
+            alt: ''
+            rel: ''
+            link_class: ''
+            prefix: ''
+            suffix: ''
+            target: ''
+            nl2br: false
+            max_length: 0
+            word_boundary: true
+            ellipsis: true
+            more_link: false
+            more_link_text: ''
+            more_link_path: ''
+            strip_tags: false
+            trim: false
+            preserve_tags: ''
+            html: false
+          element_type: ''
+          element_class: ''
+          element_label_type: ''
+          element_label_class: ''
+          element_label_colon: false
+          element_wrapper_type: ''
+          element_wrapper_class: ''
+          element_default_classes: true
+          empty: ''
+          hide_empty: false
+          empty_zero: false
+          hide_alter_empty: true
+          click_sort_column: value
+          type: number_integer
+          settings:
+            thousand_separator: ''
+            prefix_suffix: true
+          group_column: value
+          group_columns: {  }
+          group_rows: true
+          delta_limit: 0
+          delta_offset: 0
+          delta_reversed: false
+          delta_first_last: false
+          multi_type: separator
+          separator: ', '
+          field_api_classes: false
+          entity_type: content_moderation_state
+          entity_field: revision_id
+          plugin_id: field
+      filters: {  }
+      sorts:
+        vid:
+          id: vid
+          table: node_field_revision
+          field: vid
+          relationship: none
+          group_type: group
+          admin_label: ''
+          order: ASC
+          exposed: false
+          expose:
+            label: ''
+          entity_type: node
+          entity_field: vid
+          plugin_id: standard
+      header: {  }
+      footer: {  }
+      empty: {  }
+      relationships:
+        moderation_state:
+          id: moderation_state
+          table: node_field_revision
+          field: moderation_state
+          relationship: none
+          group_type: group
+          admin_label: 'Content moderation state'
+          required: false
+          entity_type: node
+          plugin_id: standard
+      arguments: {  }
+      display_extenders: {  }
+    cache_metadata:
+      max-age: -1
+      contexts:
+        - 'languages:language_content'
+        - 'languages:language_interface'
+        - url.query_args
+        - 'user.node_grants:view'
+        - user.permissions
+      tags: {  }
diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b96ef8480438d1b6ed36e999587a267b504d9508
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml
@@ -0,0 +1,10 @@
+name: 'Content moderation test views'
+type: module
+description: 'Provides default views for views Content moderation tests.'
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+  - content_moderation
+  - node
+  - views
diff --git a/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..77ae04627fc1b48eddc418f26512a27d7aaa4712
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Functional;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests the "Latest Revision" views filter.
+ *
+ * @group content_moderation
+ */
+class LatestRevisionViewsFilterTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'content_moderation_test_views',
+    'content_moderation',
+  ];
+
+  /**
+   * Tests view shows the correct node IDs.
+   */
+  public function testViewShowsCorrectNids() {
+    $node_type = $this->createNodeType('Test', 'test');
+
+    $permissions = [
+      'access content',
+      'view all revisions',
+    ];
+    $editor1 = $this->drupalCreateUser($permissions);
+
+    $this->drupalLogin($editor1);
+
+    // Make a pre-moderation node.
+    /** @var Node $node_0 */
+    $node_0 = Node::create([
+      'type' => 'test',
+      'title' => 'Node 0 - Rev 1',
+      'uid' => $editor1->id(),
+    ]);
+    $node_0->save();
+
+    // Now enable moderation for subsequent nodes.
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->save();
+
+    // Make a node that is only ever in Draft.
+    /** @var Node $node_1 */
+    $node_1 = Node::create([
+      'type' => 'test',
+      'title' => 'Node 1 - Rev 1',
+      'uid' => $editor1->id(),
+    ]);
+    $node_1->moderation_state->target_id = 'draft';
+    $node_1->save();
+
+    // Make a node that is in Draft, then Published.
+    /** @var Node $node_2 */
+    $node_2 = Node::create([
+      'type' => 'test',
+      'title' => 'Node 2 - Rev 1',
+      'uid' => $editor1->id(),
+    ]);
+    $node_2->moderation_state->target_id = 'draft';
+    $node_2->save();
+
+    $node_2->setTitle('Node 2 - Rev 2');
+    $node_2->moderation_state->target_id = 'published';
+    $node_2->save();
+
+    // Make a node that is in Draft, then Published, then Draft.
+    /** @var Node $node_3 */
+    $node_3 = Node::create([
+      'type' => 'test',
+      'title' => 'Node 3 - Rev 1',
+      'uid' => $editor1->id(),
+    ]);
+    $node_3->moderation_state->target_id = 'draft';
+    $node_3->save();
+
+    $node_3->setTitle('Node 3 - Rev 2');
+    $node_3->moderation_state->target_id = 'published';
+    $node_3->save();
+
+    $node_3->setTitle('Node 3 - Rev 3');
+    $node_3->moderation_state->target_id = 'draft';
+    $node_3->save();
+
+    // Now show the View, and confirm that only the correct titles are showing.
+    $this->drupalGet('/latest');
+    $page = $this->getSession()->getPage();
+    $this->assertEquals(200, $this->getSession()->getStatusCode());
+    $this->assertTrue($page->hasContent('Node 1 - Rev 1'));
+    $this->assertTrue($page->hasContent('Node 2 - Rev 2'));
+    $this->assertTrue($page->hasContent('Node 3 - Rev 3'));
+    $this->assertFalse($page->hasContent('Node 2 - Rev 1'));
+    $this->assertFalse($page->hasContent('Node 3 - Rev 1'));
+    $this->assertFalse($page->hasContent('Node 3 - Rev 2'));
+    $this->assertFalse($page->hasContent('Node 0 - Rev 1'));
+  }
+
+  /**
+   * Creates a new node type.
+   *
+   * @param string $label
+   *   The human-readable label of the type to create.
+   * @param string $machine_name
+   *   The machine name of the type to create.
+   *
+   * @return NodeType
+   *   The node type just created.
+   */
+  protected function createNodeType($label, $machine_name) {
+    /** @var NodeType $node_type */
+    $node_type = NodeType::create([
+      'type' => $machine_name,
+      'label' => $label,
+    ]);
+    $node_type->save();
+
+    return $node_type;
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..799d89a5117062c07c0b69291f5141cf54551ffe
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Functional;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests the view access control handler for moderation state entities.
+ *
+ * @group content_moderation
+ */
+class ModerationStateAccessTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'content_moderation_test_views',
+    'content_moderation',
+  ];
+
+  /**
+   * Test the view operation access handler with the view permission.
+   */
+  public function testViewShowsCorrectStates() {
+    $node_type_id = 'test';
+    $this->createNodeType('Test', $node_type_id);
+
+    $permissions = [
+      'access content',
+      'view all revisions',
+      'view moderation states',
+    ];
+    $editor1 = $this->drupalCreateUser($permissions);
+    $this->drupalLogin($editor1);
+
+    $node_1 = Node::create([
+      'type' => $node_type_id,
+      'title' => 'Draft node',
+      'uid' => $editor1->id(),
+    ]);
+    $node_1->moderation_state->target_id = 'draft';
+    $node_1->save();
+
+    $node_2 = Node::create([
+      'type' => $node_type_id,
+      'title' => 'Published node',
+      'uid' => $editor1->id(),
+    ]);
+    $node_2->moderation_state->target_id = 'published';
+    $node_2->save();
+
+    // Resave the node with a new state.
+    $node_2->setTitle('Archived node');
+    $node_2->moderation_state->target_id = 'archived';
+    $node_2->save();
+
+    // Now show the View, and confirm that the state labels are showing.
+    $this->drupalGet('/latest');
+    $page = $this->getSession()->getPage();
+    $this->assertTrue($page->hasLink('Draft'));
+    $this->assertTrue($page->hasLink('Archived'));
+    $this->assertFalse($page->hasLink('Published'));
+
+    // Now log in as an admin and test the same thing.
+    $permissions = [
+      'access content',
+      'view all revisions',
+      'administer moderation states',
+    ];
+    $admin1 = $this->drupalCreateUser($permissions);
+    $this->drupalLogin($admin1);
+
+    $this->drupalGet('/latest');
+    $page = $this->getSession()->getPage();
+    $this->assertEquals(200, $this->getSession()->getStatusCode());
+    $this->assertTrue($page->hasLink('Draft'));
+    $this->assertTrue($page->hasLink('Archived'));
+    $this->assertFalse($page->hasLink('Published'));
+  }
+
+  /**
+   * Creates a new node type.
+   *
+   * @param string $label
+   *   The human-readable label of the type to create.
+   * @param string $machine_name
+   *   The machine name of the type to create.
+   *
+   * @return NodeType
+   *   The node type just created.
+   */
+  protected function createNodeType($label, $machine_name) {
+    /** @var NodeType $node_type */
+    $node_type = NodeType::create([
+      'type' => $machine_name,
+      'label' => $label,
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->save();
+
+    return $node_type;
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8b382c14a9fa3d900d5c0f5cfa4a776c23d6e274
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\block_content\Entity\BlockContentType;
+use Drupal\config\Tests\SchemaCheckTestTrait;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\NodeType;
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+
+/**
+ * Ensures that content moderation schema is correct.
+ *
+ * @group content_moderation
+ */
+class ContentModerationSchemaTest extends KernelTestBase {
+
+  use SchemaCheckTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'content_moderation',
+    'node',
+    'user',
+    'block_content',
+    'system',
+  ];
+
+  /**
+   * Tests content moderation default schema.
+   */
+  public function testContentModerationDefaultConfig() {
+    $this->installConfig(['content_moderation']);
+    $typed_config = \Drupal::service('config.typed');
+    $moderation_states = ModerationState::loadMultiple();
+    foreach ($moderation_states as $moderation_state) {
+      $this->assertConfigSchema($typed_config, $moderation_state->getEntityType()->getConfigPrefix() . '.' . $moderation_state->id(), $moderation_state->toArray());
+    }
+    $moderation_state_transitions = ModerationStateTransition::loadMultiple();
+    foreach ($moderation_state_transitions as $moderation_state_transition) {
+      $this->assertConfigSchema($typed_config, $moderation_state_transition->getEntityType()->getConfigPrefix() . '.' . $moderation_state_transition->id(), $moderation_state_transition->toArray());
+    }
+
+  }
+
+  /**
+   * Tests content moderation third party schema for node types.
+   */
+  public function testContentModerationNodeTypeConfig() {
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    $this->installConfig(['content_moderation']);
+    $typed_config = \Drupal::service('config.typed');
+    $moderation_states = ModerationState::loadMultiple();
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states));
+    $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', '');
+    $node_type->save();
+    $this->assertConfigSchema($typed_config, $node_type->getEntityType()->getConfigPrefix() . '.' . $node_type->id(), $node_type->toArray());
+  }
+
+  /**
+   * Tests content moderation third party schema for block content types.
+   */
+  public function testContentModerationBlockContentTypeConfig() {
+    $this->installEntitySchema('block_content');
+    $this->installEntitySchema('user');
+    $this->installConfig(['content_moderation']);
+    $typed_config = \Drupal::service('config.typed');
+    $moderation_states = ModerationState::loadMultiple();
+    $block_content_type = BlockContentType::create([
+      'id' => 'basic',
+      'label' => 'basic',
+      'revision' => TRUE,
+    ]);
+    $block_content_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $block_content_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states));
+    $block_content_type->setThirdPartySetting('content_moderation', 'default_moderation_state', '');
+    $block_content_type->save();
+    $this->assertConfigSchema($typed_config, $block_content_type->getEntityType()->getConfigPrefix() . '.' . $block_content_type->id(), $block_content_type->toArray());
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c76e65116e3aaa0a26aff4270e50f2e8090732ca
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php
@@ -0,0 +1,234 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\content_moderation\Entity\ContentModerationState;
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\node\NodeInterface;
+
+/**
+ * Tests links between a content entity and a content_moderation_state entity.
+ *
+ * @group content_moderation
+ */
+class ContentModerationStateTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'node',
+    'content_moderation',
+    'user',
+    'system',
+    'language',
+    'content_translation',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installSchema('node', 'node_access');
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('content_moderation_state');
+    $this->installConfig('content_moderation');
+  }
+
+  /**
+   * Tests basic monolingual content moderation through the API.
+   */
+  public function testBasicModeration() {
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']);
+    $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft');
+    $node_type->save();
+    $node = Node::create([
+      'type' => 'example',
+      'title' => 'Test title',
+    ]);
+    $node->save();
+    $node = $this->reloadNode($node);
+    $this->assertEquals('draft', $node->moderation_state->entity->id());
+
+    $published = ModerationState::load('published');
+    $node->moderation_state->entity = $published;
+    $node->save();
+
+    $node = $this->reloadNode($node);
+    $this->assertEquals('published', $node->moderation_state->entity->id());
+
+    // Change the state without saving the node.
+    $content_moderation_state = ContentModerationState::load(1);
+    $content_moderation_state->set('moderation_state', 'draft');
+    $content_moderation_state->setNewRevision(TRUE);
+    $content_moderation_state->save();
+
+    $node = $this->reloadNode($node, 3);
+    $this->assertEquals('draft', $node->moderation_state->entity->id());
+    $this->assertFalse($node->isPublished());
+
+    // Get the default revision.
+    $node = $this->reloadNode($node);
+    $this->assertTrue($node->isPublished());
+    $this->assertEquals(2, $node->getRevisionId());
+
+    $node->moderation_state->target_id = 'published';
+    $node->save();
+
+    $node = $this->reloadNode($node, 4);
+    $this->assertEquals('published', $node->moderation_state->entity->id());
+
+    // Get the default revision.
+    $node = $this->reloadNode($node);
+    $this->assertTrue($node->isPublished());
+    $this->assertEquals(4, $node->getRevisionId());
+
+  }
+
+  /**
+   * Tests basic multilingual content moderation through the API.
+   */
+  public function testMultilingualModeration() {
+    // Enable French.
+    ConfigurableLanguage::createFromLangcode('fr')->save();
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']);
+    $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft');
+    $node_type->save();
+    $english_node = Node::create([
+      'type' => 'example',
+      'title' => 'Test title',
+    ]);
+    // Revision 1 (en).
+    $english_node
+      ->setPublished(FALSE)
+      ->save();
+    $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+    $this->assertFalse($english_node->isPublished());
+
+    // Create a French translation.
+    $french_node = $english_node->addTranslation('fr', ['title' => 'French title']);
+    $french_node->setPublished(FALSE);
+    // Revision 1 (fr).
+    $french_node->save();
+    $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+    $this->assertEquals('draft', $french_node->moderation_state->entity->id());
+    $this->assertFalse($french_node->isPublished());
+
+    // Move English node to create another draft.
+    $english_node = $this->reloadNode($english_node);
+    $english_node->moderation_state->target_id = 'draft';
+    // Revision 2 (en, fr).
+    $english_node->save();
+    $english_node = $this->reloadNode($english_node);
+    $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+
+    // French node should still be in draft.
+    $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+    $this->assertEquals('draft', $french_node->moderation_state->entity->id());
+
+    // Publish the French node.
+    $french_node->moderation_state->target_id = 'published';
+    // Revision 3 (en, fr).
+    $french_node->save();
+    $french_node = $this->reloadNode($french_node)->getTranslation('fr');
+    $this->assertTrue($french_node->isPublished());
+    $this->assertEquals('published', $french_node->moderation_state->entity->id());
+    $this->assertTrue($french_node->isPublished());
+    $english_node = $french_node->getTranslation('en');
+    $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+
+    // Publish the English node.
+    $english_node->moderation_state->target_id = 'published';
+    // Revision 4 (en, fr).
+    $english_node->save();
+    $english_node = $this->reloadNode($english_node);
+    $this->assertTrue($english_node->isPublished());
+
+    // Move the French node back to draft.
+    $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+    $this->assertTrue($french_node->isPublished());
+    $french_node->moderation_state->target_id = 'draft';
+    // Revision 5 (en, fr).
+    $french_node->save();
+    $french_node = $this->reloadNode($english_node, 5)->getTranslation('fr');
+    $this->assertFalse($french_node->isPublished());
+    $this->assertTrue($french_node->getTranslation('en')->isPublished());
+
+    // Republish the French node.
+    $french_node->moderation_state->target_id = 'published';
+    // Revision 6 (en, fr).
+    $french_node->save();
+    $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+    $this->assertTrue($french_node->isPublished());
+
+    // Change the EN state without saving the node.
+    $content_moderation_state = ContentModerationState::load(1);
+    $content_moderation_state->set('moderation_state', 'draft');
+    $content_moderation_state->setNewRevision(TRUE);
+    // Revision 7 (en, fr).
+    $content_moderation_state->save();
+    $english_node = $this->reloadNode($french_node, $french_node->getRevisionId() + 1);
+
+    $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+    $french_node = $this->reloadNode($english_node)->getTranslation('fr');
+    $this->assertEquals('published', $french_node->moderation_state->entity->id());
+
+    // This should unpublish the French node.
+    $content_moderation_state = ContentModerationState::load(1);
+    $content_moderation_state = $content_moderation_state->getTranslation('fr');
+    $content_moderation_state->set('moderation_state', 'draft');
+    $content_moderation_state->setNewRevision(TRUE);
+    // Revision 8 (en, fr).
+    $content_moderation_state->save();
+
+    $english_node = $this->reloadNode($english_node, $english_node->getRevisionId());
+    $this->assertEquals('draft', $english_node->moderation_state->entity->id());
+    $french_node = $this->reloadNode($english_node, '8')->getTranslation('fr');
+    $this->assertEquals('draft', $french_node->moderation_state->entity->id());
+    // Switching the moderation state to an unpublished state should update the
+    // entity.
+    $this->assertFalse($french_node->isPublished());
+
+    // Get the default english node.
+    $english_node = $this->reloadNode($english_node);
+    $this->assertTrue($english_node->isPublished());
+    $this->assertEquals(6, $english_node->getRevisionId());
+  }
+
+  /**
+   * Reloads the node after clearing the static cache.
+   *
+   * @param \Drupal\node\NodeInterface $node
+   *   The node to reload.
+   * @param int|FALSE $revision_id
+   *   The specific revision ID to load. Defaults FALSE and just loads the
+   *   default revision.
+   *
+   * @return \Drupal\node\NodeInterface
+   *   The reloaded node.
+   */
+  protected function reloadNode(NodeInterface $node, $revision_id = FALSE) {
+    $storage = \Drupal::entityTypeManager()->getStorage('node');
+    $storage->resetCache([$node->id()]);
+    if ($revision_id) {
+      return $storage->loadRevision($revision_id);
+    }
+    return $storage->load($node->id());
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..99d8f0e38875df44dac9ef0e4398aaa365c8c286
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php
@@ -0,0 +1,197 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\content_moderation\Entity\ModerationState;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\EntityOperations
+ *
+ * @group content_moderation
+ */
+class EntityOperationsTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'content_moderation',
+    'node',
+    'user',
+    'system',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->installEntitySchema('node');
+    $this->installSchema('node', 'node_access');
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('content_moderation_state');
+    $this->installConfig('content_moderation');
+
+    $this->createNodeType();
+  }
+
+  /**
+   * Creates a page node type to test with, ensuring that it's moderatable.
+   */
+  protected function createNodeType() {
+    $node_type = NodeType::create([
+      'type' => 'page',
+      'label' => 'Page',
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->save();
+  }
+
+  /**
+   * Verifies that the process of saving forward-revisions works as expected.
+   */
+  public function testForwardRevisions() {
+    // Create a new node in draft.
+    $page = Node::create([
+      'type' => 'page',
+      'title' => 'A',
+    ]);
+    $page->moderation_state->target_id = 'draft';
+    $page->save();
+
+    $id = $page->id();
+
+    // Verify the entity saved correctly, and that the presence of forward
+    // revisions doesn't affect the default node load.
+    /** @var Node $page */
+    $page = Node::load($id);
+    $this->assertEquals('A', $page->getTitle());
+    $this->assertTrue($page->isDefaultRevision());
+    $this->assertFalse($page->isPublished());
+
+    // Moderate the entity to published.
+    $page->setTitle('B');
+    $page->moderation_state->target_id = 'published';
+    $page->save();
+
+    // Verify the entity is now published and public.
+    $page = Node::load($id);
+    $this->assertEquals('B', $page->getTitle());
+    $this->assertTrue($page->isDefaultRevision());
+    $this->assertTrue($page->isPublished());
+
+    // Make a new forward-revision in Draft.
+    $page->setTitle('C');
+    $page->moderation_state->target_id = 'draft';
+    $page->save();
+
+    // Verify normal loads return the still-default previous version.
+    $page = Node::load($id);
+    $this->assertEquals('B', $page->getTitle());
+
+    // Verify we can load the forward revision, even if the mechanism is kind
+    // of gross. Note: revisionIds() is only available on NodeStorageInterface,
+    // so this won't work for non-nodes. We'd need to use entity queries. This
+    // is a core bug that should get fixed.
+    $storage = \Drupal::entityTypeManager()->getStorage('node');
+    $revision_ids = $storage->revisionIds($page);
+    sort($revision_ids);
+    $latest = end($revision_ids);
+    $page = $storage->loadRevision($latest);
+    $this->assertEquals('C', $page->getTitle());
+
+    $page->setTitle('D');
+    $page->moderation_state->target_id = 'published';
+    $page->save();
+
+    // Verify normal loads return the still-default previous version.
+    $page = Node::load($id);
+    $this->assertEquals('D', $page->getTitle());
+    $this->assertTrue($page->isDefaultRevision());
+    $this->assertTrue($page->isPublished());
+
+    // Now check that we can immediately add a new published revision over it.
+    $page->setTitle('E');
+    $page->moderation_state->target_id = 'published';
+    $page->save();
+
+    $page = Node::load($id);
+    $this->assertEquals('E', $page->getTitle());
+    $this->assertTrue($page->isDefaultRevision());
+    $this->assertTrue($page->isPublished());
+  }
+
+  /**
+   * Verifies that a newly-created node can go straight to published.
+   */
+  public function testPublishedCreation() {
+    // Create a new node in draft.
+    $page = Node::create([
+      'type' => 'page',
+      'title' => 'A',
+    ]);
+    $page->moderation_state->target_id = 'published';
+    $page->save();
+
+    $id = $page->id();
+
+    // Verify the entity saved correctly.
+    /** @var Node $page */
+    $page = Node::load($id);
+    $this->assertEquals('A', $page->getTitle());
+    $this->assertTrue($page->isDefaultRevision());
+    $this->assertTrue($page->isPublished());
+  }
+
+  /**
+   * Verifies that an unpublished state may be made the default revision.
+   */
+  public function testArchive() {
+    $published_id = $this->randomMachineName();
+    $published_state = ModerationState::create([
+      'id' => $published_id,
+      'label' => $this->randomString(),
+      'published' => TRUE,
+      'default_revision' => TRUE,
+    ]);
+    $published_state->save();
+
+    $archived_id = $this->randomMachineName();
+    $archived_state = ModerationState::create([
+      'id' => $archived_id,
+      'label' => $this->randomString(),
+      'published' => FALSE,
+      'default_revision' => TRUE,
+    ]);
+    $archived_state->save();
+
+    $page = Node::create([
+      'type' => 'page',
+      'title' => $this->randomString(),
+    ]);
+    $page->moderation_state->target_id = $published_id;
+    $page->save();
+
+    $id = $page->id();
+
+    // The newly-created page should already be published.
+    $page = Node::load($id);
+    $this->assertTrue($page->isPublished());
+
+    // When the page is moderated to the archived state, then the latest
+    // revision should be the default revision, and it should be unpublished.
+    $page->moderation_state->target_id = $archived_id;
+    $page->save();
+    $new_revision_id = $page->getRevisionId();
+
+    $storage = \Drupal::entityTypeManager()->getStorage('node');
+    $new_revision = $storage->loadRevision($new_revision_id);
+    $this->assertFalse($new_revision->isPublished());
+    $this->assertTrue($new_revision->isDefaultRevision());
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..89c84f9c7523ca12eeaccf3dacebe0f4692ba0c7
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\entity_test\Entity\EntityTest;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\ParamConverter\EntityRevisionConverter
+ * @group content_moderation
+ */
+class EntityRevisionConverterTest extends KernelTestBase {
+
+  public static $modules = [
+    'user',
+    'entity_test',
+    'system',
+    'content_moderation',
+    'node',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installEntitySchema('entity_test');
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('content_moderation_state');
+    $this->installSchema('system', 'router');
+    $this->installSchema('system', 'sequences');
+    $this->installSchema('node', 'node_access');
+    \Drupal::service('router.builder')->rebuild();
+  }
+
+  /**
+   * @covers ::convert
+   */
+  public function testConvertNonRevisionableEntityType() {
+    $entity_test = EntityTest::create([
+      'name' => 'test',
+    ]);
+
+    $entity_test->save();
+
+    /** @var \Symfony\Component\Routing\RouterInterface $router */
+    $router = \Drupal::service('router.no_access_checks');
+    $result = $router->match('/entity_test/' . $entity_test->id());
+
+    $this->assertInstanceOf(EntityTest::class, $result['entity_test']);
+    $this->assertEquals($entity_test->getRevisionId(), $result['entity_test']->getRevisionId());
+  }
+
+  /**
+   * @covers ::convert
+   */
+  public function testConvertWithRevisionableEntityType() {
+    $node_type = NodeType::create([
+      'type' => 'article',
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->save();
+
+    $revision_ids = [];
+    $node = Node::create([
+      'title' => 'test',
+      'type' => 'article',
+    ]);
+    $node->save();
+
+    $revision_ids[] = $node->getRevisionId();
+
+    $node->setNewRevision(TRUE);
+    $node->save();
+    $revision_ids[] = $node->getRevisionId();
+
+    $node->setNewRevision(TRUE);
+    $node->isDefaultRevision(FALSE);
+    $node->save();
+    $revision_ids[] = $node->getRevisionId();
+
+    /** @var \Symfony\Component\Routing\RouterInterface $router */
+    $router = \Drupal::service('router.no_access_checks');
+    $result = $router->match('/node/' . $node->id() . '/edit');
+
+    $this->assertInstanceOf(Node::class, $result['node']);
+    $this->assertEquals($revision_ids[2], $result['node']->getRevisionId());
+    $this->assertFalse($result['node']->isDefaultRevision());
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..97e61f1ed8c88bac100c6e4d7ade87d348b47476
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\Plugin\Validation\Constraint\ModerationStateConstraintValidator
+ * @group content_moderation
+ */
+class EntityStateChangeValidationTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'node',
+    'content_moderation',
+    'user',
+    'system',
+    'language',
+    'content_translation',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installSchema('node', 'node_access');
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('content_moderation_state');
+    $this->installConfig('content_moderation');
+  }
+
+  /**
+   * Test valid transitions.
+   *
+   * @covers ::validate
+   */
+  public function testValidTransition() {
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->save();
+    $node = Node::create([
+      'type' => 'example',
+      'title' => 'Test title',
+    ]);
+    $node->moderation_state->target_id = 'draft';
+    $node->save();
+
+    $node->moderation_state->target_id = 'published';
+    $this->assertCount(0, $node->validate());
+    $node->save();
+
+    $this->assertEquals('published', $node->moderation_state->entity->id());
+  }
+
+  /**
+   * Test invalid transitions.
+   *
+   * @covers ::validate
+   */
+  public function testInvalidTransition() {
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->save();
+    $node = Node::create([
+      'type' => 'example',
+      'title' => 'Test title',
+    ]);
+    $node->moderation_state->target_id = 'draft';
+    $node->save();
+
+    $node->moderation_state->target_id = 'archived';
+    $violations = $node->validate();
+    $this->assertCount(1, $violations);
+
+    $this->assertEquals('Invalid state transition from <em class="placeholder">Draft</em> to <em class="placeholder">Archived</em>', $violations->get(0)->getMessage());
+  }
+
+  /**
+   * Tests that content without prior moderation information can be moderated.
+   */
+  public function testLegacyContent() {
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->save();
+    /** @var \Drupal\node\NodeInterface $node */
+    $node = Node::create([
+      'type' => 'example',
+      'title' => 'Test title',
+    ]);
+    $node->save();
+
+    $nid = $node->id();
+
+    // Enable moderation for our node type.
+    /** @var NodeType $node_type */
+    $node_type = NodeType::load('example');
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']);
+    $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft');
+    $node_type->save();
+
+    $node = Node::load($nid);
+
+    // Having no previous state should not break validation.
+    $violations = $node->validate();
+
+    $this->assertCount(0, $violations);
+
+    // Having no previous state should not break saving the node.
+    $node->setTitle('New');
+    $node->save();
+  }
+
+  /**
+   * Tests that content without prior moderation information can be translated.
+   */
+  public function testLegacyMultilingualContent() {
+    // Enable French.
+    ConfigurableLanguage::createFromLangcode('fr')->save();
+
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->save();
+    /** @var \Drupal\node\NodeInterface $node */
+    $node = Node::create([
+      'type' => 'example',
+      'title' => 'Test title',
+      'langcode' => 'en',
+    ]);
+    $node->save();
+
+    $nid = $node->id();
+
+    $node = Node::load($nid);
+
+    // Creating a translation shouldn't break, even though there's no previous
+    // moderated revision for the new language.
+    $node_fr = $node->addTranslation('fr');
+    $node_fr->setTitle('Francais');
+    $node_fr->save();
+
+    // Enable moderation for our node type.
+    /** @var NodeType $node_type */
+    $node_type = NodeType::load('example');
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']);
+    $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft');
+    $node_type->save();
+
+    // Reload the French version of the node.
+    $node = Node::load($nid);
+    $node_fr = $node->getTranslation('fr');
+
+    /** @var \Drupal\node\NodeInterface $node_fr */
+    $node_fr->setTitle('Nouveau');
+    $node_fr->save();
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f312cde2499eb162dbcf0e99ec7fca5d4e3ba75a
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\content_moderation\Entity\ModerationState;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\Entity\ModerationState
+ *
+ * @group content_moderation
+ */
+class ModerationStateEntityTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['content_moderation'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installEntitySchema('moderation_state');
+  }
+
+  /**
+   * Verify moderation state methods based on entity properties.
+   *
+   * @covers ::isPublishedState
+   * @covers ::isDefaultRevisionState
+   *
+   * @dataProvider moderationStateProvider
+   */
+  public function testModerationStateProperties($published, $default_revision, $is_published, $is_default) {
+    $moderation_state_id = $this->randomMachineName();
+    $moderation_state = ModerationState::create([
+      'id' => $moderation_state_id,
+      'label' => $this->randomString(),
+      'published' => $published,
+      'default_revision' => $default_revision,
+    ]);
+    $moderation_state->save();
+
+    $moderation_state = ModerationState::load($moderation_state_id);
+    $this->assertEquals($is_published, $moderation_state->isPublishedState());
+    $this->assertEquals($is_default, $moderation_state->isDefaultRevisionState());
+  }
+
+  /**
+   * Data provider for ::testModerationStateProperties.
+   */
+  public function moderationStateProvider() {
+    return [
+      // Draft, Needs review; should not touch the default revision.
+      [FALSE, FALSE, FALSE, FALSE],
+      // Published; this state should update and publish the default revision.
+      [TRUE, TRUE, TRUE, TRUE],
+      // Archive; this state should update but not publish the default revision.
+      [FALSE, TRUE, FALSE, TRUE],
+      // We try to prevent creating this state via the UI, but when a moderation
+      // state is a published state, it should also become the default revision.
+      [TRUE, FALSE, TRUE, TRUE],
+    ];
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php b/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c869619c9555a7df1de76fbc5f30d04d6a364f2b
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php
@@ -0,0 +1,159 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\views\Kernel\ViewsKernelTestBase;
+use Drupal\views\Views;
+
+/**
+ * Tests the views integration of content_moderation.
+ *
+ * @group content_moderation
+ */
+class ViewsDataIntegrationTest extends ViewsKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'content_moderation_test_views',
+    'node',
+    'content_moderation',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp($import_test_views = TRUE) {
+    parent::setUp($import_test_views);
+
+    $this->installEntitySchema('node');
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('content_moderation_state');
+    $this->installSchema('node', 'node_access');
+    $this->installConfig('content_moderation_test_views');
+    $this->installConfig('content_moderation');
+
+    $node_type = NodeType::create([
+      'type' => 'page',
+    ]);
+    $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE);
+    $node_type->save();
+  }
+
+  /**
+   * Tests content_moderation_views_data().
+   *
+   * @see content_moderation_views_data()
+   */
+  public function testViewsData() {
+    $node = Node::create([
+      'type' => 'page',
+      'title' => 'Test title first revision',
+    ]);
+    $node->moderation_state->target_id = 'published';
+    $node->save();
+
+    $revision = clone $node;
+    $revision->setNewRevision(TRUE);
+    $revision->isDefaultRevision(FALSE);
+    $revision->title->value = 'Test title second revision';
+    $revision->moderation_state->target_id = 'draft';
+    $revision->save();
+
+    $view = Views::getView('test_content_moderation_latest_revision');
+    $view->execute();
+
+    // Ensure that the content_revision_tracker contains the right latest
+    // revision ID.
+    // Also ensure that the relationship back to the revision table contains the
+    // right latest revision.
+    $expected_result = [
+      [
+        'nid' => $node->id(),
+        'revision_id' => $revision->getRevisionId(),
+        'title' => $revision->label(),
+        'moderation_state_1' => 'draft',
+        'moderation_state' => 'published',
+      ],
+    ];
+    $this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid', 'content_revision_tracker_revision_id' => 'revision_id', 'moderation_state' => 'moderation_state', 'moderation_state_1' => 'moderation_state_1']);
+  }
+
+  /**
+   * Tests the join from the revision data table to the moderation state table.
+   */
+  public function testContentModerationStateRevisionJoin() {
+    $node = Node::create([
+      'type' => 'page',
+      'title' => 'Test title first revision',
+    ]);
+    $node->moderation_state->target_id = 'published';
+    $node->save();
+
+    $revision = clone $node;
+    $revision->setNewRevision(TRUE);
+    $revision->isDefaultRevision(FALSE);
+    $revision->title->value = 'Test title second revision';
+    $revision->moderation_state->target_id = 'draft';
+    $revision->save();
+
+    $view = Views::getView('test_content_moderation_revision_test');
+    $view->execute();
+
+    $expected_result = [
+      [
+        'revision_id' => $node->getRevisionId(),
+        'moderation_state' => 'published',
+      ],
+      [
+        'revision_id' => $revision->getRevisionId(),
+        'moderation_state' => 'draft',
+      ],
+    ];
+    $this->assertIdenticalResultset($view, $expected_result, ['revision_id' => 'revision_id', 'moderation_state' => 'moderation_state']);
+  }
+
+  /**
+   * Tests the join from the data table to the moderation state table.
+   */
+  public function testContentModerationStateBaseJoin() {
+    $node = Node::create([
+      'type' => 'page',
+      'title' => 'Test title first revision',
+    ]);
+    $node->moderation_state->target_id = 'published';
+    $node->save();
+
+    $revision = clone $node;
+    $revision->setNewRevision(TRUE);
+    $revision->isDefaultRevision(FALSE);
+    $revision->title->value = 'Test title second revision';
+    $revision->moderation_state->target_id = 'draft';
+    $revision->save();
+
+    $view = Views::getView('test_content_moderation_base_table_test');
+    $view->execute();
+
+    $expected_result = [
+      [
+        'nid' => $node->id(),
+        // @todo I would have expected that the content_moderation_state default
+        //   revision is the same one as in the node, but it isn't.
+        // Joins from the base table to the default revision of the
+        // content_moderation.
+        'moderation_state' => 'draft',
+        // Joins from the revision table to the default revision of the
+        // content_moderation.
+        'moderation_state_1' => 'draft',
+        // Joins from the revision table to the revision of the
+        // content_moderation.
+        'moderation_state_2' => 'published',
+      ],
+    ];
+    $this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid', 'moderation_state' => 'moderation_state', 'moderation_state_1' => 'moderation_state_1', 'moderation_state_2' => 'moderation_state_2']);
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5de0b2a977c6508a312077bb0369df099a554301
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\content_moderation\ContentPreprocess;
+use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\node\Entity\Node;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\ContentPreprocess
+ *
+ * @group content_moderation
+ */
+class ContentPreprocessTest extends \PHPUnit_Framework_TestCase {
+
+  /**
+   * @covers ::isLatestVersionPage
+   * @dataProvider routeNodeProvider
+   */
+  public function testIsLatestVersionPage($route_name, $route_nid, $check_nid, $result, $message) {
+    $content_preprocess = new ContentPreprocess($this->setupCurrentRouteMatch($route_name, $route_nid));
+    $node = $this->setupNode($check_nid);
+    $this->assertEquals($result, $content_preprocess->isLatestVersionPage($node), $message);
+  }
+
+  /**
+   * Data provider for self::testIsLatestVersionPage().
+   */
+  public function routeNodeProvider() {
+    return [
+      ['entity.node.canonical', 1, 1, FALSE, 'Not on the latest version tab route.'],
+      ['entity.node.latest_version', 1, 1, TRUE, 'On the latest version tab route, with the route node.'],
+      ['entity.node.latest_version', 1, 2, FALSE, 'On the latest version tab route, with a different node.'],
+    ];
+  }
+
+  /**
+   * Mock the current route matching object.
+   *
+   * @param string $route_name
+   *   The route to mock.
+   * @param int $nid
+   *   The node ID for mocking.
+   *
+   * @return \Drupal\Core\Routing\CurrentRouteMatch
+   *   The mocked current route match object.
+   */
+  protected function setupCurrentRouteMatch($route_name, $nid) {
+    $route_match = $this->prophesize(CurrentRouteMatch::class);
+    $route_match->getRouteName()->willReturn($route_name);
+    $route_match->getParameter('node')->willReturn($this->setupNode($nid));
+
+    return $route_match->reveal();
+  }
+
+  /**
+   * Mock a node object.
+   *
+   * @param int $nid
+   *   The node ID to mock.
+   *
+   * @return \Drupal\node\Entity\Node
+   *   The mocked node.
+   */
+  protected function setupNode($nid) {
+    $node = $this->prophesize(Node::class);
+    $node->id()->willReturn($nid);
+
+    return $node->reveal();
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1f8838b7741dc0133c580b5a9a333375714209fd
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\Core\Access\AccessResultAllowed;
+use Drupal\Core\Access\AccessResultForbidden;
+use Drupal\Core\Routing\RouteMatch;
+use Drupal\node\Entity\Node;
+use Drupal\content_moderation\Access\LatestRevisionCheck;
+use Drupal\content_moderation\ModerationInformation;
+use Symfony\Component\Routing\Route;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\Access\LatestRevisionCheck
+ * @group content_moderation
+ */
+class LatestRevisionCheckTest extends \PHPUnit_Framework_TestCase {
+
+  /**
+   * Test the access check of the LatestRevisionCheck service.
+   *
+   * @param string $entity_class
+   *   The class of the entity to mock.
+   * @param string $entity_type
+   *   The machine name of the entity to mock.
+   * @param bool $has_forward
+   *   Whether this entity should have a forward revision in the system.
+   * @param string $result_class
+   *   The AccessResult class that should result. One of AccessResultAllowed,
+   *   AccessResultForbidden, AccessResultNeutral.
+   *
+   * @dataProvider accessSituationProvider
+   */
+  public function testLatestAccessPermissions($entity_class, $entity_type, $has_forward, $result_class) {
+
+    /** @var \Drupal\Core\Entity\EntityInterface $entity */
+    $entity = $this->prophesize($entity_class);
+    $entity->getCacheContexts()->willReturn([]);
+    $entity->getCacheTags()->willReturn([]);
+    $entity->getCacheMaxAge()->willReturn(0);
+
+    /** @var \Drupal\content_moderation\ModerationInformation $mod_info */
+    $mod_info = $this->prophesize(ModerationInformation::class);
+    $mod_info->hasForwardRevision($entity->reveal())->willReturn($has_forward);
+
+    $route = $this->prophesize(Route::class);
+
+    $route->getOption('_content_moderation_entity_type')->willReturn($entity_type);
+
+    $route_match = $this->prophesize(RouteMatch::class);
+    $route_match->getParameter($entity_type)->willReturn($entity->reveal());
+
+    $lrc = new LatestRevisionCheck($mod_info->reveal());
+
+    /** @var \Drupal\Core\Access\AccessResult $result */
+    $result = $lrc->access($route->reveal(), $route_match->reveal());
+
+    $this->assertInstanceOf($result_class, $result);
+
+  }
+
+  /**
+   * Data provider for testLastAccessPermissions().
+   */
+  public function accessSituationProvider() {
+    return [
+      [Node::class, 'node', TRUE, AccessResultAllowed::class],
+      [Node::class, 'node', FALSE, AccessResultForbidden::class],
+      [BlockContent::class, 'block_content', TRUE, AccessResultAllowed::class],
+      [BlockContent::class, 'block_content', FALSE, AccessResultForbidden::class],
+    ];
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..6833cdbc66595f7d7ecb3ea0f2fe4cf9167f958e
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Entity\ContentEntityFormInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\ContentEntityType;
+use Drupal\Core\Entity\EntityFormInterface;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\content_moderation\ModerationInformation;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\ModerationInformation
+ * @group content_moderation
+ */
+class ModerationInformationTest extends \PHPUnit_Framework_TestCase {
+
+  /**
+   * Builds a mock user.
+   *
+   * @return AccountInterface
+   *   The mocked user.
+   */
+  protected function getUser() {
+    return $this->prophesize(AccountInterface::class)->reveal();
+  }
+
+  /**
+   * Returns a mock Entity Type Manager.
+   *
+   * @param \Drupal\Core\Entity\EntityStorageInterface $entity_bundle_storage
+   *   Entity bundle storage.
+   *
+   * @return EntityTypeManagerInterface
+   *   The mocked entity type manager.
+   */
+  protected function getEntityTypeManager(EntityStorageInterface $entity_bundle_storage) {
+    $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class);
+    $entity_type_manager->getStorage('entity_test_bundle')->willReturn($entity_bundle_storage);
+    return $entity_type_manager->reveal();
+  }
+
+  /**
+   * Sets up content moderation and entity manager mocking.
+   *
+   * @param bool $status
+   *   TRUE if content_moderation should be enabled, FALSE if not.
+   *
+   * @return \Drupal\Core\Entity\EntityTypeManagerInterface
+   *   The mocked entity type manager.
+   */
+  public function setupModerationEntityManager($status) {
+    $bundle = $this->prophesize(ConfigEntityInterface::class);
+    $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)->willReturn($status);
+
+    $entity_storage = $this->prophesize(EntityStorageInterface::class);
+    $entity_storage->load('test_bundle')->willReturn($bundle->reveal());
+
+    return $this->getEntityTypeManager($entity_storage->reveal());
+  }
+
+  /**
+   * @dataProvider providerBoolean
+   * @covers ::isModeratableEntity
+   */
+  public function testIsModeratableEntity($status) {
+    $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser());
+
+    $entity_type = new ContentEntityType([
+      'id' => 'test_entity_type',
+      'bundle_entity_type' => 'entity_test_bundle',
+    ]);
+    $entity = $this->prophesize(ContentEntityInterface::class);
+    $entity->getEntityType()->willReturn($entity_type);
+    $entity->bundle()->willReturn('test_bundle');
+
+    $this->assertEquals($status, $moderation_information->isModeratableEntity($entity->reveal()));
+  }
+
+  /**
+   * @covers ::isModeratableEntity
+   */
+  public function testIsModeratableEntityForNonBundleEntityType() {
+    $entity_type = new ContentEntityType([
+      'id' => 'test_entity_type',
+    ]);
+    $entity = $this->prophesize(ContentEntityInterface::class);
+    $entity->getEntityType()->willReturn($entity_type);
+    $entity->bundle()->willReturn('test_entity_type');
+
+    $entity_storage = $this->prophesize(EntityStorageInterface::class);
+    $entity_type_manager = $this->getEntityTypeManager($entity_storage->reveal());
+    $moderation_information = new ModerationInformation($entity_type_manager, $this->getUser());
+
+    $this->assertEquals(FALSE, $moderation_information->isModeratableEntity($entity->reveal()));
+  }
+
+  /**
+   * @dataProvider providerBoolean
+   * @covers ::isModeratableBundle
+   */
+  public function testIsModeratableBundle($status) {
+    $entity_type = new ContentEntityType([
+      'id' => 'test_entity_type',
+      'bundle_entity_type' => 'entity_test_bundle',
+    ]);
+
+    $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser());
+
+    $this->assertEquals($status, $moderation_information->isModeratableBundle($entity_type, 'test_bundle'));
+  }
+
+  /**
+   * @dataProvider providerBoolean
+   * @covers ::isModeratedEntityForm
+   */
+  public function testIsModeratedEntityForm($status) {
+    $entity_type = new ContentEntityType([
+      'id' => 'test_entity_type',
+      'bundle_entity_type' => 'entity_test_bundle',
+    ]);
+
+    $entity = $this->prophesize(ContentEntityInterface::class);
+    $entity->getEntityType()->willReturn($entity_type);
+    $entity->bundle()->willReturn('test_bundle');
+
+    $form = $this->prophesize(ContentEntityFormInterface::class);
+    $form->getEntity()->willReturn($entity);
+
+    $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser());
+
+    $this->assertEquals($status, $moderation_information->isModeratedEntityForm($form->reveal()));
+  }
+
+  /**
+   * @covers ::isModeratedEntityForm
+   */
+  public function testIsModeratedEntityFormWithNonContentEntityForm() {
+    $form = $this->prophesize(EntityFormInterface::class);
+    $moderation_information = new ModerationInformation($this->setupModerationEntityManager(TRUE), $this->getUser());
+
+    $this->assertFalse($moderation_information->isModeratedEntityForm($form->reveal()));
+  }
+
+  /**
+   * Data provider for several tests.
+   */
+  public function providerBoolean() {
+    return [
+      [FALSE],
+      [TRUE],
+    ];
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b0574788724d160713df62ff4dc544e9de740b4c
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php
@@ -0,0 +1,297 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Entity\Query\QueryFactory;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\content_moderation\ModerationStateInterface;
+use Drupal\content_moderation\ModerationStateTransitionInterface;
+use Drupal\content_moderation\StateTransitionValidation;
+use Prophecy\Argument;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\StateTransitionValidation
+ * @group content_moderation
+ */
+class StateTransitionValidationTest extends \PHPUnit_Framework_TestCase {
+
+  /**
+   * Builds a mock storage object for Transitions.
+   *
+   * @return EntityStorageInterface
+   *   The mocked storage object for Transitions.
+   */
+  protected function setupTransitionStorage() {
+    $entity_storage = $this->prophesize(EntityStorageInterface::class);
+
+    $list = $this->setupTransitionEntityList();
+    $entity_storage->loadMultiple()->willReturn($list);
+    $entity_storage->loadMultiple(Argument::type('array'))->will(function ($args) use ($list) {
+      $keys = $args[0];
+      if (empty($keys)) {
+        return $list;
+      }
+
+      $return = array_map(function($key) use ($list) {
+        return $list[$key];
+      }, $keys);
+
+      return $return;
+    });
+    return $entity_storage->reveal();
+  }
+
+  /**
+   * Builds an array of mocked Transition objects.
+   *
+   * @return ModerationStateTransitionInterface[]
+   *   An array of mocked Transition objects.
+   */
+  protected function setupTransitionEntityList() {
+    $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+    $transition->id()->willReturn('draft__needs_review');
+    $transition->getFromState()->willReturn('draft');
+    $transition->getToState()->willReturn('needs_review');
+    $list[$transition->reveal()->id()] = $transition->reveal();
+
+    $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+    $transition->id()->willReturn('needs_review__staging');
+    $transition->getFromState()->willReturn('needs_review');
+    $transition->getToState()->willReturn('staging');
+    $list[$transition->reveal()->id()] = $transition->reveal();
+
+    $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+    $transition->id()->willReturn('staging__published');
+    $transition->getFromState()->willReturn('staging');
+    $transition->getToState()->willReturn('published');
+    $list[$transition->reveal()->id()] = $transition->reveal();
+
+    $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+    $transition->id()->willReturn('needs_review__draft');
+    $transition->getFromState()->willReturn('needs_review');
+    $transition->getToState()->willReturn('draft');
+    $list[$transition->reveal()->id()] = $transition->reveal();
+
+    $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+    $transition->id()->willReturn('draft__draft');
+    $transition->getFromState()->willReturn('draft');
+    $transition->getToState()->willReturn('draft');
+    $list[$transition->reveal()->id()] = $transition->reveal();
+
+    $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+    $transition->id()->willReturn('needs_review__needs_review');
+    $transition->getFromState()->willReturn('needs_review');
+    $transition->getToState()->willReturn('needs_review');
+    $list[$transition->reveal()->id()] = $transition->reveal();
+
+    $transition = $this->prophesize(ModerationStateTransitionInterface::class);
+    $transition->id()->willReturn('published__published');
+    $transition->getFromState()->willReturn('published');
+    $transition->getToState()->willReturn('published');
+    $list[$transition->reveal()->id()] = $transition->reveal();
+
+    return $list;
+  }
+
+  /**
+   * Builds a mock storage object for States.
+   *
+   * @return EntityStorageInterface
+   *   The mocked storage object for States.
+   */
+  protected function setupStateStorage() {
+    $entity_storage = $this->prophesize(EntityStorageInterface::class);
+
+    $state = $this->prophesize(ModerationStateInterface::class);
+    $state->id()->willReturn('draft');
+    $state->label()->willReturn('Draft');
+    $state->isPublishedState()->willReturn(FALSE);
+    $state->isDefaultRevisionState()->willReturn(FALSE);
+    $states['draft'] = $state->reveal();
+
+    $state = $this->prophesize(ModerationStateInterface::class);
+    $state->id()->willReturn('needs_review');
+    $state->label()->willReturn('Needs Review');
+    $state->isPublishedState()->willReturn(FALSE);
+    $state->isDefaultRevisionState()->willReturn(FALSE);
+    $states['needs_review'] = $state->reveal();
+
+    $state = $this->prophesize(ModerationStateInterface::class);
+    $state->id()->willReturn('staging');
+    $state->label()->willReturn('Staging');
+    $state->isPublishedState()->willReturn(FALSE);
+    $state->isDefaultRevisionState()->willReturn(FALSE);
+    $states['staging'] = $state->reveal();
+
+    $state = $this->prophesize(ModerationStateInterface::class);
+    $state->id()->willReturn('published');
+    $state->label()->willReturn('Published');
+    $state->isPublishedState()->willReturn(TRUE);
+    $state->isDefaultRevisionState()->willReturn(TRUE);
+    $states['published'] = $state->reveal();
+
+    $state = $this->prophesize(ModerationStateInterface::class);
+    $state->id()->willReturn('archived');
+    $state->label()->willReturn('Archived');
+    $state->isPublishedState()->willReturn(TRUE);
+    $state->isDefaultRevisionState()->willReturn(TRUE);
+    $states['archived'] = $state->reveal();
+
+    $entity_storage->loadMultiple()->willReturn($states);
+
+    foreach ($states as $id => $state) {
+      $entity_storage->load($id)->willReturn($state);
+    }
+
+    return $entity_storage->reveal();
+  }
+
+  /**
+   * Builds a mocked Entity Type Manager.
+   *
+   * @return EntityTypeManagerInterface
+   *   The mocked Entity Type Manager.
+   */
+  protected function setupEntityTypeManager(EntityStorageInterface $storage) {
+    $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
+    $entityTypeManager->getStorage('moderation_state')->willReturn($storage);
+    $entityTypeManager->getStorage('moderation_state_transition')->willReturn($this->setupTransitionStorage());
+
+    return $entityTypeManager->reveal();
+  }
+
+  /**
+   * Builds a mocked query factory that does nothing.
+   *
+   * @return QueryFactory
+   *   The mocked query factory that does nothing.
+   */
+  protected function setupQueryFactory() {
+    $factory = $this->prophesize(QueryFactory::class);
+
+    return $factory->reveal();
+  }
+
+  /**
+   * @covers ::isTransitionAllowed
+   * @covers ::calculatePossibleTransitions
+   *
+   * @dataProvider providerIsTransitionAllowedWithValidTransition
+   */
+  public function testIsTransitionAllowedWithValidTransition($from_id, $to_id) {
+    $storage = $this->setupStateStorage();
+    $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager($storage), $this->setupQueryFactory());
+    $this->assertTrue($state_transition_validation->isTransitionAllowed($storage->load($from_id), $storage->load($to_id)));
+  }
+
+  /**
+   * Data provider for self::testIsTransitionAllowedWithValidTransition().
+   */
+  public function providerIsTransitionAllowedWithValidTransition() {
+    return [
+      ['draft', 'draft'],
+      ['draft', 'needs_review'],
+      ['needs_review', 'needs_review'],
+      ['needs_review', 'staging'],
+      ['staging', 'published'],
+      ['needs_review', 'draft'],
+    ];
+  }
+
+  /**
+   * @covers ::isTransitionAllowed
+   * @covers ::calculatePossibleTransitions
+   *
+   * @dataProvider providerIsTransitionAllowedWithInValidTransition
+   */
+  public function testIsTransitionAllowedWithInValidTransition($from_id, $to_id) {
+    $storage = $this->setupStateStorage();
+    $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager($storage), $this->setupQueryFactory());
+    $this->assertFalse($state_transition_validation->isTransitionAllowed($storage->load($from_id), $storage->load($to_id)));
+  }
+
+  /**
+   * Data provider for self::testIsTransitionAllowedWithInValidTransition().
+   */
+  public function providerIsTransitionAllowedWithInValidTransition() {
+    return [
+      ['published', 'needs_review'],
+      ['published', 'staging'],
+      ['staging', 'needs_review'],
+      ['staging', 'staging'],
+      ['needs_review', 'published'],
+      ['published', 'archived'],
+      ['archived', 'published'],
+    ];
+  }
+
+  /**
+   * Verifies user-aware transition validation.
+   *
+   * @param string $from_id
+   *   The state to transition from.
+   * @param string $to_id
+   *   The state to transition to.
+   * @param string $permission
+   *   The permission to give the user, or not.
+   * @param bool $allowed
+   *   Whether or not to grant a user this permission.
+   * @param bool $result
+   *   Whether userMayTransition() is expected to return TRUE or FALSE.
+   *
+   * @dataProvider userTransitionsProvider
+   */
+  public function testUserSensitiveValidTransitions($from_id, $to_id, $permission, $allowed, $result) {
+    $user = $this->prophesize(AccountInterface::class);
+    // The one listed permission will be returned as instructed; Any others are
+    // always denied.
+    $user->hasPermission($permission)->willReturn($allowed);
+    $user->hasPermission(Argument::type('string'))->willReturn(FALSE);
+
+    $storage = $this->setupStateStorage();
+    $validator = new Validator($this->setupEntityTypeManager($storage), $this->setupQueryFactory());
+
+    $this->assertEquals($result, $validator->userMayTransition($storage->load($from_id), $storage->load($to_id), $user->reveal()));
+  }
+
+  /**
+   * Data provider for the user transition test.
+   */
+  public function userTransitionsProvider() {
+    // The user has the right permission, so let it through.
+    $ret[] = ['draft', 'draft', 'use draft__draft transition', TRUE, TRUE];
+
+    // The user doesn't have the right permission, block it.
+    $ret[] = ['draft', 'draft', 'use draft__draft transition', FALSE, FALSE];
+
+    // The user has some other permission that doesn't matter.
+    $ret[] = ['draft', 'draft', 'use draft__needs_review transition', TRUE, FALSE];
+
+    // The user has permission, but the transition isn't allowed anyway.
+    $ret[] = ['published', 'needs_review', 'use published__needs_review transition', TRUE, FALSE];
+
+    return $ret;
+  }
+
+}
+
+/**
+ * Testable subclass for selected tests.
+ *
+ * EntityQuery is beyond untestable, so we have to subclass and override the
+ * method that uses it.
+ */
+class Validator extends StateTransitionValidation {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) {
+    if ($from->id() === 'draft' && $to->id() === 'draft') {
+      return $this->transitionStorage()->loadMultiple(['draft__draft'])[0];
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
index 5e2265de28540da3761e7deb5caf46c6a574dcaf..f4e242df19e39eaeafd97e4e45568611ba308231 100644
--- a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
@@ -24,6 +24,7 @@ class StableTemplateOverrideTest extends KernelTestBase {
    */
   protected $templatesToSkip = [
     'views-form-views-form',
+    'entity-moderation-form'
   ];
 
   /**