diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
index 2b06b92c26..df53d38aa4 100644
--- a/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
+++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageBase.php
@@ -166,6 +166,74 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
     return $translation;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function createRevision(ContentEntityInterface $entity, $default_revision = TRUE, $keep_untranslatable_fields = NULL) {
+    $new_revision = clone $entity;
+
+    // For translatable entities, create a merged revision of the active
+    // translation and the other translations in the default revision. This
+    // allows to create pending revisions that can always be saved as the new
+    // default revision without reverting changes in other languages.
+    if (!$entity->isNew() && $entity->isTranslatable()) {
+      $active_langcode = $entity->language()->getId();
+
+      // Default to preserving the untranslatable field values in the default
+      // revision, otherwise we may expose data that was not meant to be
+      // accessible.
+      if (!isset($keep_untranslatable_fields)) {
+        $keep_untranslatable_fields = FALSE;
+      }
+
+      /** @var \Drupal\Core\Entity\ContentEntityInterface $default_revision */
+      $default_revision = $this->load($entity->id());
+      /** @var \Drupal\Core\Field\FieldDefinitionInterface[] $definitions */
+      $definitions = $default_revision->getFieldDefinitions();
+      foreach ($default_revision->getTranslationLanguages() as $langcode => $language) {
+        if ($langcode == $active_langcode) {
+          continue;
+        }
+
+        $default_revision_translation = $default_revision->getTranslation($langcode);
+        $new_revision_translation = $new_revision->hasTranslation($langcode) ?
+          $new_revision->getTranslation($langcode) : $new_revision->addTranslation($langcode);
+
+        foreach ($definitions as $field_name => $definition) {
+          if (!$keep_untranslatable_fields || $definition->isTranslatable()) {
+            $new_revision_translation->set($field_name, $default_revision_translation->get($field_name)->getValue());
+          }
+        }
+
+        // Make sure the "revision_translation_affected" flag is recalculated.
+        $new_revision_translation->setRevisionTranslationAffected(NULL);
+
+        // No need to copy untranslatable field values more than once.
+        $keep_untranslatable_fields = TRUE;
+      }
+
+      // Preserve a reference to the current revision, as the new revision would
+      // appear as being originated from the default revision otherwise.
+      $new_revision->set($this->entityType->getKey('revision'), $entity->getLoadedRevisionId());
+      $new_revision->updateLoadedRevisionId();
+
+      // Populate the "original" property with the current values, given that
+      // the new revision is not stored anywhere.
+      $new_revision->original = clone $new_revision;
+    }
+
+    // Eventually mark the new revision as such.
+    $new_revision->setNewRevision();
+    $new_revision->isDefaultRevision($default_revision);
+
+    // Actually make sure the current translation is marked as affected, even if
+    // there are no explicit changes, to be sure this revision can be related
+    // to the correct translation.
+    $new_revision->setRevisionTranslationAffected(TRUE);
+
+    return $new_revision;
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -757,4 +825,49 @@ protected function buildCacheId($id) {
     return "values:{$this->entityTypeId}:$id";
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getLatestRevisionId($id) {
+    if (!$this->entityType->isRevisionable()) {
+      return NULL;
+    }
+
+    $result = $this->getQuery()
+      ->latestRevision()
+      ->condition($this->entityType->getKey('id'), $id)
+      ->accessCheck(FALSE)
+      ->execute();
+
+    return key($result);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLatestAffectedRevisionId($id, $langcode) {
+    if (!$this->entityType->isRevisionable()) {
+      return NULL;
+    }
+
+    if (!$this->entityType->isTranslatable()) {
+      return $this->getLatestRevisionId($id);
+    }
+
+    $id_key = $this->entityType->getKey('id');
+    $revision_key = $this->entityType->getKey('revision');
+    $rta_key = $this->entityType->getKey('revision_translation_affected');
+
+    $result = $this->getQuery()
+      ->allRevisions()
+      ->condition($id_key, $id)
+      ->condition($rta_key, 1, '=', $langcode)
+      ->range(0, 1)
+      ->sort($revision_key, 'DESC')
+      ->accessCheck(FALSE)
+      ->execute();
+
+    return key($result);
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
index eb979c7fd6..978b0b0351 100644
--- a/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
+++ b/core/lib/Drupal/Core/Entity/ContentEntityStorageInterface.php
@@ -5,7 +5,7 @@
 /**
  * A storage that supports content entity types.
  */
-interface ContentEntityStorageInterface extends EntityStorageInterface {
+interface ContentEntityStorageInterface extends EntityStorageInterface, RevisionableStorageInterface {
 
   /**
    * Constructs a new entity translation object, without permanently saving it.
@@ -23,6 +23,26 @@
    */
   public function createTranslation(ContentEntityInterface $entity, $langcode, array $values = []);
 
+  /**
+   * Creates a new revision starting off from the specified entity object.
+   *
+   * When dealing with a translatable entity, this will merge the default
+   * revision with the latest affected revision for the entity active language.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   The entity object being modified.
+   * @param bool $default_revision
+   *   (optional) Whether the new revision should be marked as default. Defaults
+   *   to TRUE.
+   * @param bool|null $keep_untranslatable_fields
+   *   (optional) Whether untranslatable field values should be kept or copied
+   *   from the default revision when generating a merged revision. Defaults to
+   *   FALSE.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface
+   *   A new entity revision object.
+   */
+  public function createRevision(ContentEntityInterface $entity, $default_revision = TRUE, $keep_untranslatable_fields = NULL);
 
   /**
    * Creates an entity with sample field values.
@@ -40,4 +60,17 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    */
   public function createWithSampleValues($bundle = FALSE, array $values = []);
 
+  /**
+   * Returns the latest affected revision identifier for an entity translation.
+   *
+   * @param int $id
+   *   The entity identifier.
+   * @param string $langcode
+   *   The language of the translation.
+   *
+   * @return int|null
+   *   The latest revision identifier or NULL if no revision could be found.
+   */
+  public function getLatestAffectedRevisionId($id, $langcode);
+
 }
diff --git a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php
index b3fc12d6b6..0f914ea8c2 100644
--- a/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php
+++ b/core/lib/Drupal/Core/Entity/KeyValueStore/KeyValueContentEntityStorage.php
@@ -23,4 +23,18 @@ public function createTranslation(ContentEntityInterface $entity, $langcode, arr
    */
   public function createWithSampleValues($bundle = FALSE, array $values = []) {}
 
+  /**
+   * {@inheritdoc}
+   */
+  public function getLatestRevisionId($id) {
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getLatestAffectedRevisionId($id, $langcode) {
+    return NULL;
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php b/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php
new file mode 100644
index 0000000000..52ecf83918
--- /dev/null
+++ b/core/lib/Drupal/Core/Entity/RevisionableStorageInterface.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\Core\Entity;
+
+/**
+ * A storage that supports revisionable entity types.
+ */
+interface RevisionableStorageInterface {
+
+  /**
+   * Returns the latest revision identifier for an entity.
+   *
+   * @param int $id
+   *   The entity identifier.
+   *
+   * @return int|null
+   *   The latest revision identifier or NULL if no revision could be found.
+   */
+  public function getLatestRevisionId($id);
+
+}
diff --git a/core/modules/system/tests/modules/entity_test/entity_test.module b/core/modules/system/tests/modules/entity_test/entity_test.module
index 025cb5fc2f..7d4df70bae 100644
--- a/core/modules/system/tests/modules/entity_test/entity_test.module
+++ b/core/modules/system/tests/modules/entity_test/entity_test.module
@@ -206,6 +206,23 @@ function entity_test_entity_bundle_info() {
   return $bundles;
 }
 
+/**
+ * Implements hook_entity_bundle_info_alter().
+ */
+function entity_test_entity_bundle_info_alter(&$bundles) {
+  $entity_info = \Drupal::entityTypeManager()->getDefinitions();
+  $state = \Drupal::state();
+  foreach ($bundles as $entity_type_id => &$all_bundle_info) {
+    if ($entity_info[$entity_type_id]->getProvider() == 'entity_test') {
+      if ($state->get('entity_test.translation')) {
+        foreach ($all_bundle_info as $bundle_name => &$bundle_info) {
+          $bundle_info['translatable'] = TRUE;
+        }
+      }
+    }
+  }
+}
+
 /**
  * Implements hook_entity_view_mode_info_alter().
  */
diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php
index f01675f602..b14cbd0fc3 100644
--- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php
+++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestMulRev.php
@@ -1,6 +1,8 @@
 <?php
 
 namespace Drupal\entity_test\Entity;
+use Drupal\Core\Entity\EntityTypeInterface;
+use Drupal\Core\Field\BaseFieldDefinition;
 
 /**
  * Defines the test entity class.
@@ -47,4 +49,17 @@
  */
 class EntityTestMulRev extends EntityTestRev {
 
+  /**
+   * {@inheritdoc}
+   */
+  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
+    $fields = parent::baseFieldDefinitions($entity_type);
+
+    $fields['not_translatable'] = BaseFieldDefinition::create('string')
+      ->setLabel(t('Non translatable'))
+      ->setDescription(t('A non-translatable string field'));
+
+    return $fields;
+  }
+
 }
diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php
new file mode 100644
index 0000000000..a713f7d0c1
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDecoupledTranslationRevisionsTest.php
@@ -0,0 +1,457 @@
+<?php
+
+namespace Drupal\KernelTests\Core\Entity;
+
+use Drupal\Core\Entity\ContentEntityInterface;
+use Drupal\entity_test\Entity\EntityTestMulRev;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\user\Entity\User;
+
+/**
+ * Test decoupled translation revisions.
+ *
+ * @group entity
+ *
+ * @coversDefaultClass \Drupal\Core\Entity\ContentEntityStorageBase
+ */
+class EntityDecoupledTranslationRevisionsTest extends EntityKernelTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = [
+    'system',
+    'entity_test',
+    'language',
+  ];
+
+  /**
+   * The entity bundle info service.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface
+   */
+  protected $bundleInfo;
+
+  /**
+   * The entity storage.
+   *
+   * @var \Drupal\Core\Entity\ContentEntityStorageInterface
+   */
+  protected $storage;
+
+  /**
+   * The translations of the test entity.
+   *
+   * @var \Drupal\Core\Entity\ContentEntityInterface[]
+   */
+  protected $translations;
+
+  /**
+   * The previous revision identifiers for the various revision translations.
+   *
+   * @var int[]
+   */
+  protected $previousRevisionId = [];
+
+  /**
+   * The previous unstranslatable field value.
+   *
+   * @var string[]
+   */
+  protected $previousUntranslatableFieldValue;
+
+  /**
+   * The current edit sequence step index.
+   *
+   * @var int
+   */
+  protected $stepIndex;
+
+  /**
+   * The current edit sequence step info.
+   *
+   * @var array
+   */
+  protected $stepInfo;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $entity_type_id = 'entity_test_mulrev';
+    $this->installEntitySchema($entity_type_id);
+    $this->storage = $this->container->get('entity_type.manager')
+      ->getStorage($entity_type_id);
+
+    $this->installConfig(['language']);
+    $langcodes = ['it', 'fr'];
+    foreach ($langcodes as $langcode) {
+      ConfigurableLanguage::createFromLangcode($langcode)->save();
+    }
+
+    $values = [
+      'name' => $this->randomString(),
+      'status' => 1,
+    ];
+    User::create($values)->save();
+
+    // Make sure entity bundles are translatable.
+    $this->state->set('entity_test.translation', TRUE);
+    $this->bundleInfo = \Drupal::service('entity_type.bundle.info');
+    $this->bundleInfo->clearCachedBundles();
+  }
+
+  /**
+   * Data provider for ::testDecoupledDefaultRevisions.
+   */
+  public function dataTestDecoupledPendingRevisions() {
+    $sets = [];
+
+    $sets['Intermixed languages - No initial default translation'] = [[
+      ['en', TRUE],
+      ['en', FALSE],
+      ['it', FALSE],
+      ['en', FALSE],
+      ['it', FALSE],
+      ['en', TRUE],
+      ['it', TRUE],
+    ]];
+
+    $sets['Intermixed languages - With initial default translation'] = [[
+      ['en', TRUE],
+      ['it', TRUE],
+      ['en', FALSE],
+      ['it', FALSE],
+      ['en', TRUE],
+      ['it', TRUE],
+    ]];
+
+    $sets['Alternate languages - No initial default translation'] = [[
+      ['en', TRUE],
+      ['en', FALSE],
+      ['en', FALSE],
+      ['en', TRUE],
+      ['it', FALSE],
+      ['en', TRUE],
+      ['it', FALSE],
+      ['it', FALSE],
+      ['it', TRUE],
+    ]];
+
+    $sets['Alternate languages - With initial default translation'] = [[
+      ['en', TRUE],
+      ['it', TRUE],
+      ['en', TRUE],
+      ['en', FALSE],
+      ['en', FALSE],
+      ['en', TRUE],
+      ['it', TRUE],
+      ['it', FALSE],
+      ['it', FALSE],
+      ['it', TRUE],
+    ]];
+
+    $sets['Multiple languages - No initial default translation'] = [[
+      ['en', TRUE],
+      ['it', FALSE],
+      ['fr', FALSE],
+      ['en', FALSE],
+      ['en', TRUE],
+      ['it', TRUE],
+      ['fr', FALSE],
+      ['en', FALSE],
+      ['it', FALSE],
+      ['en', TRUE],
+      ['fr', TRUE],
+      ['it', TRUE],
+      ['fr', TRUE],
+    ]];
+
+    $sets['Multiple languages - With initial default translation'] = [[
+      ['en', TRUE],
+      ['it', TRUE],
+      ['fr', TRUE],
+      ['en', FALSE],
+      ['it', FALSE],
+      ['en', TRUE],
+      ['it', TRUE],
+      ['fr', FALSE],
+      ['en', FALSE],
+      ['it', FALSE],
+      ['en', TRUE],
+      ['fr', TRUE],
+      ['it', TRUE],
+      ['fr', TRUE],
+    ]];
+
+    return $sets;
+  }
+
+  /**
+   * Test decoupled default revisions.
+   *
+   * @param array[] $sequence
+   *   An array with arrays of arguments for the ::doSaveNewRevision() method as
+   *   values. Every child array corresponds to a method invocation.
+   *
+   * @covers ::createRevision
+   *
+   * @dataProvider dataTestDecoupledPendingRevisions
+   */
+  public function testDecoupledPendingRevisions($sequence) {
+    $revision_id = $this->doTestEditSequence($sequence);
+    $this->assertEquals(count($sequence), $revision_id);
+  }
+
+  /**
+   * Data provider for ::testUntranslatableFields.
+   */
+  public function dataTestUntranslatableFields() {
+    $sets = [];
+
+    $sets['Default behavior - Untranslatable fields affect all revisions'] = [
+      [
+        ['en', TRUE, TRUE],
+        ['it', TRUE, TRUE],
+        ['en', FALSE],
+        ['it', FALSE],
+      ],
+    ];
+
+    return $sets;
+  }
+
+  /**
+   * Tests that untranslatable fields are handled correctly.
+   *
+   * @param array[] $sequence
+   *   An array with arrays of arguments for the ::doSaveNewRevision() method as
+   *   values. Every child array corresponds to a method invocation.
+   *
+   * @covers ::createRevision
+   *
+   * @dataProvider dataTestUntranslatableFields
+   */
+  public function testUntranslatableFields($sequence) {
+    // Test that a new entity is always valid.
+    $entity = EntityTestMulRev::create();
+    $entity->set('not_translatable', 0);
+    $violations = $entity->validate();
+    $this->assertEmpty($violations);
+
+    // Test the specified sequence.
+    $this->doTestEditSequence($sequence);
+  }
+
+  /**
+   * Actually tests an edit step sequence.
+   *
+   * @param array[] $sequence
+   *   An array of sequence steps.
+   *
+   * @return int
+   *   The latest saved revision id.
+   */
+  protected function doTestEditSequence($sequence) {
+    $revision_id = NULL;
+    foreach ($sequence as $index => $step) {
+      $this->stepIndex = $index;
+      $revision_id = call_user_func_array([$this, 'doEditStep'], $step);
+    }
+    return $revision_id;
+  }
+
+  /**
+   * Saves a new revision of the test entity.
+   *
+   * @param string $active_langcode
+   *   The language of the translation for which a new revision will be saved.
+   * @param bool $default_revision
+   *   Whether the revision should be flagged as the default revision.
+   * @param bool $untranslatable_update
+   *   (optional) Whether an untranslatable field update should be performed.
+   *   Defaults to FALSE.
+   * @param bool $valid
+   *   (optional) Whether entity validation is expected to succeed. Defaults to
+   *   TRUE.
+   *
+   * @return int
+   *   The new revision identifier.
+   */
+  protected function doEditStep($active_langcode, $default_revision, $untranslatable_update = FALSE, $valid = TRUE) {
+    $this->stepInfo = [$active_langcode, $default_revision, $untranslatable_update, $valid];
+
+    // Initialize previous data tracking.
+    if (!isset($this->translations)) {
+      $this->translations[$active_langcode] = EntityTestMulRev::create();
+      $this->previousRevisionId[$active_langcode] = 0;
+    }
+    if (!isset($this->translations[$active_langcode])) {
+      $this->translations[$active_langcode] = reset($this->translations)->addTranslation($active_langcode);
+      $this->previousRevisionId[$active_langcode] = 0;
+    }
+
+    // We want to update previous data only if we expect a valid result,
+    // otherwise we would be just polluting it with invalid values.
+    if ($valid) {
+      $entity = &$this->translations[$active_langcode];
+      $previous_revision_id = &$this->previousRevisionId[$active_langcode];
+    }
+    else {
+      $entity = clone $this->translations[$active_langcode];
+      $previous_revision_id = $this->previousRevisionId[$active_langcode];
+    }
+
+    // Check that after instantiating a new revision for the specified
+    // translation, we are resuming work from where we left the last time. If
+    // that is the case, the label generated for the previous revision should
+    // match the stored one.
+    if (!$entity->isNew()) {
+      $previous_label = NULL;
+      if (!$entity->isNewTranslation()) {
+        $previous_label = $this->generateNewEntityLabel($entity, $previous_revision_id);
+      }
+      $previous_revision_id = (int) $entity->getLoadedRevisionId();
+      $latest_affected_revision_id = $this->storage->getLatestAffectedRevisionId($entity->id(), $entity->language()->getId());
+      /** @var \Drupal\Core\Entity\ContentEntityInterface $latest_affected_revision */
+      $latest_affected_revision = $this->storage->loadRevision($latest_affected_revision_id);
+      $translation = $latest_affected_revision->hasTranslation($active_langcode) ?
+        $latest_affected_revision->getTranslation($active_langcode) : $latest_affected_revision->addTranslation($active_langcode);
+      $entity = $this->storage->createRevision($translation, $default_revision);
+      $this->assertEquals($previous_label, $entity->label(), $this->formatMessage('Loaded translatable field value does not match the previous one.'));
+    }
+
+    $value = $entity->get('not_translatable')->value;
+    if (isset($previous_untranslatable_field_value)) {
+      $this->assertEquals($previous_untranslatable_field_value, $value, $this->formatMessage('Loaded untranslatable field value does not match the previous one.'));
+    }
+
+    // Perform a change and store it.
+    $label = $this->generateNewEntityLabel($entity, $previous_revision_id, TRUE);
+    $entity->set('name', $label);
+    if ($untranslatable_update) {
+      // Store the revision id of the previous untranslatable fields update in
+      // the new value, besides the upcoming revision id. Useful for analyzing
+      // test failures.
+      $prev = 0;
+      if (isset($value)) {
+        preg_match('/^\d+ -> (\d+)$/', $value, $matches);
+        $prev = $matches[1];
+      }
+      $value = $prev . ' -> ' . ($entity->getLoadedRevisionId() + 1);
+      $entity->set('not_translatable', $value);
+    }
+
+    $violations = $entity->validate();
+    $messages = [];
+    foreach ($violations as $violation) {
+      $messages[] = $violation->message;
+    }
+    $this->assertEquals($valid, !$violations->count(), $this->formatMessage('Validation does not match the expected result: %s', implode(', ', $messages)));
+
+    if ($valid) {
+      $entity->save();
+
+      // Reload the current revision translation and the default revision to
+      // make sure data was stored correctly.
+      /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */
+      $entity = $this->storage->loadRevision($entity->getRevisionId());
+      $entity = $entity->getTranslation($active_langcode);
+      /** @var \Drupal\Core\Entity\ContentEntityInterface $default_entity */
+      $default_entity = $this->storage->loadUnchanged($entity->id());
+
+      // Verify that the values for the current revision translation match the
+      // expected ones, while for the other translations they match the default
+      // revision. We also need to verify that only the current revision
+      // translation was marked as affected.
+      foreach ($entity->getTranslationLanguages() as $langcode => $language) {
+        $translation = $entity->getTranslation($langcode);
+        $rta_expected = $langcode == $active_langcode || $untranslatable_update;
+        $this->assertEquals($rta_expected, $translation->isRevisionTranslationAffected(), $this->formatMessage("'$langcode' translation incorrectly affected"));
+        $label_expected = $label;
+        if ($langcode !== $active_langcode) {
+          $default_translation = $default_entity->hasTranslation($langcode) ? $default_entity->getTranslation($langcode) : $default_entity;
+          $label_expected = $default_translation->label();
+        }
+        $this->assertEquals($label_expected, $translation->label(), $this->formatMessage("Incorrect '$langcode' translation label"));
+      }
+    }
+
+    return $entity->getRevisionId();
+  }
+
+  /**
+   * Generates a new label for the specified revision.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $revision
+   *   An entity object.
+   * @param int $previous_revision_id
+   *   The previous revision identifier for this revision translation.
+   * @param bool $next
+   *   (optional) Whether the label describes the current revision or the one
+   *   to be created. Defaults to FALSE.
+   *
+   * @return string
+   *   A revision label.
+   */
+  protected function generateNewEntityLabel(ContentEntityInterface $revision, $previous_revision_id, $next = FALSE) {
+    $language_label = $revision->language()->getName();
+    $revision_type = $revision->isDefaultRevision() ? 'Default' : 'Pending';
+    $revision_id = $next ? $this->storage->getLatestRevisionId($revision->id()) + 1 : $revision->getLoadedRevisionId();
+    return sprintf('%s (%s %d -> %d)', $language_label, $revision_type, $previous_revision_id, $revision_id);
+  }
+
+  /**
+   * Formats an assertion message.
+   *
+   * @param string $message
+   *   The human-readable message.
+   *
+   * @return string
+   *   The formatted message.
+   */
+  protected function formatMessage($message) {
+    $args = func_get_args();
+    array_shift($args);
+    $params = array_merge($args, $this->stepInfo);
+    array_unshift($params, $this->stepIndex + 1);
+    array_unshift($params, '[Step %d] ' . $message . ' (langcode: %s, default_revision: %d, untranslatable_update: %d, valid: %d)');
+    return call_user_func_array('sprintf', $params);
+  }
+
+  /**
+   * Tests that internal properties are preserved while creating a new revision.
+   */
+  public function testInternalProperties() {
+    $entity = EntityTestMulRev::create();
+    $this->doTestInternalProperties($entity);
+
+    $entity = EntityTestMulRev::create();
+    $entity->save();
+    $this->doTestInternalProperties($entity);
+
+    /** @var \Drupal\entity_test\Entity\EntityTestMulRev $translation */
+    $translation = EntityTestMulRev::create()->addTranslation('it');
+    $translation->save();
+    $this->doTestInternalProperties($translation);
+  }
+
+  /**
+   * Checks that internal properties are preserved for the specified entity.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
+   *   An entity object.
+   */
+  protected function doTestInternalProperties(ContentEntityInterface $entity) {
+    $this->assertFalse($entity->isValidationRequired());
+    $entity->setValidationRequired(TRUE);
+    $this->assertTrue($entity->isValidationRequired());
+    $new_revision = $this->storage->createRevision($entity);
+    $this->assertTrue($new_revision->isValidationRequired());
+  }
+
+}
