diff --git a/core/composer.json b/core/composer.json
index 15fa324..1a27140 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -61,6 +61,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/includes/file.inc b/core/includes/file.inc
index 017a3d6..e6490e9 100644
--- a/core/includes/file.inc
+++ b/core/includes/file.inc
@@ -412,7 +412,7 @@ function file_valid_uri($uri) {
 }
 
 /**
- * Copies a file to a new location without database changes or hook invocation.
+ * Copies a file to a new location without invoking the file API.
  *
  * This is a powerful function that in many ways performs like an advanced
  * version of copy().
@@ -422,9 +422,10 @@ function file_valid_uri($uri) {
  * - If the $source and $destination are equal, the behavior depends on the
  *   $replace parameter. FILE_EXISTS_REPLACE will error out. FILE_EXISTS_RENAME
  *   will rename the file until the $destination is unique.
- * - Works around a PHP bug where copy() does not properly support streams if
- *   safe_mode or open_basedir are enabled.
- *   @see https://bugs.php.net/bug.php?id=60456
+ * - Provides a fallback using realpaths if the move fails using stream
+ *   wrappers. This can occur because PHP's copy() function does not properly
+ *   support streams if open_basedir is enabled. See
+ *   https://bugs.php.net/bug.php?id=60456
  *
  * @param $source
  *   A string specifying the filepath or URI of the source file.
@@ -445,66 +446,18 @@ function file_valid_uri($uri) {
  * @see file_copy()
  */
 function file_unmanaged_copy($source, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
-  if (!file_unmanaged_prepare($source, $destination, $replace)) {
-    return FALSE;
-  }
-  // Attempt to resolve the URIs. This is necessary in certain configurations
-  // (see above).
-  $real_source = drupal_realpath($source) ?: $source;
-  $real_destination = drupal_realpath($destination) ?: $destination;
-  // Perform the copy operation.
-  if (!@copy($real_source, $real_destination)) {
-    \Drupal::logger('file')->error('The specified file %file could not be copied to %destination.', array('%file' => $source, '%destination' => $destination));
-    return FALSE;
-  }
-  // Set the permissions on the new file.
-  drupal_chmod($destination);
-  return $destination;
-}
-
-/**
- * Internal function that prepares the destination for a file_unmanaged_copy or
- * file_unmanaged_move operation.
- *
- * - Checks if $source and $destination are valid and readable/writable.
- * - Checks that $source is not equal to $destination; if they are an error
- *   is reported.
- * - If file already exists in $destination either the call will error out,
- *   replace the file or rename the file based on the $replace parameter.
- *
- * @param $source
- *   A string specifying the filepath or URI of the source file.
- * @param $destination
- *   A URI containing the destination that $source should be moved/copied to.
- *   The URI may be a bare filepath (without a scheme) and in that case the
- *   default scheme (file://) will be used. If this value is omitted, Drupal's
- *   default files scheme will be used, usually "public://".
- * @param $replace
- *   Replace behavior when the destination file already exists:
- *   - FILE_EXISTS_REPLACE - Replace the existing file.
- *   - FILE_EXISTS_RENAME - Append _{incrementing number} until the filename is
- *       unique.
- *   - FILE_EXISTS_ERROR - Do nothing and return FALSE.
- *
- * @return
- *   TRUE, or FALSE in the event of an error.
- *
- * @see file_unmanaged_copy()
- * @see file_unmanaged_move()
- */
-function file_unmanaged_prepare($source, &$destination = NULL, $replace = FILE_EXISTS_RENAME) {
   $original_source = $source;
   $logger = \Drupal::logger('file');
 
   // Assert that the source file actually exists.
   if (!file_exists($source)) {
     // @todo Replace drupal_set_message() calls with exceptions instead.
-    drupal_set_message(t('The specified file %file could not be moved/copied because no file by that name exists. Please check that you supplied the correct filename.', array('%file' => $original_source)), 'error');
+    drupal_set_message(t('The specified file %file could not be copied because no file by that name exists. Please check that you supplied the correct filename.', array('%file' => $original_source)), 'error');
     if (($realpath = drupal_realpath($original_source)) !== FALSE) {
-      $logger->notice('File %file (%realpath) could not be moved/copied because it does not exist.', array('%file' => $original_source, '%realpath' => $realpath));
+      $logger->notice('File %file (%realpath) could not be copied because it does not exist.', array('%file' => $original_source, '%realpath' => $realpath));
     }
     else {
-      $logger->notice('File %file could not be moved/copied because it does not exist.', array('%file' => $original_source));
+      $logger->notice('File %file could not be copied because it does not exist.', array('%file' => $original_source));
     }
     return FALSE;
   }
@@ -525,8 +478,8 @@ function file_unmanaged_prepare($source, &$destination = NULL, $replace = FILE_E
     $dirname = drupal_dirname($destination);
     if (!file_prepare_directory($dirname)) {
       // The destination is not valid.
-      $logger->notice('File %file could not be moved/copied because the destination directory %destination is not configured correctly.', array('%file' => $original_source, '%destination' => $dirname));
-      drupal_set_message(t('The specified file %file could not be moved/copied because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions. More information is available in the system log.', array('%file' => $original_source)), 'error');
+      $logger->notice('File %file could not be copied because the destination directory %destination is not configured correctly.', array('%file' => $original_source, '%destination' => $dirname));
+      drupal_set_message(t('The specified file %file could not be copied because the destination directory is not properly configured. This may be caused by a problem with file or directory permissions. More information is available in the system log.', array('%file' => $original_source)), 'error');
       return FALSE;
     }
   }
@@ -534,8 +487,8 @@ function file_unmanaged_prepare($source, &$destination = NULL, $replace = FILE_E
   // Determine whether we can perform this operation based on overwrite rules.
   $destination = file_destination($destination, $replace);
   if ($destination === FALSE) {
-    drupal_set_message(t('The file %file could not be moved/copied because a file by that name already exists in the destination directory.', array('%file' => $original_source)), 'error');
-    $logger->notice('File %file could not be moved/copied because a file by that name already exists in the destination directory (%destination)', array('%file' => $original_source, '%destination' => $destination));
+    drupal_set_message(t('The file %file could not be copied because a file by that name already exists in the destination directory.', array('%file' => $original_source)), 'error');
+    $logger->notice('File %file could not be copied because a file by that name already exists in the destination directory (%destination)', array('%file' => $original_source, '%destination' => $destination));
     return FALSE;
   }
 
@@ -543,13 +496,26 @@ function file_unmanaged_prepare($source, &$destination = NULL, $replace = FILE_E
   $real_source = drupal_realpath($source);
   $real_destination = drupal_realpath($destination);
   if ($source == $destination || ($real_source !== FALSE) && ($real_source == $real_destination)) {
-    drupal_set_message(t('The specified file %file was not moved/copied because it would overwrite itself.', array('%file' => $source)), 'error');
-    $logger->notice('File %file could not be moved/copied because it would overwrite itself.', array('%file' => $source));
+    drupal_set_message(t('The specified file %file was not copied because it would overwrite itself.', array('%file' => $source)), 'error');
+    $logger->notice('File %file could not be copied because it would overwrite itself.', array('%file' => $source));
     return FALSE;
   }
   // Make sure the .htaccess files are present.
   file_ensure_htaccess();
-  return TRUE;
+  // Perform the copy operation.
+  if (!@copy($source, $destination)) {
+    // If the copy failed and realpaths exist, retry the operation using them
+    // instead.
+    if ($real_source === FALSE || $real_destination === FALSE || !@copy($real_source, $real_destination)) {
+      $logger->error('The specified file %file could not be copied to %destination.', array('%file' => $source, '%destination' => $destination));
+      return FALSE;
+    }
+  }
+
+  // Set the permissions on the new file.
+  drupal_chmod($destination);
+
+  return $destination;
 }
 
 /**
@@ -600,24 +566,12 @@ function file_destination($destination, $replace) {
 /**
  * Moves a file to a new location without database changes or hook invocation.
  *
- * This is a powerful function that in many ways performs like an advanced
- * version of rename().
- * - Checks if $source and $destination are valid and readable/writable.
- * - Checks that $source is not equal to $destination; if they are an error
- *   is reported.
- * - If file already exists in $destination either the call will error out,
- *   replace the file or rename the file based on the $replace parameter.
- * - Works around a PHP bug where rename() does not properly support streams if
- *   safe_mode or open_basedir are enabled.
- *   @see https://bugs.php.net/bug.php?id=60456
- *
  * @param $source
- *   A string specifying the filepath or URI of the source file.
+ *   A string specifying the filepath or URI of the original file.
  * @param $destination
- *   A URI containing the destination that $source should be moved to. The
- *   URI may be a bare filepath (without a scheme) and in that case the default
- *   scheme (file://) will be used. If this value is omitted, Drupal's default
- *   files scheme will be used, usually "public://".
+ *   A string containing the destination that $source should be moved to.
+ *   This must be a stream wrapper URI. If this value is omitted, Drupal's
+ *   default files scheme will be used, usually "public://".
  * @param $replace
  *   Replace behavior when the destination file already exists:
  *   - FILE_EXISTS_REPLACE - Replace the existing file.
@@ -626,37 +580,16 @@ function file_destination($destination, $replace) {
  *   - FILE_EXISTS_ERROR - Do nothing and return FALSE.
  *
  * @return
- *   The path to the new file, or FALSE in the event of an error.
+ *   The URI of the moved file, or FALSE in the event of an error.
  *
  * @see file_move()
  */
 function file_unmanaged_move($source, $destination = NULL, $replace = FILE_EXISTS_RENAME) {
-  if (!file_unmanaged_prepare($source, $destination, $replace)) {
+  $filepath = file_unmanaged_copy($source, $destination, $replace);
+  if ($filepath == FALSE || file_unmanaged_delete($source) == FALSE) {
     return FALSE;
   }
-  // Ensure compatibility with Windows.
-  // @see drupal_unlink()
-  if ((substr(PHP_OS, 0, 3) == 'WIN') && (!file_stream_wrapper_valid_scheme(file_uri_scheme($source)))) {
-    chmod($source, 0600);
-  }
-  // Attempt to resolve the URIs. This is necessary in certain configurations
-  // (see above) and can also permit fast moves across local schemes.
-  $real_source = drupal_realpath($source) ?: $source;
-  $real_destination = drupal_realpath($destination) ?: $destination;
-  // Perform the move operation.
-  if (!@rename($real_source, $real_destination)) {
-    // Fall back to slow copy and unlink procedure. This is necessary for
-    // renames across schemes that are not local, or where rename() has not been
-    // implemented. It's not necessary to use drupal_unlink() as the Windows
-    // issue has already been resolved above.
-    if (!@copy($real_source, $real_destination) || !@unlink($real_source)) {
-      \Drupal::logger('file')->error('The specified file %file could not be moved to %destination.', array('%file' => $source, '%destination' => $destination));
-      return FALSE;
-    }
-  }
-  // Set the permissions on the new file.
-  drupal_chmod($destination);
-  return $destination;
+  return $filepath;
 }
 
 /**
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 3fd6b0a..9b89250 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -134,8 +134,7 @@ function drupal_theme_rebuild() {
  */
 function drupal_find_theme_functions($cache, $prefixes) {
   $implementations = [];
-  $functions = get_defined_functions();
-  $theme_functions = preg_grep('/^(' . implode(')|(', $prefixes) . ')_/', $functions['user']);
+  $grouped_functions = \Drupal::service('theme.registry')->getPrefixGroupedUserFunctions();
 
   foreach ($cache as $hook => $info) {
     foreach ($prefixes as $prefix) {
@@ -151,8 +150,10 @@ function drupal_find_theme_functions($cache, $prefixes) {
       // are found using the base hook's pattern, not a pattern from an
       // intermediary suggestion.
       $pattern = isset($info['pattern']) ? $info['pattern'] : ($hook . '__');
-      if (!isset($info['base hook']) && !empty($pattern)) {
-        $matches = preg_grep('/^' . $prefix . '_' . $pattern . '/', $theme_functions);
+      // Grep only the functions which are within the prefix group.
+      list($first_prefix,) = explode('_', $prefix, 2);
+      if (!isset($info['base hook']) && !empty($pattern) && isset($grouped_functions[$first_prefix])) {
+        $matches = preg_grep('/^' . $prefix . '_' . $pattern . '/', $grouped_functions[$first_prefix]);
         if ($matches) {
           foreach ($matches as $match) {
             $new_hook = substr($match, strlen($prefix) + 1);
diff --git a/core/lib/Drupal/Core/Cache/ChainedFastBackend.php b/core/lib/Drupal/Core/Cache/ChainedFastBackend.php
index 5c0750d..62839c2 100644
--- a/core/lib/Drupal/Core/Cache/ChainedFastBackend.php
+++ b/core/lib/Drupal/Core/Cache/ChainedFastBackend.php
@@ -87,16 +87,8 @@ class ChainedFastBackend implements CacheBackendInterface, CacheTagsInvalidatorI
    *   The fast cache backend.
    * @param string $bin
    *   The cache bin for which the object is created.
-   *
-   * @throws \Exception
-   *   When the consistent cache backend and the fast cache backend are the same
-   *   service.
    */
   public function __construct(CacheBackendInterface $consistent_backend, CacheBackendInterface $fast_backend, $bin) {
-    if ($consistent_backend == $fast_backend) {
-      // @todo: should throw a proper exception. See https://www.drupal.org/node/2751847.
-      trigger_error('Consistent cache backend and fast cache backend cannot use the same service.', E_USER_ERROR);
-    }
     $this->consistentBackend = $consistent_backend;
     $this->fastBackend = $fast_backend;
     $this->bin = 'cache_' . $bin;
diff --git a/core/modules/block/migration_templates/d7_block.yml b/core/modules/block/migration_templates/d7_block.yml
index ff56111..32e8f5d 100644
--- a/core/modules/block/migration_templates/d7_block.yml
+++ b/core/modules/block/migration_templates/d7_block.yml
@@ -19,9 +19,6 @@ process:
         - module
         - delta
       delimiter: _
-    -
-      plugin: machine_name
-      field: id
   plugin:
     -
       plugin: static_map
diff --git a/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php b/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php
index 571c210..9c3ff5f 100644
--- a/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php
+++ b/core/modules/block/tests/src/Kernel/Migrate/d7/MigrateBlockTest.php
@@ -108,7 +108,7 @@ public function testBlockMigration() {
     $this->assertEntity('bartik_system_main', 'system_main_block', [], '', 'content', 'bartik', 0, '', '0');
     $this->assertEntity('bartik_search_form', 'search_form_block', [], '', 'sidebar_first', 'bartik', -1, '', '0');
     $this->assertEntity('bartik_user_login', 'user_login_block', [], '', 'sidebar_first', 'bartik', 0, '', '0');
-    $this->assertEntity('bartik_system_powered_by', 'system_powered_by_block', [], '', 'footer', 'bartik', 10, '', '0');
+    $this->assertEntity('bartik_system_powered-by', 'system_powered_by_block', [], '', 'footer', 'bartik', 10, '', '0');
     $this->assertEntity('seven_system_main', 'system_main_block', [], '', 'content', 'seven', 0, '', '0');
     $this->assertEntity('seven_user_login', 'user_login_block', [], '', 'content', 'seven', 10, '', '0');
 
diff --git a/core/modules/config_translation/migration_templates/d6_i18n_system_site.yml b/core/modules/config_translation/migration_templates/d6_i18n_system_site.yml
deleted file mode 100644
index 9c9337c..0000000
--- a/core/modules/config_translation/migration_templates/d6_i18n_system_site.yml
+++ /dev/null
@@ -1,38 +0,0 @@
-id: d6_i18n_system_site
-label: Site configuration
-migration_tags:
-  - Drupal 6
-source:
-  plugin: i18n_variable
-  constants:
-    slash: '/'
-  variables:
-    - site_name
-    - site_mail
-    - site_slogan
-    - site_frontpage
-    - site_403
-    - site_404
-process:
-  langcode: language
-  name: site_name
-  mail: site_mail
-  slogan: site_slogan
-  'page/front':
-    plugin: concat
-    source:
-      - constants/slash
-      - site_frontpage
-  'page/403':
-    plugin: concat
-    source:
-      - constants/slash
-      - site_403
-  'page/404':
-    plugin: concat
-    source:
-      - constants/slash
-      - site_404
-destination:
-  plugin: config
-  config_name: system.site
diff --git a/core/modules/config_translation/migration_templates/d6_i18n_user_mail.yml b/core/modules/config_translation/migration_templates/d6_i18n_user_mail.yml
deleted file mode 100644
index b3c916f..0000000
--- a/core/modules/config_translation/migration_templates/d6_i18n_user_mail.yml
+++ /dev/null
@@ -1,68 +0,0 @@
-id: d6_i18n_user_mail
-label: User mail configuration
-migration_tags:
-  - Drupal 6
-source:
-  plugin: i18n_variable
-  variables:
-    - user_mail_status_activated_subject
-    - user_mail_status_activated_body
-    - user_mail_password_reset_subject
-    - user_mail_password_reset_body
-    - user_mail_status_deleted_subject
-    - user_mail_status_deleted_body
-    - user_mail_register_admin_created_subject
-    - user_mail_register_admin_created_body
-    - user_mail_register_no_approval_required_subject
-    - user_mail_register_no_approval_required_body
-    - user_mail_register_pending_approval_subject
-    - user_mail_register_pending_approval_body
-    - user_mail_status_blocked_subject
-    - user_mail_status_blocked_body
-process:
-  langcode: language
-  'status_activated/subject':
-    plugin: convert_tokens
-    source: user_mail_status_activated_subject
-  'status_activated/body':
-    plugin: convert_tokens
-    source: user_mail_status_activated_body
-  'password_reset/subject':
-    plugin: convert_tokens
-    source: user_mail_password_reset_subject
-  'password_reset/body':
-    plugin: convert_tokens
-    source: user_mail_password_reset_body
-  'cancel_confirm/subject':
-    plugin: convert_tokens
-    source: user_mail_status_deleted_subject
-  'cancel_confirm/body':
-    plugin: convert_tokens
-    source: user_mail_status_deleted_body
-  'register_admin_created/subject':
-    plugin: convert_tokens
-    source: user_mail_register_admin_created_subject
-  'register_admin_created/body':
-    plugin: convert_tokens
-    source: user_mail_register_admin_created_body
-  'register_no_approval_required/subject':
-    plugin: convert_tokens
-    source: user_mail_register_no_approval_required_subject
-  'register_no_approval_required/body':
-    plugin: convert_tokens
-    source: user_mail_register_no_approval_required_body
-  'register_pending_approval/subject':
-    plugin: convert_tokens
-    source: user_mail_register_pending_approval_subject
-  'register_pending_approval/body':
-    plugin: convert_tokens
-    source: user_mail_register_pending_approval_body
-  'status_blocked/subject':
-    plugin: convert_tokens
-    source: user_mail_status_blocked_subject
-  'status_blocked/body':
-    plugin: convert_tokens
-    source: user_mail_status_blocked_body
-destination:
-  plugin: config
-  config_name: user.mail
diff --git a/core/modules/config_translation/migration_templates/d6_i18n_user_settings.yml b/core/modules/config_translation/migration_templates/d6_i18n_user_settings.yml
deleted file mode 100644
index 979a74a..0000000
--- a/core/modules/config_translation/migration_templates/d6_i18n_user_settings.yml
+++ /dev/null
@@ -1,29 +0,0 @@
-id: d6_i18n_user_settings
-label: User configuration
-migration_tags:
-  - Drupal 6
-source:
-  plugin: i18n_variable
-  variables:
-    - user_mail_status_blocked_notify
-    - user_mail_status_activated_notify
-    - user_email_verification
-    - user_register
-    - anonymous
-process:
-  langcode: language
-  'notify/status_blocked': user_mail_status_blocked_notify
-  'notify/status_activated': user_mail_status_activated_notify
-  verify_mail: user_email_verification
-  register:
-    plugin: static_map
-    source: user_register
-    default_value: visitors_admin_approval
-    map:
-      2: visitors_admin_approval
-      1: visitors
-      0: admin_only
-  anonymous: anonymous
-destination:
-  plugin: config
-  config_name: user.settings
diff --git a/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nSystemSiteTest.php b/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nSystemSiteTest.php
deleted file mode 100644
index 770b33c..0000000
--- a/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nSystemSiteTest.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-
-namespace Drupal\Tests\config_translation\Kernel\Migrate\d6;
-
-use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
-
-/**
- * Upgrade i18n_strings site variables to system.*.yml.
- *
- * @group migrate_drupal_6
- */
-class MigrateI18nSystemSiteTest extends MigrateDrupal6TestBase {
-
-  public static $modules = ['language', 'config_translation'];
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-    $this->executeMigration('d6_i18n_system_site');
-  }
-
-  /**
-   * Tests migration of system (site) variables to system.site.yml.
-   */
-  public function testSystemSite() {
-    $config_translation = \Drupal::service('language_manager')->getLanguageConfigOverride('fr', 'system.site');
-    $this->assertIdentical('fr site name', $config_translation->get('name'));
-    $this->assertIdentical('fr_site_mail@example.com', $config_translation->get('mail'));
-    $this->assertIdentical('fr Migrate rocks', $config_translation->get('slogan'));
-    $this->assertIdentical('/fr-user', $config_translation->get('page.403'));
-    $this->assertIdentical('/fr-page-not-found', $config_translation->get('page.404'));
-    $this->assertIdentical('/node', $config_translation->get('page.front'));
-    $this->assertIdentical(NULL, $config_translation->get('admin_compact_mode'));
-
-    $config_translation = \Drupal::service('language_manager')->getLanguageConfigOverride('zu', 'system.site');
-    $this->assertIdentical('zu - site_name', $config_translation->get('name'));
-    $this->assertIdentical('site_mail@example.com', $config_translation->get('mail'));
-    $this->assertIdentical('Migrate rocks', $config_translation->get('slogan'));
-    $this->assertIdentical('/zu-user', $config_translation->get('page.403'));
-    $this->assertIdentical('/zu-page-not-found', $config_translation->get('page.404'));
-    $this->assertIdentical('/node', $config_translation->get('page.front'));
-    $this->assertIdentical(NULL, $config_translation->get('admin_compact_mode'));
-  }
-
-}
diff --git a/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nUserConfigsTest.php b/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nUserConfigsTest.php
deleted file mode 100644
index b36a547..0000000
--- a/core/modules/config_translation/tests/src/Kernel/Migrate/d6/MigrateI18nUserConfigsTest.php
+++ /dev/null
@@ -1,74 +0,0 @@
-<?php
-
-namespace Drupal\Tests\config_translation\Kernel\Migrate\d6;
-
-use Drupal\config\Tests\SchemaCheckTestTrait;
-use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
-
-/**
- * Upgrade i18n variables to user.*.yml.
- *
- * @group migrate_drupal_6
- */
-class MigrateI18nUserConfigsTest extends MigrateDrupal6TestBase {
-
-  use SchemaCheckTestTrait;
-
-  public static $modules = ['language', 'locale', 'config_translation'];
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-    $this->installSchema('locale',
-      ['locales_source', 'locales_target', 'locales_location']);
-    $this->executeMigrations(['d6_i18n_user_mail', 'd6_i18n_user_settings']);
-  }
-
-  /**
-   * Tests migration of i18n user variables to user.mail.yml.
-   */
-  public function testUserMail() {
-    $config = \Drupal::service('language_manager')->getLanguageConfigOverride('fr', 'user.mail');
-    $this->assertIdentical('fr - Account details for [user:name] at [site:name] (approved)', $config->get('status_activated.subject'));
-    $this->assertIdentical("fr - [user:name],\r\n\r\nYour account at [site:name] has been activated.\r\n\r\nYou may now log in by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.\r\n\r\nOnce you have set your own password, you will be able to log in to [site:login-url] in the future using:\r\n\r\nusername: [user:name]\r\n", $config->get('status_activated.body'));
-    $this->assertIdentical('fr - Replacement login information for [user:name] at [site:name]', $config->get('password_reset.subject'));
-    $this->assertIdentical("fr - [user:name],\r\n\r\nA request to reset the password for your account has been made at [site:name].\r\n\r\nYou may now log in to [site:url-brief] by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once. It expires after one day and nothing will happen if it's not used.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.", $config->get('password_reset.body'));
-    $this->assertIdentical('fr - Account details for [user:name] at [site:name] (deleted)', $config->get('cancel_confirm.subject'));
-    $this->assertIdentical("fr - [user:name],\r\n\r\nYour account on [site:name] has been deleted.", $config->get('cancel_confirm.body'));
-    $this->assertIdentical('fr - An administrator created an account for you at [site:name]', $config->get('register_admin_created.subject'));
-    $this->assertIdentical("fr - [user:name],\r\n\r\nA site administrator at [site:name] has created an account for you. You may now log in to [site:login-url] using the following username and password:\r\n\r\nusername: [user:name]\r\npassword: \r\n\r\nYou may also log in by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.\r\n\r\n\r\n--  [site:name] team", $config->get('register_admin_created.body'));
-    $this->assertIdentical('fr - Account details for [user:name] at [site:name]', $config->get('register_no_approval_required.subject'));
-    $this->assertIdentical("fr - [user:name],\r\n\r\nThank you for registering at [site:name]. You may now log in to [site:login-url] using the following username and password:\r\n\r\nusername: [user:name]\r\npassword: \r\n\r\nYou may also log in by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.\r\n\r\n\r\n--  [site:name] team", $config->get('register_no_approval_required.body'));
-    $this->assertIdentical('fr - Account details for [user:name] at [site:name] (pending admin approval)', $config->get('register_pending_approval.subject'));
-    $this->assertIdentical("fr - [user:name],\r\n\r\nThank you for registering at [site:name]. Your application for an account is currently pending approval. Once it has been approved, you will receive another email containing information about how to log in, set your password, and other details.\r\n\r\n\r\n--  [site:name] team", $config->get('register_pending_approval.body'));
-    $this->assertIdentical('fr - Account details for [user:name] at [site:name] (blocked)', $config->get('status_blocked.subject'));
-    $this->assertIdentical("fr - [user:name],\r\n\r\nYour account on [site:name] has been blocked.", $config->get('status_blocked.body'));
-    $this->assertConfigSchema(\Drupal::service('config.typed'), 'user.mail', $config->get());
-
-    $config = \Drupal::service('language_manager')->getLanguageConfigOverride('zu', 'user.mail');
-    $this->assertIdentical('zu - An administrator created an account for you at [site:name]', $config->get('register_admin_created.subject'));
-    $this->assertIdentical("zu - [user:name],\r\n\r\nA site administrator at [site:name] has created an account for you. You may now log in to [site:login-url] using the following username and password:\r\n\r\nusername: [user:name]\r\npassword: \r\n\r\nYou may also log in by clicking on this link or copying and pasting it in your browser:\r\n\r\n[user:one-time-login-url]\r\n\r\nThis is a one-time login, so it can be used only once.\r\n\r\nAfter logging in, you will be redirected to [user:edit-url] so you can change your password.\r\n\r\n\r\n--  [site:name] team", $config->get('register_admin_created.body'));
-  }
-
-  /**
-   * Tests migration of i18n user variables to user.settings.yml.
-   */
-  public function testUserSettings() {
-    $config = \Drupal::service('language_manager')->getLanguageConfigOverride('fr', 'user.settings');
-    $this->assertIdentical(1, $config->get('notify.status_blocked'));
-    $this->assertIdentical(0, $config->get('notify.status_activated'));
-    $this->assertIdentical(0, $config->get('verify_mail'));
-    $this->assertIdentical('admin_only', $config->get('register'));
-    $this->assertIdentical('fr Guest', $config->get('anonymous'));
-
-    $config = \Drupal::service('language_manager')->getLanguageConfigOverride('zu', 'user.settings');
-    $this->assertIdentical(1, $config->get('notify.status_blocked'));
-    $this->assertIdentical(0, $config->get('notify.status_activated'));
-    $this->assertIdentical(0, $config->get('verify_mail'));
-    $this->assertIdentical('admin_only', $config->get('register'));
-    $this->assertIdentical('Guest', $config->get('anonymous'));
-  }
-
-}
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state.archived.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state.archived.yml
new file mode 100644
index 0000000..637d792
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state.archived.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+id: archived
+label: Archived
+published: false
+default_revision: true
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state.draft.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state.draft.yml
new file mode 100644
index 0000000..5c2784a
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state.draft.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+id: draft
+label: Draft
+published: false
+default_revision: false
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state.needs_review.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state.needs_review.yml
new file mode 100644
index 0000000..76a8033
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state.needs_review.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+id: needs_review
+label: Needs Review
+published: false
+default_revision: false
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state.published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state.published.yml
new file mode 100644
index 0000000..d40dda7
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state.published.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+id: published
+label: Published
+published: true
+default_revision: true
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.archived_published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.archived_published.yml
new file mode 100644
index 0000000..45bce07
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.archived_published.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.archived
+    - content_moderation.moderation_state.published
+id: archived_published
+label: 'Un-archive'
+stateFrom: archived
+stateTo: published
+weight: 0
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_draft.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_draft.yml
new file mode 100644
index 0000000..acca7d2
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_draft.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.draft
+id: draft_draft
+label: 'Create New Draft'
+stateFrom: draft
+stateTo: draft
+weight: -11
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_needs_review.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_needs_review.yml
new file mode 100644
index 0000000..bf10d97
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_needs_review.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.draft
+    - content_moderation.moderation_state.needs_review
+id: draft_needs_review
+label: 'Request Review'
+stateFrom: draft
+stateTo: needs_review
+weight: -10
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_published.yml
new file mode 100644
index 0000000..468c962
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.draft_published.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.draft
+    - content_moderation.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.moderation_state_transition.needs_review_draft.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_draft.yml
new file mode 100644
index 0000000..091507d
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_draft.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.draft
+    - content_moderation.moderation_state.needs_review
+id: needs_review_draft
+label: 'Send Back to Draft'
+stateFrom: needs_review
+stateTo: draft
+weight: -6
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_needs_review.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_needs_review.yml
new file mode 100644
index 0000000..5ee8dd7
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_needs_review.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.needs_review
+id: needs_review_needs_review
+label: 'Keep in Review'
+stateFrom: needs_review
+stateTo: needs_review
+weight: -8
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_published.yml
new file mode 100644
index 0000000..ccc5262
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.needs_review_published.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.needs_review
+    - content_moderation.moderation_state.published
+id: needs_review_published
+label: 'Publish'
+stateFrom: needs_review
+stateTo: published
+weight: -7
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_archived.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_archived.yml
new file mode 100644
index 0000000..7022a4d
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_archived.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.archived
+    - content_moderation.moderation_state.published
+id: published_archived
+label: 'Archive'
+stateFrom: published
+stateTo: archived
+weight: -3
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_draft.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_draft.yml
new file mode 100644
index 0000000..6a94de4
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_draft.yml
@@ -0,0 +1,11 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.draft
+    - content_moderation.moderation_state.published
+id: published_draft
+label: 'Create New Draft'
+stateFrom: published
+stateTo: draft
+weight: -5
diff --git a/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_published.yml b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_published.yml
new file mode 100644
index 0000000..4d9a18e
--- /dev/null
+++ b/core/modules/content_moderation/config/install/content_moderation.moderation_state_transition.published_published.yml
@@ -0,0 +1,10 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - content_moderation.moderation_state.published
+id: published_published
+label: 'Publish'
+stateFrom: published
+stateTo: published
+weight: -4
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 0000000..1265e3e
--- /dev/null
+++ b/core/modules/content_moderation/config/schema/content_moderation.schema.yml
@@ -0,0 +1,50 @@
+content_moderation.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'
+    uuid:
+      type: string
+
+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'
diff --git a/core/modules/content_moderation/config/schema/content_moderation_transition.schema.yml b/core/modules/content_moderation/config/schema/content_moderation_transition.schema.yml
new file mode 100644
index 0000000..0ae5dba
--- /dev/null
+++ b/core/modules/content_moderation/config/schema/content_moderation_transition.schema.yml
@@ -0,0 +1,21 @@
+content_moderation.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'
+    uuid:
+      type: string
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 0000000..f60c584
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.info.yml
@@ -0,0 +1,10 @@
+name: Content moderation
+type: module
+description: Provides moderation states for content
+version: VERSION
+core: 8.x
+package: Core (Experimental)
+configure: content_moderation.overview
+dependencies:
+  - views
+  - options
diff --git a/core/modules/content_moderation/content_moderation.install b/core/modules/content_moderation/content_moderation.install
new file mode 100644
index 0000000..5af7953
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.install
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * @file
+ * Contains install/update hooks for moderation_state.
+ */
+
+/**
+ * Implements hook_install().
+ */
+function content_moderation_install() {
+  /** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */
+  $moderation_info = \Drupal::service('content_moderation.moderation_information');
+
+  /** @var \Drupal\Core\Entity\EntityFieldManagerInterface $field_manager */
+  $field_manager = \Drupal::service('entity_field.manager');
+
+  $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager();
+
+  $revisionable_entity_defintions = $moderation_info->selectRevisionableEntities((\Drupal::entityTypeManager()->getDefinitions()));
+
+  // Some modules, such as Entity Pilot, seem to have a weirdness with their
+  // base field definition such that an entity may end up in this list that
+  // does not end up selected in EntityTypeInfo::entityTypeAlter().  The result
+  // is that the moderation_state field is null, and thus trying to install
+  // a field with a null definition explodes (rightly so).
+  // Until that oddity is sorted out, we can at least put an extra check in
+  // here to filter out such broken entities.
+  // @todo Remove when the underlying bug is fixed.
+  // @see https://www.drupal.org/node/2674446
+  $revisionable_entity_defintions = array_filter($revisionable_entity_defintions, function(\Drupal\Core\Entity\ContentEntityTypeInterface $type) use ($field_manager) {
+    return !empty($field_manager->getFieldStorageDefinitions($type->id())['moderation_state']);
+  });
+
+  /** @var \Drupal\Core\Entity\ContentEntityTypeInterface $type */
+  foreach ($revisionable_entity_defintions as $type) {
+    $content_moderation_definition = $field_manager->getFieldStorageDefinitions($type->id())['moderation_state'];
+    $entity_definition_update_manager->installFieldStorageDefinition('moderation_state', $type->id(), 'moderation_state', $content_moderation_definition);
+  }
+}
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 0000000..31dd1c4
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.libraries.yml
@@ -0,0 +1,5 @@
+entity-moderation-form:
+  version: 1.x
+  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 0000000..9de5061
--- /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 0000000..0fcb3eb
--- /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 0000000..d715219
--- /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 0000000..15a1edc
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.module
@@ -0,0 +1,233 @@
+<?php
+
+/**
+ * @file
+ * Contains content_moderation.module.
+ *
+ * @todo include UI bits of https://www.drupal.org/node/2429153
+ * @todo How to remove the live version (i.e. published => draft without new
+ *   revision) - i.e. unpublish
+ */
+
+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;
+use Drupal\content_moderation\Plugin\Action\ModerationOptOutPublishNode;
+use Drupal\content_moderation\Plugin\Action\ModerationOptOutUnpublishNode;
+use Drupal\content_moderation\Plugin\Menu\EditTab;
+
+/**
+ * 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>.', array(':content_moderation' => 'https://www.drupal.org/documentation/modules/workbench_moderation/')) . '</p>';
+      return $output;
+
+    default:
+  }
+}
+
+/**
+ * Implements hook_entity_base_field_info().
+ */
+function content_moderation_entity_base_field_info(EntityTypeInterface $entity_type) {
+  return \Drupal::service('content_moderation.entity_type')->entityBaseFieldInfo($entity_type);
+}
+
+/**
+ * Implements hook_module_implements_alter().
+ */
+function content_moderation_module_implements_alter(&$implementations, $hook) {
+  /** @var \Drupal\content_moderation\InlineEditingDisabler $inline_editing_disabler */
+  $inline_editing_disabler = \Drupal::service('content_moderation.inline_editing_disabler');
+  $inline_editing_disabler->moduleImplementsAlter($implementations, $hook);
+}
+
+/**
+ * Implements hook_entity_view_alter().
+ */
+function content_moderation_entity_view_alter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
+  /** @var \Drupal\content_moderation\InlineEditingDisabler $inline_editing_disabler */
+  $inline_editing_disabler = \Drupal::service('content_moderation.inline_editing_disabler');
+  $inline_editing_disabler->entityViewAlter($build, $entity, $display);
+}
+
+/**
+ * Implements hook_entity_type_alter().
+ */
+function content_moderation_entity_type_alter(array &$entity_types) {
+  \Drupal::service('content_moderation.entity_type')->entityTypeAlter($entity_types);
+}
+
+/**
+ * Implements hook_entity_operation().
+ */
+function content_moderation_entity_operation(EntityInterface $entity) {
+  return \Drupal::service('content_moderation.entity_type')->entityOperation($entity);
+}
+
+/**
+ * Sets required flag based on enabled state.
+ */
+function content_moderation_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) {
+  return \Drupal::service('content_moderation.entity_type')->entityBundleFieldInfoAlter($fields, $entity_type, $bundle);
+}
+
+/**
+ * Implements hook_entity_load().
+ */
+function content_moderation_entity_storage_load(array $entities, $entity_type_id) {
+  return \Drupal::service('content_moderation.entity_operations')->entityStorageLoad($entities, $entity_type_id);
+}
+
+/**
+ * Implements hook_entity_presave().
+ */
+function content_moderation_entity_presave(EntityInterface $entity) {
+  return \Drupal::service('content_moderation.entity_operations')->entityPresave($entity);
+}
+
+/**
+ * Implements hook_entity_insert().
+ */
+function content_moderation_entity_insert(EntityInterface $entity) {
+  return \Drupal::service('content_moderation.entity_operations')->entityInsert($entity);
+}
+
+/**
+ * Implements hook_entity_update().
+ */
+function content_moderation_entity_update(EntityInterface $entity) {
+  return \Drupal::service('content_moderation.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) {
+  return \Drupal::service('content_moderation.entity_type')->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) {
+  \Drupal::service('content_moderation.content_preprocess')->preprocessNode($variables);
+}
+
+/**
+ * Implements hook_entity_extra_field_info().
+ */
+function content_moderation_entity_extra_field_info() {
+  return \Drupal::service('content_moderation.entity_type')->entityExtraFieldInfo();
+}
+
+/**
+ * Implements hook_entity_view().
+ */
+function content_moderation_entity_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode) {
+  \Drupal::service('content_moderation.entity_operations')->entityView($build, $entity, $display, $view_mode);
+}
+
+/**
+ * Implements hook_entity_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 $entity, $operation, AccountInterface $account) {
+  /** @var \Drupal\content_moderation\ModerationInformationInterface $modinfo */
+  $moderation_info = Drupal::service('content_moderation.moderation_information');
+
+  if ($operation == 'view') {
+    return (!$entity->isPublished())
+      ? AccessResult::allowedIfHasPermission($account, 'view any unpublished content')
+      : AccessResult::neutral();
+  }
+  elseif ($operation == 'update' && $moderation_info->isModeratableEntity($entity) && $entity->moderation_information && $entity->moderation_information->target_id) {
+    /** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */
+    $transition_validation = \Drupal::service('content_moderation.state_transition_validation');
+
+    return $transition_validation->getValidTransitionTargets($entity, $account)
+      ? AccessResult::neutral()
+      : AccessResult::forbidden();
+  }
+}
+
+/**
+ * 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;
+  }
+}
+
+/**
+ * Implements hook_views_data_alter().
+ */
+function content_moderation_views_data_alter(array &$data) {
+
+  /** @var \Drupal\content_moderation\ModerationInformationInterface $mod_info */
+  $mod_info = \Drupal::service('content_moderation.moderation_information');
+
+  /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $etm */
+  $etm = \Drupal::service('entity_type.manager');
+
+  $revisionable_types = $mod_info->selectRevisionableEntities($etm->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'],
+    ];
+  }
+}
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 0000000..34cf91a
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.permissions.yml
@@ -0,0 +1,20 @@
+view any unpublished content:
+  title: View any unpublished content
+  description: This permission is necessary for any users that may moderate content.
+
+'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 0000000..1ecc3cc
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.routing.yml
@@ -0,0 +1,89 @@
+content_moderation.overview:
+  path: '/admin/structure/content-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/structure/content-moderation/states'
+  defaults:
+    _entity_list: 'moderation_state'
+    _title: 'Moderation states'
+  requirements:
+    _permission: 'administer moderation states'
+  options:
+    _admin_route: TRUE
+
+entity.moderation_state.add_form:
+  path: '/admin/structure/content-moderation/states/add'
+  defaults:
+    _entity_form: 'moderation_state.add'
+    _title: 'Add Moderation state'
+  requirements:
+    _permission: 'administer moderation states'
+  options:
+    _admin_route: TRUE
+
+entity.moderation_state.edit_form:
+  path: '/admin/structure/content-moderation/states/{moderation_state}'
+  defaults:
+    _entity_form: 'moderation_state.edit'
+    _title: 'Edit Moderation state'
+  requirements:
+    _permission: 'administer moderation states'
+  options:
+    _admin_route: TRUE
+
+entity.moderation_state.delete_form:
+  path: '/admin/structure/content-moderation/states/{moderation_state}/delete'
+  defaults:
+    _entity_form: 'moderation_state.delete'
+    _title: 'Delete Moderation state'
+  requirements:
+    _permission: 'administer moderation states'
+  options:
+    _admin_route: TRUE
+
+# ModerationStateTransition routing definition
+entity.moderation_state_transition.collection:
+  path: '/admin/structure/content-moderation/transitions'
+  defaults:
+    _entity_list: 'moderation_state_transition'
+    _title: 'Moderation state transitions'
+  requirements:
+    _permission: 'administer moderation state transitions'
+  options:
+    _admin_route: TRUE
+
+entity.moderation_state_transition.add_form:
+  path: '/admin/structure/content-moderation/transitions/add'
+  defaults:
+    _entity_form: 'moderation_state_transition.add'
+    _title: 'Add Moderation state transition'
+  requirements:
+    _permission: 'administer moderation state transitions'
+  options:
+    _admin_route: TRUE
+
+entity.moderation_state_transition.edit_form:
+  path: '/admin/structure/content-moderation/transitions/{moderation_state_transition}'
+  defaults:
+    _entity_form: 'moderation_state_transition.edit'
+    _title: 'Edit Moderation state transition'
+  requirements:
+    _permission: 'administer moderation state transitions'
+  options:
+    _admin_route: TRUE
+
+entity.moderation_state_transition.delete_form:
+  path: '/admin/structure/content-moderation/transitions/{moderation_state_transition}/delete'
+  defaults:
+    _entity_form: 'moderation_state_transition.delete'
+    _title: 'Delete Moderation state transition'
+  requirements:
+    _permission: 'administer moderation state transitions'
+  options:
+    _admin_route: TRUE
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 0000000..0c3c872
--- /dev/null
+++ b/core/modules/content_moderation/content_moderation.services.yml
@@ -0,0 +1,35 @@
+services:
+  paramconverter.latest_revision:
+    class: Drupal\content_moderation\ParamConverter\EntityRevisionConverter
+    arguments: ['@entity.manager', '@content_moderation.moderation_information']
+    tags:
+      - { name: paramconverter, priority: 5 }
+    arguments: ['@entity.manager']
+  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']
+  content_moderation.entity_type:
+    class: Drupal\content_moderation\EntityTypeInfo
+    arguments: ['@string_translation', '@content_moderation.moderation_information', '@entity_type.manager']
+  content_moderation.entity_operations:
+    class: Drupal\content_moderation\EntityOperations
+    arguments: ['@content_moderation.moderation_information', '@entity_type.manager', '@form_builder', '@event_dispatcher', '@content_moderation.revision_tracker']
+  content_moderation.inline_editing_disabler:
+    class: \Drupal\content_moderation\InlineEditingDisabler
+    arguments: ['@content_moderation.moderation_information']
+  content_moderation.content_preprocess:
+    class: Drupal\content_moderation\ContentPreprocess
+    arguments: ['@current_route_match']
+  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/css/entity-moderation-form.css b/core/modules/content_moderation/css/entity-moderation-form.css
new file mode 100644
index 0000000..ec09407
--- /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 0000000..8632c4e
--- /dev/null
+++ b/core/modules/content_moderation/src/Access/LatestRevisionCheck.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\content_moderation\Access;
+
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Entity\ContentEntityInterface;
+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;
+
+class LatestRevisionCheck implements AccessInterface {
+
+  /**
+   * @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.
+   *
+   * @see 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.
+   */
+  public function access(Route $route, RouteMatchInterface $route_match) {
+
+    // This tab should not show up period unless there's a reason to show it.
+    // @todo Do we need any extra cache tags here?
+    $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 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/ContentPreprocess.php b/core/modules/content_moderation/src/ContentPreprocess.php
new file mode 100644
index 0000000..ec53d2a
--- /dev/null
+++ b/core/modules/content_moderation/src/ContentPreprocess.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\node\Entity\Node;
+
+/**
+ * Service to determine whether a route is the "Latest version" tab of a node.
+ */
+class ContentPreprocess {
+
+  /**
+   * @var \Drupal\Core\Routing\CurrentRouteMatch $routeMatch
+   */
+  protected $routeMatch;
+
+  /**
+   * Constructor.
+   *
+   * @param \Drupal\Core\Routing\CurrentRouteMatch $route_match
+   *   Current route match service.
+   */
+  public function __construct(CurrentRouteMatch $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/Handler/BlockContentModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php
new file mode 100644
index 0000000..b698fac
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/BlockContentModerationHandler.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * @file
+ * Contains Drupal\content_moderation\Entity\Handler\BlockContentCustomizations.
+ */
+
+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 0000000..cb80b49
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandler.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * @file
+ * Contains Drupal\content_moderation\Entity\Handler\GenericCustomizations.
+ */
+
+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,
+    // but it can't hurt.
+    $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 in core, 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 NodeType
+    // for an example.
+    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();
+
+    return;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enforceRevisionsEntityFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    return;
+  }
+
+
+  /**
+   * {@inheritdoc}
+   */
+  public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface $form_state, $form_id) {
+    return;
+  }
+
+}
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 0000000..d5295dc
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/ModerationHandlerInterface.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * @file
+ * Contains Drupal\content_moderation\Entity\Handler\EntityCustomizationInterface.
+ */
+
+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 {
+
+  /**
+   * @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\ConfigEntityTypeInterface $bundle
+   *   The bundle definition that is being saved.
+   * @return mixed
+   */
+  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 0000000..17f89ba
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php
@@ -0,0 +1,68 @@
+<?php
+/**
+ * @file
+ * Contains Drupal\content_moderation\Entity\Handler\NodeCustomizations.
+ */
+
+namespace Drupal\content_moderation\Entity\Handler;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\node\Entity\Node;
+
+/**
+ * Customizations for node entities.
+ */
+class NodeModerationHandler extends ModerationHandler {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) {
+    if ($this->shouldModerate($entity)) {
+      parent::onPresave($entity, $default_revision, $published_state);
+      // Only nodes have a concept of published.
+      /** @var $entity Node */
+      $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
+   *
+   * @return bool
+   *   TRUE when either the default revision or the state needs to be updated.
+   */
+  protected function shouldModerate(ContentEntityInterface $entity) {
+    // First condition is needed so you can add a translation.
+    // Second condition is needed when you want to publish a translation.
+    // Third condition is needed when you want to create a new draft for a published translation.
+    return $entity->isDefaultTranslation() || $entity->moderation_state->entity->isPublishedState() || $entity->isPublished();
+  }
+
+}
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 0000000..c19d23a
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/ModerationState.php
@@ -0,0 +1,81 @@
+<?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 = {
+ *     "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 = "moderation_state",
+ *   admin_permission = "administer moderation states",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "label" = "label",
+ *     "uuid" = "uuid"
+ *   },
+ *   links = {
+ *     "edit-form" = "/admin/structure/moderation-state/states/{moderation_state}/edit",
+ *     "delete-form" = "/admin/structure/moderation-state/states/{moderation_state}/delete",
+ *     "collection" = "/admin/structure/moderation-state/states"
+ *   }
+ * )
+ */
+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;
+
+  /**
+   * 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 0000000..4d355e6
--- /dev/null
+++ b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php
@@ -0,0 +1,144 @@
+<?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"
+ *     },
+ *     "storage" = "Drupal\content_moderation\ModerationStateTransitionStorage"
+ *   },
+ *   config_prefix = "moderation_state_transition",
+ *   admin_permission = "administer moderation state transitions",
+ *   entity_keys = {
+ *     "id" = "id",
+ *     "label" = "label",
+ *     "uuid" = "uuid",
+ *     "weight" = "weight"
+ *   },
+ *   links = {
+ *     "edit-form" = "/admin/structure/moderation-state/transitions/{moderation_state_transition}/edit",
+ *     "delete-form" = "/admin/structure/moderation-state/transitions/{moderation_state_transition}/delete",
+ *     "collection" = "/admin/structure/moderation-state/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;
+
+  /**
+   * Moderation state config prefix
+   *
+   * @var string.
+   */
+  protected $moderationStateConfigPrefix;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    parent::calculateDependencies();
+    $prefix = $this->getModerationStateConfigPrefix() . '.';
+    if ($this->stateFrom) {
+      $this->addDependency('config', $prefix . $this->stateFrom);
+    }
+    if ($this->stateTo) {
+      $this->addDependency('config', $prefix . $this->stateTo);
+    }
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFromState() {
+    return $this->stateFrom;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getToState() {
+    return $this->stateTo;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getWeight() {
+    return $this->weight;
+  }
+
+  /**
+   * Gets the moderation state config prefix.
+   *
+   * @return string
+   *   Moderation state config prefix.
+   */
+  protected function getModerationStateConfigPrefix() {
+    if (!isset($this->moderationStateConfigPrefix)) {
+      $this->moderationStateConfigPrefix = \Drupal::service('entity_type.manager')->getDefinition('moderation_state')->getConfigPrefix();
+    }
+    return $this->moderationStateConfigPrefix;
+  }
+
+  /**
+   * Sets the moderation state config prefix.
+   *
+   * @param string $moderation_state_config_prefix
+   *   Moderation state config prefix.
+   *
+   * @return self
+   *   Called instance.
+   */
+  public function setModerationStateConfigPrefix($moderation_state_config_prefix) {
+    $this->moderationStateConfigPrefix = $moderation_state_config_prefix;
+    return $this;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/EntityOperations.php b/core/modules/content_moderation/src/EntityOperations.php
new file mode 100644
index 0000000..0b35e2d
--- /dev/null
+++ b/core/modules/content_moderation/src/EntityOperations.php
@@ -0,0 +1,267 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+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\Event\ContentModerationEvents;
+use Drupal\content_moderation\Event\ContentModerationTransitionEvent;
+use Drupal\content_moderation\Form\EntityModerationForm;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Defines a class for reacting to entity events.
+ */
+class EntityOperations {
+
+  /**
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The event dispatcher.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   */
+  protected $eventDispatcher;
+
+  /**
+   * @var \Drupal\Core\Form\FormBuilderInterface
+   */
+  protected $formBuilder;
+
+  /**
+   * @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 \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
+   *   The event dispatcher.
+   * @param \Drupal\content_moderation\RevisionTrackerInterface $tracker
+   *   The revision tracker.
+   */
+  public function __construct(ModerationInformationInterface $moderation_info, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, EventDispatcherInterface $event_dispatcher, RevisionTrackerInterface $tracker) {
+    $this->moderationInfo = $moderation_info;
+    $this->entityTypeManager = $entity_type_manager;
+    $this->eventDispatcher = $event_dispatcher;
+    $this->formBuilder = $form_builder;
+    $this->tracker = $tracker;
+  }
+
+  /**
+   * Hook bridge.
+   *
+   * @see hook_entity_storage_load().
+   *
+   * @param EntityInterface[] $entities
+   *   An array of entity objects that have just been loaded.
+   * @param string $entity_type_id
+   *   The type of entity being loaded, such as "node" or "user".
+   */
+  public function entityStorageLoad(array $entities, $entity_type_id) {
+
+    // Ensure that all moderatable entities always have a moderation_state field
+    // with data, in all translations. That avoids us needing to have a thousand
+    // NULL checks elsewhere in the code.
+
+    // Quickly exclude any non-moderatable entities.
+    $to_check = array_filter($entities, [$this->moderationInfo, 'isModeratableEntity']);
+    if (!$to_check) {
+      return;
+    }
+
+    // @todo make this more functional, less iterative.
+    foreach ($to_check as $entity) {
+      foreach ($entity->getTranslationLanguages() as $language) {
+        $translation = $entity->getTranslation($language->getId());
+        if ($translation->moderation_state->target_id == NULL) {
+          $translation->moderation_state->target_id = $this->getDefaultLoadStateId($translation);
+        }
+      }
+    }
+  }
+
+  /**
+   * 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->entity) {
+      $published_state = $entity->moderation_state->entity->isPublishedState();
+
+      // This entity is default if it is new, the default revision, or the
+      // default revision is not published.
+      $update_default_revision = $entity->isNew()
+        || $entity->moderation_state->entity->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);
+
+      // There's currently a bug in core where $entity->original always points
+      // to the default revision, for now work around this by loading the latest
+      // revision.
+      $latest_revision = $this->moderationInfo->getLatestRevision($entity->getEntityTypeId(), $entity->id());
+      $state_before = !empty($latest_revision) ? $latest_revision->moderation_state->target_id : NULL;
+      // @todo: Revert to this simpler version when https://www.drupal.org/node/2700747 is fixed.
+      // $state_before = isset($entity->original) ? $entity->original->moderation_state->target_id : NULL;
+
+      $state_after = $entity->moderation_state->target_id;
+
+      // Allow other modules to respond to the transition. Note that this
+      // does not provide any mechanism to cancel the transition, since
+      // Entity API doesn't allow hook_entity_presave to short-circuit a save.
+      $event = new ContentModerationTransitionEvent($entity, $state_before, $state_after);
+
+      $this->eventDispatcher->dispatch(ContentModerationEvents::STATE_TRANSITION, $event);
+    }
+  }
+
+  /**
+   * Hook bridge.
+   *
+   * @see hook_entity_insert().
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity that was just saved.
+   */
+  public function entityInsert(EntityInterface $entity) {
+    if (!$this->moderationInfo->isModeratableEntity($entity)) {
+      return;
+    }
+
+    /** ContentEntityInterface $entity */
+
+    // Update our own record keeping.
+    $this->tracker->setLatestRevision($entity->getEntityTypeId(), $entity->id(), $entity->language()->getId(), $entity->getRevisionId());
+  }
+
+  /**
+   * Hook bridge.
+   *
+   * @see hook_entity_update().
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity that was just saved.
+   */
+  public function entityUpdate(EntityInterface $entity) {
+    if (!$this->moderationInfo->isModeratableEntity($entity)) {
+      return;
+    }
+
+    /** ContentEntityInterface $entity */
+
+    // Update our own record keeping.
+    $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 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 0000000..c6353e5
--- /dev/null
+++ b/core/modules/content_moderation/src/EntityTypeInfo.php
@@ -0,0 +1,358 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+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\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;
+
+  /**
+   * @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;
+  }
+
+  /**
+   * Modifies an entity type definition to include moderation configuration support.
+   *
+   * 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 entitiy\'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, 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 = [];
+    // @todo write a test for this.
+    $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference')
+      ->setLabel(t('Moderation state'))
+      ->setDescription(t('The moderation state of this piece of content.'))
+      ->setSetting('target_type', 'moderation_state')
+      ->setTargetEntityTypeId($entity_type->id())
+      ->setRevisionable(TRUE)
+      ->setTranslatable(TRUE)
+      // @todo write a test for this.
+      ->setDisplayOptions('view', [
+        'label' => 'hidden',
+        'type' => 'hidden',
+        'weight' => -5,
+      ])
+      // @todo write a custom widget/selection handler plugin instead of
+      // manual filtering?
+      ->setDisplayOptions('form', [
+        'type' => 'moderation_state_default',
+        'weight' => 5,
+        'settings' => [],
+      ])
+      ->addConstraint('ModerationState', [])
+      ->setDisplayConfigurable('form', FALSE)
+      ->setDisplayConfigurable('view', FALSE);
+    return $fields;
+  }
+
+  /**
+   * Force moderatable bundles to have a moderation_state field.
+   *
+   * @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 ($this->moderationInfo->isModeratableBundle($entity_type, $bundle) && !empty($fields['moderation_state'])) {
+      $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 ConfigEntityTypeInterface $bundle */
+      $bundle = $form_state->getFormObject()->getEntity();
+
+      $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->enforceRevisionsBundleFormAlter($form, $form_state, $form_id);
+    }
+    else if ($this->moderationInfo->isModeratedEntityForm($form_state->getFormObject())) {
+      /* @var 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
+      $form['actions']['submit']['#submit'][] = '\Drupal\content_moderation\EntityTypeInfo::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
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   */
+  public static function bundleFormRedirect(array &$form, FormStateInterface $form_state) {
+    /* @var 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/Event/ContentModerationEvents.php b/core/modules/content_moderation/src/Event/ContentModerationEvents.php
new file mode 100644
index 0000000..9c8c5bf
--- /dev/null
+++ b/core/modules/content_moderation/src/Event/ContentModerationEvents.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Drupal\content_moderation\Event;
+
+final class ContentModerationEvents {
+
+  /**
+   * This event is fired everytime a state is changed.
+   *
+   * @Event
+   */
+  const STATE_TRANSITION = 'content_moderation.state_transition';
+
+}
diff --git a/core/modules/content_moderation/src/Event/ContentModerationTransitionEvent.php b/core/modules/content_moderation/src/Event/ContentModerationTransitionEvent.php
new file mode 100644
index 0000000..60d3af8
--- /dev/null
+++ b/core/modules/content_moderation/src/Event/ContentModerationTransitionEvent.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\content_moderation\Event;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Symfony\Component\EventDispatcher\Event;
+
+/**
+ * @see \Drupal\content_moderation\ModerationStateEvents
+ */
+class ContentModerationTransitionEvent extends Event {
+
+  /**
+   * The entity which was changed.
+   *
+   * @var \Drupal\Core\Entity\ContentEntityInterface
+   */
+  protected $entity;
+
+  /**
+   * @var string
+   */
+  protected $stateBefore;
+
+  /**
+   * @var string
+   */
+  protected $stateAfter;
+
+  /**
+   * Creates a new ContentModerationTransitionEvent instance.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity which was changed.
+   * @param string $state_before
+   *   The state before the transition.
+   * @param string $state_after
+   *   The state after the transition.
+   */
+  public function __construct(ContentEntityInterface $entity, $state_before, $state_after) {
+    $this->entity = $entity;
+    $this->stateBefore = $state_before;
+    $this->stateAfter = $state_after;
+  }
+
+  /**
+   * Returns the changed entity.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface
+   */
+  public function getEntity() {
+    return $this->entity;
+  }
+
+  /**
+   * @return string
+   */
+  public function getStateBefore() {
+    return $this->stateBefore;
+  }
+
+  /**
+   * @return string
+   */
+  public function getStateAfter() {
+    return $this->stateAfter;
+  }
+
+}
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 0000000..ed43914
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php
@@ -0,0 +1,191 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+
+use Drupal\Core\Config\Entity\ConfigEntityInterface;
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\EntityForm;
+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 {
+
+  /**
+   * @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}
+   *
+   * We need to blank out the base form ID so that poorly written form alters
+   * that use the base form ID to target both add and edit forms don't pick
+   * up our form. This should be fixed in core.
+   */
+  public function getBaseFormId() {
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function form(array $form, FormStateInterface $form_state) {
+    /* @var 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],
+        ],
+      ],
+    ];
+
+    // This is screwy, but 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 chokes 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 I don't know why this needs to be separate from the form() method.
+   * It was in the form_alter version but we should see if we can just fold
+   * it into the method above.
+   *
+   * @param $entity_type
+   * @param \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle
+   * @param array $form
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   */
+  public function formBuilderCallback($entity_type, ConfigEntityInterface $bundle, &$form, FormStateInterface $form_state) {
+    // @todo write a test for this.
+    $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 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 0000000..eb1ab9a
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/EntityModerationForm.php
@@ -0,0 +1,141 @@
+<?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\ModerationState;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Drupal\content_moderation\StateTransitionValidation;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+class EntityModerationForm extends FormBase {
+
+  /**
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * @var \Drupal\content_moderation\StateTransitionValidation
+   */
+  protected $validation;
+
+  /**
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  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 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');
+    $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 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 0000000..1bbec59
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php
@@ -0,0 +1,51 @@
+<?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 0000000..82c93a0
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateForm.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\content_moderation\Form;
+
+use Drupal\Core\Entity\EntityForm;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Class ModerationStateForm.
+ *
+ * @package Drupal\content_moderation\Form
+ */
+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' => '\Drupal\content_moderation\Entity\ModerationState::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 When these are added, the checkbox default value does not apply properly.
+      // @see https://www.drupal.org/node/2645614
+      // '#states' => [
+      //   'checked' => [':input[name="published"]' => ['checked' => TRUE]],
+      //   'disabled' => [':input[name="published"]' => ['checked' => TRUE]],
+      // ],
+    ];
+
+    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 0000000..caabc4b
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php
@@ -0,0 +1,51 @@
+<?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 0000000..f6520d4
--- /dev/null
+++ b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php
@@ -0,0 +1,141 @@
+<?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 {
+
+  /**
+   * @var
+   */
+  protected $entityTypeManager;
+
+  /**
+   * @var \Drupal\Core\Entity\Query\QueryFactory
+   */
+  protected $queryFactory;
+
+  /**
+   * Constructs a new ModerationStateTransitionForm.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   */
+  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/InlineEditingDisabler.php b/core/modules/content_moderation/src/InlineEditingDisabler.php
new file mode 100644
index 0000000..69239ea
--- /dev/null
+++ b/core/modules/content_moderation/src/InlineEditingDisabler.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Disables the inline editing for entities with forward revisions.
+ */
+class InlineEditingDisabler {
+
+  /**
+   * The moderation info.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  /**
+   * Creates a new InlineEditingDisabler instance.
+   *
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+   *   The moderation info.
+   */
+  public function __construct(ModerationInformationInterface $moderation_info) {
+    $this->moderationInfo = $moderation_info;
+  }
+
+  /**
+   * Implements hook_entity_view_alter().
+   */
+  public function entityViewAlter(&$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
+    if ($this->moderationInfo->isModeratableEntity($entity) && !$this->moderationInfo->isLatestRevision($entity)) {
+      // Hide quickedit, because its super confusing for the user to not edit the
+      // live revision.
+      unset($build['#attributes']['data-quickedit-entity-id']);
+    }
+  }
+
+  /**
+   * Implements hook_module_implements_alter().
+   */
+  public function moduleImplementsAlter(&$implementations, $hook) {
+    if ($hook == 'entity_view_alter') {
+      // Find the quickedit implementation and move content after it.
+      unset($implementations['content_moderation']);
+      $implementations['content_moderation'] = FALSE;
+    }
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php
new file mode 100644
index 0000000..51f5875
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationInformation.php
@@ -0,0 +1,216 @@
+<?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.
+ *
+ * @todo Much of this code may eventually migrate to the Entity module, and
+ * from there to Drupal core.
+ */
+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) {
+    // We really shouldn't be checking for a base class, but core lacks an
+    // interface here. When core adds a better way to determine if we're on
+    // a Bundle configuration form we should switch to that.
+    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) {
+        $revision_id = array_keys($revision_ids)[0];
+        return $revision_id;
+      }
+    }
+  }
+
+  /**
+   * {@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) {
+        $revision_id = array_keys($revision_ids)[0];
+        return $revision_id;
+      }
+    }
+  }
+
+  /**
+   * {@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 0000000..290a5eb
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationInformationInterface.php
@@ -0,0 +1,211 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+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
+   */
+  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 an entity list to just bundle definitions for revisionable entities.
+   *
+   * @param EntityTypeInterface[] $entity_types
+   *   The master entity type list filter.
+   *
+   * @return ConfigEntityTypeInterface[]
+   *   An array of only the config entities we want to modify.
+   */
+  public function selectRevisionableEntityTypes(array $entity_types);
+
+  /**
+   * Filters an entity list to just the definitions for moderatable entities.
+   *
+   * An entity type is moderatable only if it is both revisionable and bundable.
+   *
+   * @param EntityTypeInterface[] $entity_types
+   *   The master entity type list filter.
+   *
+   * @return 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\EntityInterface $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/ModerationStateInterface.php b/core/modules/content_moderation/src/ModerationStateInterface.php
new file mode 100644
index 0000000..38c7569
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateInterface.php
@@ -0,0 +1,29 @@
+<?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 this state represents a published node.
+   *
+   * @return bool
+   *   TRUE if this state deems the node published.
+   */
+  public function isPublishedState();
+
+  /**
+   * Determines if a revision should be made the default revision upon transition to
+   * this state.
+   *
+   * @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 0000000..4ebaff1
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateListBuilder.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
+use Drupal\Core\Entity\EntityInterface;
+
+/**
+ * Provides a listing of Moderation state entities.
+ */
+class ModerationStateListBuilder extends ConfigEntityListBuilder {
+  /**
+   * {@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'] = $entity->id();
+    // You probably want a few more properties here...
+    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 0000000..035cf1e
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php
@@ -0,0 +1,47 @@
+<?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();
+
+  /**
+   * Sets the moderation state config prefix.
+   *
+   * @param string $moderation_state_config_prefix
+   *   Moderation state config prefix.
+   *
+   * @return self
+   *   Called instance.
+   */
+  public function setModerationStateConfigPrefix($moderation_state_config_prefix);
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php b/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php
new file mode 100644
index 0000000..220db5e
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateTransitionListBuilder.php
@@ -0,0 +1,91 @@
+<?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 Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a listing of Moderation state transition entities.
+ */
+class ModerationStateTransitionListBuilder extends DraggableListBuilder {
+
+  /**
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $stateStorage;
+
+  /**
+   * @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')
+    );
+  }
+
+  /**
+   * Constructs a new ModerationStateTransitionListBuilder.
+   *
+   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
+   * @param \Drupal\Core\Entity\EntityStorageInterface $transition_storage
+   * @param \Drupal\Core\Entity\EntityStorageInterface $state_storage
+   */
+  public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $transition_storage, EntityStorageInterface $state_storage) {
+    parent::__construct($entity_type, $transition_storage);
+    $this->stateStorage = $state_storage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'content_moderation_transition_list';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildHeader() {
+    $header['label'] = $this->t('Moderation state transition');
+    $header['id'] = $this->t('Machine name');
+    $header['from'] = $this->t('From state');
+    $header['to'] = $this->t('To state');
+    return $header + parent::buildHeader();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildRow(EntityInterface $entity) {
+    /** @var ModerationStateTransitionInterface $entity */
+
+    $row['label'] = $entity->label();
+    $row['id']['#markup'] = $entity->id();
+    $row['from']['#markup'] = $this->stateStorage->load($entity->getFromState())->label();
+    $row['to']['#markup'] = $this->stateStorage->load($entity->getToState())->label();
+
+    return $row + parent::buildRow($entity);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    $build = parent::render(); // TODO: Change the autogenerated stub
+
+    $build['item'] = [
+      '#type' => 'item',
+      '#markup' => $this->t('When saving an entity, only a destination state that has a transition is legal. That includes its current state. If you want to allow an entity to be saved without changing its state then you must define a transition from that state to itself. Note that all users will still need permission to use a defined transition.'),
+      '#weight' => -5,
+    ];
+
+    return $build;
+  }
+
+}
diff --git a/core/modules/content_moderation/src/ModerationStateTransitionStorage.php b/core/modules/content_moderation/src/ModerationStateTransitionStorage.php
new file mode 100644
index 0000000..96ce0cb
--- /dev/null
+++ b/core/modules/content_moderation/src/ModerationStateTransitionStorage.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+
+use Drupal\Component\Uuid\UuidInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Config\Entity\ConfigEntityStorage;
+use Drupal\Core\Entity\EntityHandlerInterface;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Language\LanguageManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+class ModerationStateTransitionStorage extends ConfigEntityStorage implements EntityHandlerInterface {
+
+  /**
+   * Entity type manager service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
+    return new static(
+      $entity_type,
+      $container->get('config.factory'),
+      $container->get('uuid'),
+      $container->get('language_manager'),
+      $container->get('entity_type.manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __construct(EntityTypeInterface $entity_type, ConfigFactoryInterface $config_factory, UuidInterface $uuid_service, LanguageManagerInterface $language_manager, EntityTypeManagerInterface $entity_type_manager) {
+    parent::__construct($entity_type, $config_factory, $uuid_service, $language_manager);
+    $this->entityTypeManager = $entity_type_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function doCreate(array $values) {
+    /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */
+    $entity = parent::doCreate($values);
+    return $entity->setModerationStateConfigPrefix($this->entityTypeManager->getDefinition('moderation_state')->getConfigPrefix());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function mapFromStorageRecords(array $records) {
+    $entities = parent::mapFromStorageRecords($records);
+    $prefix = $this->entityTypeManager->getDefinition('moderation_state')->getConfigPrefix();
+    /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */
+    foreach ($entities as &$entity) {
+      $entity->setModerationStateConfigPrefix($prefix);
+    }
+    reset($entities);
+    return $entities;
+  }
+
+}
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 0000000..33479cd
--- /dev/null
+++ b/core/modules/content_moderation/src/ParamConverter/EntityRevisionConverter.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\content_moderation\ParamConverter;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\ParamConverter\EntityConverter;
+use Drupal\Core\TypedData\TranslatableInterface;
+use Drupal\content_moderation\ModerationInformationInterface;
+use Symfony\Component\Routing\Route;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+
+/**
+ * Defines a class for making sure the edit-route loads the current draft.
+ */
+class EntityRevisionConverter extends EntityConverter {
+
+  /**
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInformation;
+
+  /**
+   * EntityRevisionConverter constructor.
+   *
+   * @todo: If the parent class is ever cleaned up to use EntityTypeManager
+   * instead of Entity manager, this method will also need to be adjusted.
+   *
+   * @param \Drupal\Core\Entity\EntityManagerInterface $entity_type_manager
+   *   The entity manager, needed by the parent class.
+   * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info
+   *   The moderation info utility service.
+   */
+  public function __construct(EntityTypeManagerInterface $entity_type_manager, ModerationInformationInterface $moderation_info) {
+    parent::__construct($entity_type_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 WBM 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);
+      $entity = $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 ($entity instanceof EntityInterface && $entity instanceof TranslatableInterface) {
+        $entity = $this->entityManager->getTranslationFromContext($entity, NULL, array('operation' => 'entity_upcast'));
+      }
+    }
+
+    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 0000000..90f509c
--- /dev/null
+++ b/core/modules/content_moderation/src/Permissions.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+use Drupal\Core\Routing\UrlGeneratorTrait;
+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;
+  use UrlGeneratorTrait;
+
+  /**
+   * Returns an array of transition permissions.
+   *
+   * @return array
+   *   The transition permissions.
+   */
+  public function transitionPermissions() {
+    // @todo 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 0000000..5def5a5
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutPublishNode.php
@@ -0,0 +1,60 @@
+<?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 knows to opt-out of modifying moderated entites.
+ *
+ * @see PublishNode
+ */
+class ModerationOptOutPublishNode extends PublishNode implements ContainerFactoryPluginInterface {
+
+  /**
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $mod_info) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->moderationInfo = $mod_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 0000000..6222735
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Action/ModerationOptOutUnpublishNode.php
@@ -0,0 +1,60 @@
+<?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 knows to opt-out of modifying moderated entites.
+ *
+ * @see UnpublishNode
+ */
+class ModerationOptOutUnpublishNode extends UnpublishNode implements ContainerFactoryPluginInterface {
+
+  /**
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInfo;
+
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, ModerationInformationInterface $mod_info) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+    $this->moderationInfo = $mod_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 0000000..2c4ebaa
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Derivative/DynamicLocalTasks.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Derivative;
+
+use Drupal\Component\Plugin\Derivative\DeriverBase;
+use Drupal\Core\Config\Entity\ConfigEntityTypeInterface;
+use Drupal\Core\Entity\ContentEntityTypeInterface;
+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;
+
+  /**
+   * 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.
+   */
+  public function __construct($base_plugin_id, EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation) {
+    $this->entityTypeManager = $entity_type_manager;
+    $this->stringTranslation = $string_translation;
+    $this->basePluginId = $base_plugin_id;
+  }
+
+  /**
+   * {@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')
+    );
+  }
+
+  /**
+   * {@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 moderateable.
+   *
+   * @return EntityTypeInterface[]
+   *   An array of just those entities we care about.
+   */
+  protected function moderatableEntityDefinitions() {
+    return array_filter($this->entityTypeManager->getDefinitions(), function (EntityTypeInterface $type) {
+      return ($type instanceof ContentEntityTypeInterface)
+        && $type->getBundleEntityType()
+        && $type->isRevisionable();
+    });
+  }
+
+  /**
+   * Returns an iterable of the config entities representing moderatable content.
+   *
+   * @return EntityTypeInterface[]
+   *   An array of just those entity types we care about.
+   */
+  protected function moderatableEntityTypeDefinitions() {
+    $entity_types = $this->entityTypeManager->getDefinitions();
+
+    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();
+    });
+  }
+}
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 0000000..7b6404e
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php
@@ -0,0 +1,267 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Field\FieldWidget;
+
+use Drupal\Component\Utility\NestedArray;
+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\Entity\ModerationStateTransition;
+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;
+
+  /**
+   * @var \Drupal\content_moderation\ModerationInformation
+   */
+  protected $moderationInformation;
+
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * @var \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected $moderationStateTransitionStorage;
+
+  /**
+   * @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\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 transation entity query 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 write a test for this.
+      return $element + ['#access' => FALSE];
+    }
+
+    $default = $items->get($delta)->target_id ?: $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 ModerationStateTransition $transition */
+    foreach ($transitions as $transition) {
+      $target_states[$transition->getToState()] = $transition->label();
+    }
+
+    // @todo write a test for this.
+    $element += [
+      '#access' => FALSE,
+      '#type' => 'select',
+      '#options' => $target_states,
+      '#default_value' => $default,
+      '#published' => $default ? $default_state->isPublishedState() : FALSE,
+    ];
+
+    // 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';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function extractFormValues(FieldItemListInterface $items, array $form, FormStateInterface $form_state) {
+    $field_name = $this->fieldDefinition->getName();
+
+    // Extract the values from $form_state->getValues().
+    $path = array_merge($form['#parents'], array($field_name));
+    $key_exists = NULL;
+    // Convert the field value into expected array format.
+    $values = $form_state->getValues();
+    $value = NestedArray::getValue($values, $path, $key_exists);
+    if (empty($value)) {
+      parent::extractFormValues($items, $form, $form_state);
+      return;
+    }
+    if (!isset($value[0]['target_id'])) {
+      NestedArray::setValue($values, $path, [['target_id' => reset($value)]]);
+      $form_state->setValues($values);
+    }
+    parent::extractFormValues($items, $form, $form_state);
+  }
+
+}
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 0000000..f492574
--- /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 moderatio 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 write a test for this.
+    return $this->moderationInfo->isLiveRevision($this->entity)
+      ? $this->t('New draft')
+      : $this->t('Edit draft');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheTags() {
+    // @todo 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/ModerationState.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationState.php
new file mode 100644
index 0000000..62c0454
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationState.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Validation\Constraint;
+
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * Dynamic Entity Reference valid reference constraint.
+ *
+ * Verifies that nodes have a valid moderation state.
+ *
+ * @Constraint(
+ *   id = "ModerationState",
+ *   label = @Translation("Valid moderation state", context = "Validation")
+ * )
+ */
+class ModerationState extends Constraint {
+
+  public $message = 'Invalid state transition from %from to %to';
+
+}
diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateValidator.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateValidator.php
new file mode 100644
index 0000000..aafe840
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateValidator.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\content_moderation\Plugin\Validation\Constraint;
+
+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;
+
+class ModerationStateValidator extends ConstraintValidator implements ContainerInjectionInterface {
+
+  /**
+   * The state transition validation.
+   *
+   * @var \Drupal\content_moderation\StateTransitionValidation
+   */
+  protected $validation;
+
+  /**
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  private $entityTypeManager;
+
+  /**
+   * The moderation info.
+   *
+   * @var \Drupal\content_moderation\ModerationInformationInterface
+   */
+  protected $moderationInformation;
+
+  /**
+   * Creates a new ModerationStateValidator 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;
+  }
+
+  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());
+    }
+    $next_moderation_state_id = $entity->moderation_state->target_id;
+    $original_moderation_state_id = $original_entity->moderation_state->target_id;
+
+    if (!$this->validation->isTransitionAllowed($original_moderation_state_id, $next_moderation_state_id)) {
+      $this->context->addViolation($constraint->message, ['%from' => $original_entity->moderation_state->entity->label(), '%to' => $entity->moderation_state->entity->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
+   *
+   * @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 0000000..c65b194
--- /dev/null
+++ b/core/modules/content_moderation/src/Plugin/views/filter/LatestRevision.php
@@ -0,0 +1,121 @@
+<?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\Annotation\ViewsFilter;
+use Drupal\views\Plugin\views\filter\FilterPluginBase;
+use Drupal\views\Plugin\views\query\Sql;
+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 {
+
+  /**
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * @var \Drupal\views\Plugin\ViewsHandlerManager
+   */
+  protected $joinHandler;
+
+  /**
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  /**
+   * Constructs a new LatestRevision.
+   *
+   * @param array $configuration
+   * @param string $plugin_id
+   * @param mixed $plugin_definition
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   * @param \Drupal\views\Plugin\ViewsHandlerManager $join_handler
+   * @param \Drupal\Core\Database\Connection $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 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 0000000..010128c
--- /dev/null
+++ b/core/modules/content_moderation/src/RevisionTracker.php
@@ -0,0 +1,154 @@
+<?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;
+
+  /**
+   * Constructs a new RevisionTracker.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $connection;
+
+  /**
+   * RevisionTracker constructor.
+   *
+   * @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, $entity_id, $langcode, $revision_id) {
+    try {
+      $this->recordLatestRevision($entity_type, $entity_id, $langcode, $revision_id);
+    }
+    catch (DatabaseExceptionWrapper $e) {
+      $this->ensureTableExists();
+      $this->recordLatestRevision($entity_type, $entity_id, $langcode, $revision_id);
+    }
+
+    return $this;
+  }
+
+  /**
+   * Records the latest revision of a given entity.
+   *
+   * @param $entity_type
+   *   The machine name of the type of entity.
+   * @param $entity_id
+   *   The Entity ID in question.
+   * @param $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 $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, $entity_id, $langcode, $revision_id) {
+    return $this->connection->merge($this->tableName)
+      ->keys([
+        'entity_type' => $entity_type,
+        '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 0000000..5711743
--- /dev/null
+++ b/core/modules/content_moderation/src/RevisionTrackerInterface.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\content_moderation;
+
+/**
+ * Tracks metadata about revisions across content entities.
+ */
+interface RevisionTrackerInterface {
+
+  /**
+   * Sets the latest revision of a given entity.
+   *
+   * @param $entity_type
+   *   The machine name of the type of entity.
+   * @param $entity_id
+   *   The Entity ID in question.
+   * @param $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 $revision_id
+   *   The revision ID that is now the latest revision.
+   *
+   * @return static
+   */
+  public function setLatestRevision($entity_type, $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 0000000..f9f7930
--- /dev/null
+++ b/core/modules/content_moderation/src/Routing/EntityModerationRouteProvider.php
@@ -0,0 +1,117 @@
+<?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;
+
+/**
+ * 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 0000000..35287b4
--- /dev/null
+++ b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php
@@ -0,0 +1,58 @@
+<?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'));
+
+      $route
+        ->setDefaults([
+          '_entity_form' => "{$entity_type_id}.moderation",
+          '_title' => 'Moderation',
+          //'_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::editTitle'
+        ])
+        ->setRequirement('_permission', 'administer moderation states') // @todo Come up with a new permission.
+        ->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 0000000..c9b1eab
--- /dev/null
+++ b/core/modules/content_moderation/src/StateTransitionValidation.php
@@ -0,0 +1,282 @@
+<?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\ModerationState;
+use Drupal\content_moderation\Entity\ModerationStateTransition;
+
+/**
+ * Validates whether a certain state transition is allowed.
+ */
+class StateTransitionValidation {
+
+  /**
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * @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;
+  }
+
+  /**
+   * 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 ModerationState[]
+   *   Returns an array of States to which the specified user may transition the
+   *   entity.
+   */
+  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 ModerationState $state */
+    $state = $entity->moderation_state->entity;
+    $current_state_id = $state->id();
+
+    $all_transitions = $this->getPossibleTransitions();
+    $destinations = $all_transitions[$current_state_id];
+
+    $destinations = array_intersect($states_for_bundle, $destinations);
+
+    $permitted_destinations = array_filter($destinations, function($state_name) use ($current_state_id, $user) {
+      return $this->userMayTransition($current_state_id, $state_name, $user);
+    });
+
+    return $this->entityTypeManager->getStorage('moderation_state')->loadMultiple($permitted_destinations);
+  }
+
+  /**
+   * 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.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   * @param \Drupal\Core\Session\AccountInterface $user
+   *
+   * @return ModerationStateTransition[]
+   */
+  public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) {
+    $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle());
+
+    /** @var 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)
+        && $user->hasPermission('use ' . $transition->id() . ' transition');
+    });
+
+    return $transitions;
+  }
+
+  /**
+   * Returns a list of 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[]
+   */
+  protected function getTransitionsFrom($state_name) {
+    $result = $this->transitionStateQuery()
+      ->condition('stateFrom', $state_name)
+      ->sort('weight')
+      ->execute();
+
+    return $this->transitionStorage()->loadMultiple($result);
+  }
+
+  /**
+   * 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 string $from
+   *   The origin state machine name.
+   * @param string $to
+   *   The desetination state machine name.
+   * @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($from, $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 string $from
+   *   The name of the "from" state.
+   * @param string $to
+   *   The name of the "to" state.
+   *
+   * @return ModerationStateTransition|null
+   *   A transition object, or NULL if there is no such transition in the system.
+   */
+  protected function getTransitionFromStates($from, $to) {
+    $from = $this->transitionStateQuery()
+      ->condition('stateFrom', $from)
+      ->condition('stateTo', $to)
+      ->execute();
+
+    $transitions = $this->transitionStorage()->loadMultiple($from);
+
+    if ($transitions) {
+      return current($transitions);
+    }
+    return NULL;
+  }
+
+  /**
+   * Determines a transition allowed.
+   *
+   * @param string $from
+   *   The from state.
+   * @param string $to
+   *   The to state.
+   *
+   * @return bool
+   *   Is the transition allowed.
+   */
+  public function isTransitionAllowed($from, $to) {
+    $allowed_transitions = $this->calculatePossibleTransitions();
+    if (isset($allowed_transitions[$from])) {
+      return in_array($to, $allowed_transitions[$from], TRUE);
+    }
+    return FALSE;
+  }
+
+  /**
+   *
+   * @return \Drupal\Core\Entity\Query\QueryInterface
+   *   A transition state query.
+   */
+  protected function transitionStateQuery() {
+    return $this->queryFactory->get('moderation_state_transition', 'AND');
+  }
+
+  /**
+   * Returns the transition entity storage service.
+   *
+   * @return \Drupal\Core\Entity\EntityStorageInterface
+   */
+  protected function transitionStorage() {
+    return $this->entityTypeManager->getStorage('moderation_state_transition');
+  }
+
+  /**
+   * Returns the state entity storage service.
+   *
+   * @return \Drupal\Core\Entity\EntityStorageInterface
+   */
+  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
+   */
+  protected function loadBundleEntity($bundle_entity_type_id, $bundle_id) {
+    if ($bundle_entity_type_id) {
+      return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id);
+    }
+  }
+}
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 0000000..fcb1b79
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationFormTest.php
@@ -0,0 +1,112 @@
+<?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',
+      'needs_review',
+      '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 Request Review'));
+
+    // 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 needs review.
+    $this->drupalPostForm($latest_version_path, [
+      'new_state' => 'needs_review',
+    ], t('Apply'));
+
+    // The latest version page should show the moderation form and have "Needs
+    // Review" status, because the forward revision is in "Needs Review".
+    $this->drupalGet($latest_version_path);
+    $this->assertResponse(200);
+    $this->assertText('Status', 'Form text found on the latest-version page.');
+    $this->assertText('Needs Review', 'Correct status found on the latest-version page.');
+  }
+
+}
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 0000000..ec835c6
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php
@@ -0,0 +1,171 @@
+<?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'];
+
+  /**
+   * Test that an article can be translated and its translations can be
+   * moderated separately as core does.
+   */
+  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'));
+
+    // 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 occured "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.'));
+    $english_node = $this->drupalGetNodeByTitle('Published English node', TRUE);
+    $french_node = $english_node->getTranslation('fr');
+
+    // 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);
+    $french_node = $english_node->getTranslation('fr');
+
+    // 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($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);
+    $french_node = $english_node->getTranslation('fr');
+
+    // 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 and then
+    // publish it.
+    $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.'));
+
+    // 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());
+  }
+
+}
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 0000000..a99c706
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php
@@ -0,0 +1,131 @@
+<?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 0000000..8f4f1c5
--- /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 needs review revision.
+    $this->drupalPostForm($path, [], t('Save and Request Review'));
+    // 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.');
+
+    // Update the draft to review; 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 Request Review'));
+    $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 0000000..807d984
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+use Drupal\Core\Session\AccountInterface;
+use Drupal\user\Entity\Role;
+
+/**
+ * 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.
+   */
+  /**
+   * A node type without moderation state enabled.
+   */
+  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 0000000..94e7bca
--- /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/structure/content-moderation',
+      'admin/structure/content-moderation/states',
+      'admin/structure/content-moderation/states/add',
+      'admin/structure/content-moderation/states/draft',
+      'admin/structure/content-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/structure/content-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', 1);
+    $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/structure/content-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/structure/content-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 0000000..970c2bb
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php
@@ -0,0 +1,149 @@
+<?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_needs_review transition',
+    'use published_draft transition',
+    'use needs_review_published 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',
+    'views',
+    'options',
+    'user',
+  ];
+
+  /**
+   * 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 $id => $state) {
+      $key = $state->isPublishedState() ? 'allowed_moderation_states_published[' . $state->id() . ']' : 'allowed_moderation_states_unpublished[' . $state->id() . ']';
+      $edit[$key] = (int)in_array($id, $allowed_states);
+    }
+
+    $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 0000000..8d399a0
--- /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/structure/content-moderation/transitions',
+      'admin/structure/content-moderation/transitions/add',
+      'admin/structure/content-moderation/transitions/draft_needs_review',
+      'admin/structure/content-moderation/transitions/draft_needs_review/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/structure/content-moderation');
+    $this->clickLink('Moderation state transitions');
+    $this->assertLink('Add Moderation state transition');
+    $this->assertText('Request Review');
+
+    // Edit the Draft » Needs review.
+    $this->drupalGet('admin/structure/content-moderation/transitions/draft_needs_review');
+    $this->assertFieldByName('label', 'Request Review');
+    $this->assertFieldByName('stateFrom', 'draft');
+    $this->assertFieldByName('stateTo', 'needs_review');
+    $this->drupalPostForm(NULL, [
+      'label' => 'Draft to Needs review',
+    ], t('Save'));
+    $this->assertText('Saved the Draft to Needs review Moderation state transition.');
+    $this->drupalGet('admin/structure/content-moderation/transitions/draft_needs_review');
+    $this->assertFieldByName('label', 'Draft to Needs review');
+    // Now set it back.
+    $this->drupalPostForm(NULL, [
+      'label' => 'Request Review',
+    ], t('Save'));
+    $this->assertText('Saved the Request Review Moderation state transition.');
+
+    // Add a new state.
+    $this->drupalGet('admin/structure/content-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/structure/content-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/structure/content-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 0000000..1454240
--- /dev/null
+++ b/core/modules/content_moderation/src/Tests/NodeAccessTest.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Drupal\content_moderation\Tests;
+
+use Drupal\node\NodeInterface;
+
+/**
+ * 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',
+      'needs_review',
+      '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 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 Request Review'));
+    $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 draft_needs_review transition',
+      'use published_draft transition',
+      'use needs_review_published 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 draft_needs_review transition',
+      'use published_draft transition',
+      'use needs_review_published 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/templates/entity-moderation-form.html.twig b/core/modules/content_moderation/templates/entity-moderation-form.html.twig
new file mode 100644
index 0000000..403f5f0
--- /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 0000000..4be38e0
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml
@@ -0,0 +1,399 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - system.menu.main
+  module:
+    - user
+    - content_moderation
+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: node_field_revision
+          field: moderation_state
+          relationship: none
+          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: node
+          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: {  }
+      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/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 0000000..4364b33
--- /dev/null
+++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml
@@ -0,0 +1,8 @@
+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
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 0000000..e3a739a
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Functional;
+
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\simpletest\BrowserTestBase;
+
+/**
+ * Tests the "Latest Revision" views filter.
+ *
+ * @group content_moderation
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ *
+ */
+class LatestRevisionViewsFilterTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['content_moderation_test_views', 'content_moderation', 'node', 'views', 'options', 'user', 'system'];
+
+  /**
+   *
+   */
+  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/Kernel/ContentModerationSchemaTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php
new file mode 100644
index 0000000..ac2b028
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php
@@ -0,0 +1,83 @@
+<?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/EntityOperationsTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php
new file mode 100644
index 0000000..60def39
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php
@@ -0,0 +1,192 @@
+<?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;
+
+/**
+ * Class EntityOperationsTest
+ *
+ * @coversDefaultClass \Drupal\content_moderation\EntityOperations
+ * @group content_moderation
+ */
+class EntityOperationsTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['content_moderation', 'node', 'views', 'options', 'user', 'system'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->installEntitySchema('node');
+    $this->installSchema('node', 'node_access');
+    $this->installEntitySchema('user');
+    $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 0000000..d32595b
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php
@@ -0,0 +1,82 @@
+<?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->installSchema('system', 'router');
+    $this->installSchema('system', 'sequences');
+    $this->installSchema('node', 'node_access');
+    \Drupal::service('router.builder')->rebuild();
+  }
+
+  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());
+  }
+
+  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 0000000..17c6e50
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php
@@ -0,0 +1,162 @@
+<?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;
+use Drupal\node\NodeInterface;
+
+/**
+ * @coversDefaultClass \Drupal\content_moderation\Plugin\Validation\Constraint\ModerationStateValidator
+ * @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->installConfig('content_moderation');
+  }
+
+  /**
+   * Test valid transitions.
+   *
+   * @covers ::validate
+   */
+  public function testValidTransition() {
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->save();
+    $node = Node::create([
+      'type' => 'example',
+      'title' => 'Test title',
+      'moderation_state' => 'draft',
+    ]);
+    $node->save();
+
+    $node->moderation_state->target_id = 'needs_review';
+    $this->assertCount(0, $node->validate());
+  }
+
+  /**
+   * 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',
+      'moderation_state' => '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());
+  }
+
+  /**
+   * Verifies that content without prior moderation information can be moderated.
+   */
+  public function testLegacyContent() {
+    $node_type = NodeType::create([
+      'type' => 'example',
+    ]);
+    $node_type->save();
+    /** @var 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', 'needs_review', '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();
+  }
+
+  /**
+   * Verifies 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 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', 'needs_review', '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 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 0000000..c9d639c
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\content_moderation\Entity\ModerationState;
+
+/**
+ * Class ModerationStateEntityTest
+ *
+ * @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/ModerationStateFieldTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldTest.php
new file mode 100644
index 0000000..ca022ff
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldTest.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests moderation state field.
+ * @group content_moderation
+ */
+class ModerationStateFieldTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['content_moderation', 'node', 'views', 'options', 'user'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    $this->installEntitySchema('node');
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * Covers moderation_state_install().
+   */
+  public function testModerationStateFieldIsCreated() {
+    // There should be no updates as moderation_state_install() should have
+    // applied the new field.
+    $this->assertTrue(empty($this->container->get('entity.definition_update_manager')->needsUpdates()['node']));
+    $this->assertTrue(!empty($this->container->get('entity_field.manager')->getFieldStorageDefinitions('node')['moderation_state']));
+  }
+
+}
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 0000000..bdc31e3
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/ContentPreprocessTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\content_moderation\ContentPreprocess;
+use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\node\Entity\Node;
+
+
+/**
+ * Class ContentPreprocessTest.
+ *
+ * @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);
+  }
+
+  public function routeNodeProvider() {
+    return [
+      ['entity.node.cannonical', 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
+   * @param int $nid
+   *
+   * @return CurrentRouteMatch
+   */
+  protected function setupCurrentRouteMatch($routeName, $nid) {
+    $route_match = $this->prophesize(CurrentRouteMatch::class);
+    $route_match->getRouteName()->willReturn($routeName);
+    $route_match->getParameter('node')->willReturn($this->setupNode($nid));
+
+    return $route_match->reveal();
+  }
+
+  /**
+   * Mock a node object.
+   *
+   * @param int $nid
+   * @return 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 0000000..c9eda19
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/LatestRevisionCheckTest.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Unit;
+
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Access\AccessResultAllowed;
+use Drupal\Core\Access\AccessResultForbidden;
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Routing\RouteMatch;
+use Drupal\node\Entity\Node;
+use Drupal\content_moderation\Access\LatestRevisionCheck;
+use Drupal\content_moderation\ModerationInformation;
+use Drupal\content_moderation\ModerationInformationInterface;
+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.
+   *
+   * @dataProvider accessSituationProvider
+   *
+   * @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.
+   */
+  public function testLatestAccessPermissions($entity_class, $entity_type, $has_forward, $result_class) {
+
+    /** @var EntityInterface $entity */
+    $entity = $this->prophesize($entity_class);
+    $entity->getCacheContexts()->willReturn([]);
+    $entity->getCacheTags()->willReturn([]);
+    $entity->getCacheMaxAge()->willReturn(0);
+
+    /** @var ModerationInformationInterface $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 AccessResult $result */
+    $result = $lrc->access($route->reveal(), $route_match->reveal());
+
+    $this->assertInstanceOf($result_class, $result);
+
+  }
+
+  /**
+   * Data provider for testLastAccessPermissions().
+   *
+   * @return array
+   */
+  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 0000000..f913a36
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php
@@ -0,0 +1,140 @@
+<?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
+   */
+  protected function getUser() {
+    return $this->prophesize(AccountInterface::class)->reveal();
+  }
+
+  /**
+   * Returns a mock Entity Type Manager.
+   *
+   * @param \Drupal\Core\Entity\EntityStorageInterface $entity_bundle_storage
+   *
+   * @return EntityTypeManagerInterface
+   */
+  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();
+  }
+
+  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()));
+  }
+
+  public function testIsModeratedEntityFormWithNonContentEntityForm() {
+    $form = $this->prophesize(EntityFormInterface::class);
+    $moderation_information = new ModerationInformation($this->setupModerationEntityManager(TRUE), $this->getUser());
+
+    $this->assertFalse($moderation_information->isModeratedEntityForm($form->reveal()));
+  }
+
+  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 0000000..366db21
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php
@@ -0,0 +1,252 @@
+<?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
+   */
+  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[]
+   */
+  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
+   */
+  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('published');
+    $state->label()->willReturn('Published');
+    $state->isPublishedState()->willReturn(TRUE);
+    $state->isDefaultRevisionState()->willReturn(TRUE);
+    $states['published'] = $state->reveal();
+
+    $entity_storage->loadMultiple()->willReturn($states);
+
+    return $entity_storage->reveal();
+  }
+
+  /**
+   * Builds a mocked Entity Type Manager.
+   *
+   * @return EntityTypeManagerInterface
+   */
+  protected function setupEntityTypeManager() {
+    $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class);
+    $entityTypeManager->getStorage('moderation_state')->willReturn($this->setupStateStorage());
+    $entityTypeManager->getStorage('moderation_state_transition')->willReturn($this->setupTransitionStorage());
+
+    return $entityTypeManager->reveal();
+  }
+
+  /**
+   * Builds a mocked query factory that does nothing.
+   *
+   * @return QueryFactory
+   */
+  protected function setupQueryFactory() {
+    $factory = $this->prophesize(QueryFactory::class);
+
+    return $factory->reveal();
+  }
+
+  /**
+   * @covers ::isTransitionAllowed
+   * @covers ::calculatePossibleTransitions
+   */
+  public function testIsTransitionAllowedWithValidTransition() {
+    $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager(), $this->setupQueryFactory());
+
+    $this->assertTrue($state_transition_validation->isTransitionAllowed('draft', 'draft'));
+    $this->assertTrue($state_transition_validation->isTransitionAllowed('draft', 'needs_review'));
+    $this->assertTrue($state_transition_validation->isTransitionAllowed('needs_review', 'needs_review'));
+    $this->assertTrue($state_transition_validation->isTransitionAllowed('needs_review', 'staging'));
+    $this->assertTrue($state_transition_validation->isTransitionAllowed('staging', 'published'));
+    $this->assertTrue($state_transition_validation->isTransitionAllowed('needs_review', 'draft'));
+  }
+
+  /**
+   * @covers ::isTransitionAllowed
+   * @covers ::calculatePossibleTransitions
+   */
+  public function testIsTransitionAllowedWithInValidTransition() {
+    $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager(), $this->setupQueryFactory());
+
+    $this->assertFalse($state_transition_validation->isTransitionAllowed('published', 'needs_review'));
+    $this->assertFalse($state_transition_validation->isTransitionAllowed('published', 'staging'));
+    $this->assertFalse($state_transition_validation->isTransitionAllowed('staging', 'needs_review'));
+    $this->assertFalse($state_transition_validation->isTransitionAllowed('staging', 'staging'));
+    $this->assertFalse($state_transition_validation->isTransitionAllowed('needs_review', 'published'));
+    $this->assertFalse($state_transition_validation->isTransitionAllowed('published', 'archived'));
+    $this->assertFalse($state_transition_validation->isTransitionAllowed('archived', 'published'));
+  }
+
+  /**
+   * Verifies user-aware transition validation.
+   *
+   * @param string $from
+   *   The state to transition from.
+   * @param string $to
+   *   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, $to, $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);
+
+    $validator = new Validator($this->setupEntityTypeManager(), $this->setupQueryFactory());
+
+    $this->assertEquals($result, $validator->userMayTransition($from, $to, $user->reveal()));
+  }
+
+  /**
+   * Data provider for the user transition test.
+   *
+   * @return array
+   */
+  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($from, $to) {
+    if ($from == 'draft' && $to == 'draft') {
+      return $this->transitionStorage()->loadMultiple(['draft__draft'])[0];
+    }
+  }
+
+}
diff --git a/core/modules/file/file.module b/core/modules/file/file.module
index d69c149..96bc90e 100644
--- a/core/modules/file/file.module
+++ b/core/modules/file/file.module
@@ -185,6 +185,8 @@ function file_copy(FileInterface $source, $destination = NULL, $replace = FILE_E
 /**
  * Moves a file to a new location and update the file's database entry.
  *
+ * Moving a file is performed by copying the file to the new location and then
+ * deleting the original.
  * - Checks if $source and $destination are valid and readable/writable.
  * - Performs a file move if $source is not equal to $destination.
  * - If file already exists in $destination either the call will error out,
diff --git a/core/modules/language/migration_templates/d6_language_content_settings.yml b/core/modules/language/migration_templates/d6_language_content_settings.yml
deleted file mode 100644
index e5dc750..0000000
--- a/core/modules/language/migration_templates/d6_language_content_settings.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-id: d6_language_content_settings
-label: Drupal 6 language content settings
-migration_tags:
-  - Drupal 6
-source:
-  plugin: d6_language_content_settings
-  constants:
-    target_type: 'node'
-process:
-# Ignore i18n_node_options_[node_type] options not available in Drupal 8,
-# i18n_required_node and i18n_newnode_current
-  target_bundle: type
-  target_entity_type_id: 'constants/target_type'
-  default_langcode:
-    -
-      plugin: static_map
-      source: language_content_type
-      map:
-        0: NULL
-        1: 'current_interface'
-        2: 'current_interface'
-    -
-      plugin: skip_on_empty
-      method: row
-  language_alterable:
-    plugin: static_map
-    source: i18n_lock_node
-    map:
-      0: true
-      1: false
-  'third_party_settings/content_translation/enabled':
-    plugin: static_map
-    source: language_content_type
-    map:
-      # In the case of being 0, it will be skipped. We are not actually setting
-      # a null value.
-      0: NULL
-      1: false
-      2: true
-destination:
-  plugin: entity:language_content_settings
-migration_dependencies:
-  required:
-    - d6_node_type
diff --git a/core/modules/language/migration_templates/d7_language_content_settings.yml b/core/modules/language/migration_templates/d7_language_content_settings.yml
deleted file mode 100644
index cbe935a..0000000
--- a/core/modules/language/migration_templates/d7_language_content_settings.yml
+++ /dev/null
@@ -1,44 +0,0 @@
-id: d7_language_content_settings
-label: Drupal 7 language content settings
-migration_tags:
-  - Drupal 7
-source:
-  plugin: d7_language_content_settings
-  constants:
-    target_type: 'node'
-process:
-# Ignore i18n_node_options_[node_type] options not available in Drupal 8,
-# i18n_required_node and i18n_newnode_current
-  target_bundle: type
-  target_entity_type_id: 'constants/target_type'
-  default_langcode:
-    -
-      plugin: static_map
-      source: language_content_type
-      map:
-        0: NULL
-        1: 'current_interface'
-        2: 'current_interface'
-    -
-      plugin: skip_on_empty
-      method: row
-  language_alterable:
-    plugin: static_map
-    source: i18n_lock_node
-    map:
-      0: true
-      1: false
-  'third_party_settings/content_translation/enabled':
-    plugin: static_map
-    source: language_content_type
-    map:
-      # In the case of being 0, it will be skipped. We are not actually setting
-      # a null value.
-      0: NULL
-      1: false
-      2: true
-destination:
-  plugin: entity:language_content_settings
-migration_dependencies:
-  required:
-    - d7_node_type
diff --git a/core/modules/language/src/Plugin/migrate/source/d6/LanguageContentSettings.php b/core/modules/language/src/Plugin/migrate/source/d6/LanguageContentSettings.php
deleted file mode 100644
index c41b635..0000000
--- a/core/modules/language/src/Plugin/migrate/source/d6/LanguageContentSettings.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-namespace Drupal\language\Plugin\migrate\source\d6;
-
-use Drupal\migrate\Row;
-use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
-
-/**
- * Drupal multilingual node settings from database.
- *
- * @MigrateSource(
- *   id = "d6_language_content_settings",
- * )
- */
-class LanguageContentSettings extends DrupalSqlBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function query() {
-    return $this->select('node_type', 't')
-      ->fields('t', array(
-        'type',
-      ));
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function fields() {
-    $fields = array(
-      'type' => $this->t('Type'),
-      'language_content_type' => $this->t('Multilingual support.'),
-      'i18n_lock_node' => $this->t('Lock language.'),
-    );
-    return $fields;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function prepareRow(Row $row) {
-    $type = $row->getSourceProperty('type');
-    $row->setSourceProperty('language_content_type', $this->variableGet('language_content_type_' . $type, NULL));
-    $row->setSourceProperty('i18n_lock_node', $this->variableGet('i18n_lock_node_' . $type, 0));
-    return parent::prepareRow($row);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getIds() {
-    $ids['type']['type'] = 'string';
-    return $ids;
-  }
-
-}
diff --git a/core/modules/language/src/Plugin/migrate/source/d7/LanguageContentSettings.php b/core/modules/language/src/Plugin/migrate/source/d7/LanguageContentSettings.php
deleted file mode 100644
index 91ed020..0000000
--- a/core/modules/language/src/Plugin/migrate/source/d7/LanguageContentSettings.php
+++ /dev/null
@@ -1,63 +0,0 @@
-<?php
-
-namespace Drupal\language\Plugin\migrate\source\d7;
-
-use Drupal\migrate\Row;
-use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
-
-/**
- * Drupal multilingual node settings from database.
- *
- * @MigrateSource(
- *   id = "d7_language_content_settings",
- * )
- */
-class LanguageContentSettings extends DrupalSqlBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function query() {
-    return $this->select('node_type', 't')
-      ->fields('t', array(
-        'type',
-      ));
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function fields() {
-    $fields = array(
-      'type' => $this->t('Type'),
-      'language_content_type' => $this->t('Multilingual support.'),
-      'i18n_lock_node' => $this->t('Lock language.'),
-    );
-    return $fields;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function prepareRow(Row $row) {
-    $type = $row->getSourceProperty('type');
-    $row->setSourceProperty('language_content_type', $this->variableGet('language_content_type_' . $type, NULL));
-    $i18n_node_options = $this->variableGet('i18n_node_options_' . $type, NULL);
-    if ($i18n_node_options && in_array('lock', $i18n_node_options)) {
-      $row->setSourceProperty('i18n_lock_node', 1);
-    }
-    else {
-      $row->setSourceProperty('i18n_lock_node', 0);
-    }
-    return parent::prepareRow($row);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getIds() {
-    $ids['type']['type'] = 'string';
-    return $ids;
-  }
-
-}
diff --git a/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentSettingsTest.php b/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentSettingsTest.php
deleted file mode 100644
index 0420d70..0000000
--- a/core/modules/language/tests/src/Kernel/Migrate/d6/MigrateLanguageContentSettingsTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-<?php
-
-namespace Drupal\Tests\language\Kernel\Migrate\d6;
-
-use Drupal\language\Entity\ContentLanguageSettings;
-use Drupal\Tests\migrate_drupal\Kernel\d6\MigrateDrupal6TestBase;
-
-/**
- * Tests migration of language content setting variables,
- * language_content_type_$type, i18n_node_options_* and i18n_lock_node_*.
- *
- * @group migrate_drupal_6
- */
-class MigrateLanguageContentSettingsTest extends MigrateDrupal6TestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = ['node', 'text', 'language', 'content_translation'];
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-
-    $this->installConfig(['node']);
-    $this->executeMigrations(['d6_node_type', 'd6_language_content_settings']);
-  }
-
-  /**
-   * Tests migration of content language settings.
-   */
-  public function testLanguageContent() {
-    // Assert that a translatable content is still translatable.
-    $config = $this->config('language.content_settings.node.article');
-    $this->assertSame($config->get('target_entity_type_id'), 'node');
-    $this->assertSame($config->get('target_bundle'), 'article');
-    $this->assertSame($config->get('default_langcode'), 'current_interface');
-    $this->assertTrue($config->get('third_party_settings.content_translation.enabled'));
-
-    // Assert that a non-translatable content is not translatable.
-    $config = ContentLanguageSettings::loadByEntityTypeBundle('node', 'company');
-    $this->assertTrue($config->isDefaultConfiguration());
-    $this->assertFalse($config->isLanguageAlterable());
-    $this->assertSame($config->getDefaultLangcode(), 'site_default');
-  }
-
-  /**
-   * Tests migration of content language settings when there is no language lock.
-   */
-  public function testLanguageContentWithNoLanguageLock() {
-    // Assert that a we can assign a language.
-    $config = ContentLanguageSettings::loadByEntityTypeBundle('node', 'employee');
-    $this->assertSame($config->getDefaultLangcode(), 'current_interface');
-    $this->assertTrue($config->isLanguageAlterable());
-  }
-
-}
diff --git a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentSettingsTest.php b/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentSettingsTest.php
deleted file mode 100644
index defc6cd..0000000
--- a/core/modules/language/tests/src/Kernel/Migrate/d7/MigrateLanguageContentSettingsTest.php
+++ /dev/null
@@ -1,50 +0,0 @@
-<?php
-
-namespace Drupal\Tests\language\Kernel\Migrate\d7;
-
-use Drupal\language\Entity\ContentLanguageSettings;
-use Drupal\Tests\migrate_drupal\Kernel\d7\MigrateDrupal7TestBase;
-
-/**
- * Tests migration of language content setting variables,
- * language_content_type_$type, i18n_node_options_* and i18n_lock_node_*.
- *
- * @group migrate_drupal_7
- */
-class MigrateLanguageContentSettingsTest extends MigrateDrupal7TestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = ['node', 'text', 'language', 'content_translation'];
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-
-    $this->installConfig(['node']);
-    $this->executeMigrations(['d7_node_type', 'd7_language_content_settings']);
-  }
-
-  /**
-   * Tests migration of content language settings.
-   */
-  public function testLanguageContent() {
-    // Assert that a translatable content is still translatable.
-    $config = $this->config('language.content_settings.node.blog');
-    $this->assertIdentical($config->get('target_entity_type_id'), 'node');
-    $this->assertIdentical($config->get('target_bundle'), 'blog');
-    $this->assertIdentical($config->get('default_langcode'), 'current_interface');
-    $this->assertFalse($config->get('language_alterable'));
-    $this->assertTrue($config->get('third_party_settings.content_translation.enabled'));
-
-    // Assert that a non-translatable content is not translatable.
-    $config = ContentLanguageSettings::loadByEntityTypeBundle('node', 'page');
-    $this->assertTrue($config->isDefaultConfiguration());
-    $this->assertFalse($config->isLanguageAlterable());
-    $this->assertSame($config->getDefaultLangcode(), 'site_default');
-
-  }
-
-}
diff --git a/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.node_template.yml b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.node_template.yml
new file mode 100644
index 0000000..6eead8b
--- /dev/null
+++ b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.node_template.yml
@@ -0,0 +1,10 @@
+id: node_template
+label: Template test - node
+migration_tags:
+  - Template Test
+source:
+  plugin: empty
+process:
+  src: barfoo
+destination:
+  plugin: entity:node
diff --git a/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.other_template.yml b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.other_template.yml
new file mode 100644
index 0000000..3d974f0
--- /dev/null
+++ b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.other_template.yml
@@ -0,0 +1,10 @@
+id: other_template
+label: Template with a different tag
+migration_tags:
+  - Different Template Test
+source:
+  plugin: empty
+process:
+  src: raboof
+destination:
+  plugin: entity:user
diff --git a/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.url_template.yml b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.url_template.yml
new file mode 100644
index 0000000..0204046
--- /dev/null
+++ b/core/modules/migrate/tests/modules/template_test/migration_templates/migrate.migration.url_template.yml
@@ -0,0 +1,10 @@
+id: url_template
+label: Template test - URL
+migration_tags:
+  - Template Test
+source:
+  plugin: empty
+process:
+  src: foobar
+destination:
+  plugin: url_alias
diff --git a/core/modules/migrate/tests/modules/template_test/template_test.info.yml b/core/modules/migrate/tests/modules/template_test/template_test.info.yml
new file mode 100644
index 0000000..d396c63
--- /dev/null
+++ b/core/modules/migrate/tests/modules/template_test/template_test.info.yml
@@ -0,0 +1,5 @@
+name: 'Migration template test'
+type: module
+package: Testing
+version: VERSION
+core: 8.x
diff --git a/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php b/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php
index d942bce..87d5457 100644
--- a/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php
+++ b/core/modules/migrate_drupal/src/Plugin/migrate/cckfield/CckFieldPluginBase.php
@@ -10,7 +10,7 @@
 /**
  * The base class for all cck field plugins.
  *
- * @see \Drupal\migrate\Plugin\MigratePluginManager
+ * @see \Drupal\migrate_drupal\Plugin\MigratePluginManager
  * @see \Drupal\migrate_drupal\Annotation\MigrateCckField
  * @see \Drupal\migrate_drupal\Plugin\MigrateCckFieldInterface
  * @see plugin_api
diff --git a/core/modules/migrate_drupal/tests/fixtures/drupal6.php b/core/modules/migrate_drupal/tests/fixtures/drupal6.php
index 7d1c020..4242cad 100644
--- a/core/modules/migrate_drupal/tests/fixtures/drupal6.php
+++ b/core/modules/migrate_drupal/tests/fixtures/drupal6.php
@@ -45534,14 +45534,6 @@
   'value' => 's:1:"2";',
 ))
 ->values(array(
-  'name' => 'language_content_type_employee',
-  'value' => 's:1:"2";',
-))
-->values(array(
-  'name' => 'i18n_lock_node_article',
-  'value' => 'i:1;',
-))
-->values(array(
   'name' => 'language_count',
   'value' => 'i:11;',
 ))
diff --git a/core/modules/migrate_drupal/tests/fixtures/drupal7.php b/core/modules/migrate_drupal/tests/fixtures/drupal7.php
index 258821f..2797b6c 100644
--- a/core/modules/migrate_drupal/tests/fixtures/drupal7.php
+++ b/core/modules/migrate_drupal/tests/fixtures/drupal7.php
@@ -41323,10 +41323,6 @@
   'value' => 's:1:"0";',
 ))
 ->values(array(
-  'name' => 'i18n_node_options_blog',
-  'value' => 'a:2:{i:0;s:8:"required";i:1;s:4:"lock";}',
-))
-->values(array(
   'name' => 'language_count',
   'value' => 'i:2;',
 ))
diff --git a/core/modules/simpletest/tests/src/Functional/BrowserTestBaseTest.php b/core/modules/simpletest/tests/src/Functional/BrowserTestBaseTest.php
index 796360f..d321a4b 100644
--- a/core/modules/simpletest/tests/src/Functional/BrowserTestBaseTest.php
+++ b/core/modules/simpletest/tests/src/Functional/BrowserTestBaseTest.php
@@ -33,9 +33,6 @@ public function testGoTo() {
     // Test page contains some text.
     $this->assertSession()->pageTextContains('Test page text.');
 
-    // Response includes cache tags that we can assert.
-    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Tags', 'rendered');
-
     // Test drupalGet with a url object.
     $url = Url::fromRoute('test_page_test.render_title');
     $this->drupalGet($url);
diff --git a/core/modules/taxonomy/migration_templates/d7_taxonomy_term.yml b/core/modules/taxonomy/migration_templates/d7_taxonomy_term.yml
index 3b6f7bf..7546660 100644
--- a/core/modules/taxonomy/migration_templates/d7_taxonomy_term.yml
+++ b/core/modules/taxonomy/migration_templates/d7_taxonomy_term.yml
@@ -11,8 +11,7 @@ process:
     migration: d7_taxonomy_vocabulary
     source: vid
   name: name
-  'description/value': description
-  'description/format': format
+  description: description
   weight: weight
   parent:
     plugin: migration
diff --git a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php
index 37e56aa..8a2ea11 100644
--- a/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php
+++ b/core/modules/taxonomy/tests/src/Kernel/Migrate/d7/MigrateTaxonomyTermTest.php
@@ -35,21 +35,18 @@ protected function setUp() {
    *   The parent vocabulary the migrated entity should have.
    * @param string $expected_description
    *   The description the migrated entity should have.
-   * @param string $expected_format
-   *   The format the migrated entity should have.
    * @param int $expected_weight
    *   The weight the migrated entity should have.
    * @param array $expected_parents
    *   The parent terms the migrated entity should have.
    */
-  protected function assertEntity($id, $expected_label, $expected_vid, $expected_description = '', $expected_format = NULL, $expected_weight = 0, $expected_parents = []) {
+  protected function assertEntity($id, $expected_label, $expected_vid, $expected_description = '', $expected_weight = 0, $expected_parents = []) {
     /** @var \Drupal\taxonomy\TermInterface $entity */
     $entity = Term::load($id);
     $this->assertTrue($entity instanceof TermInterface);
     $this->assertIdentical($expected_label, $entity->label());
     $this->assertIdentical($expected_vid, $entity->getVocabularyId());
     $this->assertEqual($expected_description, $entity->getDescription());
-    $this->assertEquals($expected_format, $entity->getFormat());
     $this->assertEqual($expected_weight, $entity->getWeight());
     $this->assertIdentical($expected_parents, $this->getParentIDs($id));
   }
@@ -58,14 +55,14 @@ protected function assertEntity($id, $expected_label, $expected_vid, $expected_d
    * Tests the Drupal 7 taxonomy term to Drupal 8 migration.
    */
   public function testTaxonomyTerms() {
-    $this->assertEntity(1, 'General discussion', 'forums', '', NULL, 2);
-    $this->assertEntity(2, 'Term1', 'test_vocabulary', 'The first term.', 'filtered_html');
-    $this->assertEntity(3, 'Term2', 'test_vocabulary', 'The second term.', 'filtered_html');
-    $this->assertEntity(4, 'Term3', 'test_vocabulary', 'The third term.', 'full_html', 0, [3]);
-    $this->assertEntity(5, 'Custom Forum', 'forums', 'Where the cool kids are.', NULL, 3);
-    $this->assertEntity(6, 'Games', 'forums', '', NULL, 4);
-    $this->assertEntity(7, 'Minecraft', 'forums', '', NULL, 1, [6]);
-    $this->assertEntity(8, 'Half Life 3', 'forums', '', NULL, 0, [6]);
+    $this->assertEntity(1, 'General discussion', 'forums', '', 2);
+    $this->assertEntity(2, 'Term1', 'test_vocabulary', 'The first term.');
+    $this->assertEntity(3, 'Term2', 'test_vocabulary', 'The second term.');
+    $this->assertEntity(4, 'Term3', 'test_vocabulary', 'The third term.', 0, [3]);
+    $this->assertEntity(5, 'Custom Forum', 'forums', 'Where the cool kids are.', 3);
+    $this->assertEntity(6, 'Games', 'forums', '', 4);
+    $this->assertEntity(7, 'Minecraft', 'forums', '', 1, [6]);
+    $this->assertEntity(8, 'Half Life 3', 'forums', '', 0, [6]);
   }
 
   /**
diff --git a/core/modules/views/config/schema/views.display.schema.yml b/core/modules/views/config/schema/views.display.schema.yml
index d17c48c..511b0b9 100644
--- a/core/modules/views/config/schema/views.display.schema.yml
+++ b/core/modules/views/config/schema/views.display.schema.yml
@@ -34,12 +34,6 @@ views.display.page:
         weight:
           type: integer
           label: 'Weight'
-        enabled:
-          type: boolean
-          label: 'Enabled'
-        expanded:
-          type: boolean
-          label: 'Expanded'
         menu_name:
           type: string
           label: 'Menu name'
diff --git a/core/modules/views/src/Plugin/Menu/ViewsMenuLink.php b/core/modules/views/src/Plugin/Menu/ViewsMenuLink.php
index b484c24..33a2fd2 100644
--- a/core/modules/views/src/Plugin/Menu/ViewsMenuLink.php
+++ b/core/modules/views/src/Plugin/Menu/ViewsMenuLink.php
@@ -26,6 +26,7 @@ class ViewsMenuLink extends MenuLinkBase implements ContainerFactoryPluginInterf
     'enabled' => 1,
     'title' => 1,
     'description' => 1,
+    'metadata' => 1,
   );
 
   /**
@@ -139,9 +140,9 @@ public function updateLink(array $new_definition_values, $persist) {
       $display = &$view->storage->getDisplay($view->current_display);
       // Just save the title to the original view.
       $changed = FALSE;
-      foreach ($overrides as $key => $new_definition_value) {
-        if (empty($display['display_options']['menu'][$key]) || $display['display_options']['menu'][$key] != $new_definition_value) {
-          $display['display_options']['menu'][$key] = $new_definition_value;
+      foreach ($new_definition_values as $key => $new_definition_value) {
+        if (isset($display['display_options']['menu'][$key]) && $display['display_options']['menu'][$key] != $new_definition_values[$key]) {
+          $display['display_options']['menu'][$key] = $new_definition_values[$key];
           $changed = TRUE;
         }
       }
diff --git a/core/modules/views/src/Plugin/views/display/Page.php b/core/modules/views/src/Plugin/views/display/Page.php
index d79b75b..a046698 100644
--- a/core/modules/views/src/Plugin/views/display/Page.php
+++ b/core/modules/views/src/Plugin/views/display/Page.php
@@ -124,7 +124,6 @@ protected function defineOptions() {
         'title' => array('default' => ''),
         'description' => array('default' => ''),
         'weight' => array('default' => 0),
-        'enabled' => array('default' => TRUE),
         'menu_name' => array('default' => 'main'),
         'parent' => array('default' => ''),
         'context' => array('default' => ''),
diff --git a/core/modules/views/src/Plugin/views/display/PathPluginBase.php b/core/modules/views/src/Plugin/views/display/PathPluginBase.php
index 966cc1a..9ee7fd2 100644
--- a/core/modules/views/src/Plugin/views/display/PathPluginBase.php
+++ b/core/modules/views/src/Plugin/views/display/PathPluginBase.php
@@ -322,8 +322,6 @@ public function getMenuLinks() {
         $links[$menu_link_id]['title'] = $menu['title'];
         $links[$menu_link_id]['description'] = $menu['description'];
         $links[$menu_link_id]['parent'] = $menu['parent'];
-        $links[$menu_link_id]['enabled'] = $menu['enabled'];
-        $links[$menu_link_id]['expanded'] = $menu['expanded'];
 
         if (isset($menu['weight'])) {
           $links[$menu_link_id]['weight'] = intval($menu['weight']);
diff --git a/core/modules/views/tests/src/Kernel/Plugin/Display/ViewsMenuLinkTest.php b/core/modules/views/tests/src/Kernel/Plugin/Display/ViewsMenuLinkTest.php
deleted file mode 100644
index 9e05ebc..0000000
--- a/core/modules/views/tests/src/Kernel/Plugin/Display/ViewsMenuLinkTest.php
+++ /dev/null
@@ -1,98 +0,0 @@
-<?php
-
-namespace Drupal\Tests\views\Kernel\Plugin\Display;
-
-use Drupal\Tests\views\Kernel\ViewsKernelTestBase;
-
-/**
- * Menu link test.
- *
- * @group views
- */
-class ViewsMenuLinkTest extends ViewsKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'menu_ui',
-    'user',
-    'views'
-  ];
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $testViews = ['test_page_display_menu'];
-
-  /**
-   * The entity manager.
-   *
-   * @var \Drupal\Core\Entity\EntityManagerInterface
-   */
-  protected $entityManger;
-
-  /**
-   * The menu link manager.
-   *
-   * @var \Drupal\Core\Menu\MenuLinkManagerInterface
-   */
-  protected $menuLinkManager;
-
-  /**
-   * The menu link overrides.
-   *
-   * @var \Drupal\Core\Menu\StaticMenuLinkOverridesInterface
-   */
-  protected $menuLinkOverrides;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp($import_test_views = TRUE) {
-    parent::setUp($import_test_views);
-
-    $this->entityManger = $this->container->get('entity.manager');
-    $this->menuLinkManager = $this->container->get('plugin.manager.menu.link');
-    $this->menuLinkOverrides = $this->container->get('menu_link.static.overrides');
-  }
-
-  /**
-   * Test views internal menu link options.
-   */
-  public function testMenuLinkOverrides() {
-    // Link from views module.
-    $views_link = $this->menuLinkManager->getDefinition('views_view:views.test_page_display_menu.page_3');
-    $this->assertTrue($views_link['enabled'], 'Menu link is enabled.');
-    $this->assertFalse($views_link['expanded'], 'Menu link is not expanded.');
-    $views_link['enabled'] = 0;
-    $views_link['expanded'] = 1;
-    $this->menuLinkManager->updateDefinition($views_link['id'], $views_link);
-    $views_link = $this->menuLinkManager->getDefinition($views_link['id']);
-    $this->assertFalse($views_link['enabled'], 'Menu link is disabled.');
-    $this->assertTrue($views_link['expanded'], 'Menu link is expanded.');
-    $this->menuLinkManager->rebuild();
-    $this->assertFalse($views_link['enabled'], 'Menu link is disabled.');
-    $this->assertTrue($views_link['expanded'], 'Menu link is expanded.');
-
-    // Link from user module.
-    $user_link = $this->menuLinkManager->getDefinition('user.page');
-    $this->assertTrue($user_link['enabled'], 'Menu link is enabled.');
-    $user_link['enabled'] = 0;
-    $views_link['expanded'] = 1;
-    $this->menuLinkManager->updateDefinition($user_link['id'], $user_link);
-    $this->assertFalse($user_link['enabled'], 'Menu link is disabled.');
-    $this->menuLinkManager->rebuild();
-    $this->assertFalse($user_link['enabled'], 'Menu link is disabled.');
-
-    $this->menuLinkOverrides->reload();
-
-    $views_link = $this->menuLinkManager->getDefinition('views_view:views.test_page_display_menu.page_3');
-    $this->assertFalse($views_link['enabled'], 'Menu link is disabled.');
-    $this->assertTrue($views_link['expanded'], 'Menu link is expanded.');
-
-    $user_link = $this->menuLinkManager->getDefinition('user.page');
-    $this->assertFalse($user_link['enabled'], 'Menu link is disabled.');
-  }
-
-}
diff --git a/core/modules/views/views.install b/core/modules/views/views.install
index 30ac79c..8c5041c 100644
--- a/core/modules/views/views.install
+++ b/core/modules/views/views.install
@@ -368,28 +368,6 @@ function views_update_8100() {
 }
 
 /**
- * Set default values for enabled/expanded flag on page displays.
- */
-function views_update_8101() {
-  $config_factory = \Drupal::configFactory();
-  foreach ($config_factory->listAll('views.view.') as $view_config_name) {
-    $view = $config_factory->getEditable($view_config_name);
-    $save = FALSE;
-    foreach ($view->get('display') as $display_id => $display) {
-      if ($display['display_plugin'] == 'page') {
-        $display['display_options']['menu']['enabled'] = TRUE;
-        $display['display_options']['menu']['expanded'] = FALSE;
-        $view->set("display.$display_id", $display);
-        $save = TRUE;
-      }
-    }
-    if ($save) {
-      $view->save();
-    }
-  }
-}
-
-/**
  * @} End of "addtogroup updates-8.1.0".
  */
 
diff --git a/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php b/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php
index 0789fc8..d63ada6 100644
--- a/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php
+++ b/core/tests/Drupal/FunctionalTests/AssertLegacyTrait.php
@@ -240,7 +240,7 @@ protected function assertUrl($path) {
    * @deprecated Scheduled for removal in Drupal 9.0.0.
    *   Use $this->assertSession()->assertNoEscaped() instead.
    */
-  protected function assertNoEscaped($raw) {
+  public function assertNoEscaped($raw) {
     $this->assertSession()->assertNoEscaped($raw);
   }
 
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php b/core/tests/Drupal/KernelTests/Core/Theme/StableTemplateOverrideTest.php
index 5e2265d..f4e242d 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'
   ];
 
   /**
diff --git a/core/tests/Drupal/Tests/BrowserTestBase.php b/core/tests/Drupal/Tests/BrowserTestBase.php
index c701dbd..07e1fa9 100644
--- a/core/tests/Drupal/Tests/BrowserTestBase.php
+++ b/core/tests/Drupal/Tests/BrowserTestBase.php
@@ -6,8 +6,6 @@
 use Behat\Mink\Element\Element;
 use Behat\Mink\Mink;
 use Behat\Mink\Session;
-use Drupal\Component\FileCache\FileCacheFactory;
-use Drupal\Component\Serialization\Yaml;
 use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\SafeMarkup;
 use Drupal\Component\Utility\UrlHelper;
@@ -1077,14 +1075,11 @@ public function installDrupal() {
       // testing specific overrides.
       file_put_contents($directory . '/settings.php', "\n\$test_class = '" . get_class($this) . "';\n" . 'include DRUPAL_ROOT . \'/\' . $site_path . \'/settings.testing.php\';' . "\n", FILE_APPEND);
     }
-
     $settings_services_file = DRUPAL_ROOT . '/' . $this->originalSiteDirectory . '/testing.services.yml';
-    if (!file_exists($settings_services_file)) {
-      // Otherwise, use the default services as a starting point for overrides.
-      $settings_services_file = DRUPAL_ROOT . '/sites/default/default.services.yml';
+    if (file_exists($settings_services_file)) {
+      // Copy the testing-specific service overrides in place.
+      copy($settings_services_file, $directory . '/services.yml');
     }
-    // Copy the testing-specific service overrides in place.
-    copy($settings_services_file, $directory . '/services.yml');
 
     // Since Drupal is bootstrapped already, install_begin_request() will not
     // bootstrap into DRUPAL_BOOTSTRAP_CONFIGURATION (again). Hence, we have to
@@ -1109,10 +1104,6 @@ public function installDrupal() {
     // using File API; a potential error must trigger a PHP warning.
     chmod($directory, 0777);
 
-    // During tests, cacheable responses should get the debugging cacheability
-    // headers by default.
-    $this->setContainerParameter('http.response.debug_cacheability_headers', TRUE);
-
     $request = \Drupal::request();
     $this->kernel = DrupalKernel::createFromRequest($request, $this->classLoader, 'prod', TRUE);
     $this->kernel->prepareLegacyRequest($request);
@@ -1514,7 +1505,6 @@ protected function resetAll() {
    */
   protected function refreshVariables() {
     // Clear the tag cache.
-    $this->container->get('cache_tags.invalidator')->resetChecksums();
     // @todo Replace drupal_static() usage within classes and provide a
     //   proper interface for invoking reset() on a cache backend:
     //   https://www.drupal.org/node/2311945.
@@ -1799,25 +1789,4 @@ public static function assertEquals($expected, $actual, $message = '', $delta =
     parent::assertEquals($expected, $actual, $message, $delta, $maxDepth, $canonicalize, $ignoreCase);
   }
 
-  /**
-   * Changes parameters in the services.yml file.
-   *
-   * @param string $name
-   *   The name of the parameter.
-   * @param mixed $value
-   *   The value of the parameter.
-   */
-  protected function setContainerParameter($name, $value) {
-    $filename = $this->siteDirectory . '/services.yml';
-    chmod($filename, 0666);
-
-    $services = Yaml::decode(file_get_contents($filename));
-    $services['parameters'][$name] = $value;
-    file_put_contents($filename, Yaml::encode($services));
-
-    // Ensure that the cache is deleted for the yaml file loader.
-    $file_cache = FileCacheFactory::get('container_yaml_loader');
-    $file_cache->delete($filename);
-  }
-
 }
diff --git a/core/tests/Drupal/Tests/Core/Cache/ChainedFastBackendTest.php b/core/tests/Drupal/Tests/Core/Cache/ChainedFastBackendTest.php
index 0b576b7..956c18d 100644
--- a/core/tests/Drupal/Tests/Core/Cache/ChainedFastBackendTest.php
+++ b/core/tests/Drupal/Tests/Core/Cache/ChainedFastBackendTest.php
@@ -34,21 +34,6 @@ class ChainedFastBackendTest extends UnitTestCase {
   protected $bin;
 
   /**
-   * Tests that chained fast backend cannot be constructed with two instances of
-   * the same service.
-   */
-  public function testConsistentAndFastBackendCannotBeTheSameService() {
-    // ToDo: It should throw a proper exception. See https://www.drupal.org/node/2751847.
-    $this->setExpectedException(\PHPUnit_Framework_Error::class, 'Consistent cache backend and fast cache backend cannot use the same service.');
-    $cache = $this->getMock('Drupal\Core\Cache\CacheBackendInterface');
-    $chained_fast_backend = new ChainedFastBackend(
-      $cache,
-      $cache,
-      'foo'
-    );
-  }
-
-  /**
    * Tests a get() on the fast backend, with no hit on the consistent backend.
    */
   public function testGetDoesntHitConsistentBackend() {
