diff --git a/core/modules/content_moderation/src/Entity/ContentModerationState.php b/core/modules/content_moderation/src/Entity/ContentModerationState.php
index 6d0cb0abd5..99dd438403 100644
--- a/core/modules/content_moderation/src/Entity/ContentModerationState.php
+++ b/core/modules/content_moderation/src/Entity/ContentModerationState.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\content_moderation\Entity;
 
+use Drupal\content_moderation\Event\ContentModerationEvents;
+use Drupal\content_moderation\Event\ContentModerationStateChangedEvent;
 use Drupal\Core\Entity\ContentEntityBase;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityTypeInterface;
@@ -153,15 +155,26 @@ public static function loadFromModeratedEntity(EntityInterface $entity) {
   }
 
   /**
-   * {@inheritdoc}
+   * Load the entity whose state is being tracked by this entity.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface
+   *   The entity whose state is being tracked.
    */
-  public function save() {
+  protected function loadModeratedEntity() {
     $related_entity = \Drupal::entityTypeManager()
       ->getStorage($this->content_entity_type_id->value)
       ->loadRevision($this->content_entity_revision_id->value);
     if ($related_entity instanceof TranslatableInterface) {
       $related_entity = $related_entity->getTranslation($this->activeLangcode);
     }
+    return $related_entity;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function save() {
+    $related_entity = $this->loadModeratedEntity();
     $related_entity->moderation_state = $this->moderation_state;
     return $related_entity->save();
   }
@@ -179,7 +192,25 @@ public function save() {
    *   In case of failures an exception is thrown.
    */
   protected function realSave() {
-    return parent::save();
+    if (!$this->getLoadedRevisionId()) {
+      $original_state = FALSE;
+    }
+    else {
+      $original_content_moderation_state = \Drupal::entityTypeManager()
+        ->getStorage($this->getEntityTypeId())
+        ->loadRevision($this->getLoadedRevisionId());
+      if (!$this->isDefaultTranslation() && $original_content_moderation_state->hasTranslation($this->activeLangcode)) {
+        $original_content_moderation_state = $original_content_moderation_state->getTranslation($this->activeLangcode);
+      }
+      $original_state = $original_content_moderation_state->moderation_state->value;
+    }
+
+    $result = parent::save();
+
+    $event = new ContentModerationStateChangedEvent($this->loadModeratedEntity(), $this->moderation_state->value, $original_state, $this->workflow->target_id);
+    \Drupal::service('event_dispatcher')->dispatch(ContentModerationEvents::STATE_CHANGED, $event);
+
+    return $result;
   }
 
   /**
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 0000000000..4c0b58ed1c
--- /dev/null
+++ b/core/modules/content_moderation/src/Event/ContentModerationEvents.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\content_moderation\Event;
+
+/**
+ * Defines events that content_moderation dispatches.
+ *
+ * @see \Drupal\content_moderation\Event\ContentModerationStateChangedEvent
+ */
+final class ContentModerationEvents {
+
+  /**
+   * Name of the event fired when content changes state.
+   *
+   * @see \Drupal\content_moderation\Event\ContentModerationStateChangedEvent
+   * @see \Drupal\content_moderation\Entity\ContentModerationState::realSave()
+   */
+  const STATE_CHANGED = 'content_moderation.state_changed';
+
+}
diff --git a/core/modules/content_moderation/src/Event/ContentModerationStateChangedEvent.php b/core/modules/content_moderation/src/Event/ContentModerationStateChangedEvent.php
new file mode 100644
index 0000000000..96c9487a65
--- /dev/null
+++ b/core/modules/content_moderation/src/Event/ContentModerationStateChangedEvent.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\content_moderation\Event;
+
+use Drupal\Component\EventDispatcher\Event;
+use Drupal\Core\Entity\ContentEntityInterface;
+
+/**
+ * Defines content moderation state change events.
+ *
+ * @see \Drupal\content_moderation\Entity\ContentModerationState
+ * @see \Drupal\content_moderation\Event\ContentModerationEvents
+ */
+class ContentModerationStateChangedEvent extends Event {
+
+  /**
+   * The entity that was moderated.
+   *
+   * @var \Drupal\Core\Entity\ContentEntityInterface
+   */
+  protected $moderatedEntity;
+
+  /**
+   * The state the content has changed to.
+   *
+   * @var string
+   */
+  protected $newState;
+
+  /**
+   * The state the content was before, or FALSE if none existed.
+   *
+   * @var string|FALSE
+   */
+  protected $originalState;
+
+  /**
+   * The ID of the workflow which allowed the state change.
+   *
+   * @var string
+   */
+  protected $workflow;
+
+  /**
+   * Create a new ContentModerationStateChangedEvent.
+   *
+   * @param \Drupal\Core\Entity\ContentEntityInterface $moderated_entity
+   *   The entity that is being moderated.
+   * @param string $new_state
+   *   The new state the content is moving to.
+   * @param string $original_state
+   *   The original state of the content, before the change was made.
+   * @param string $workflow
+   *   The ID of the workflow that allowed the state change.
+   */
+  public function __construct(ContentEntityInterface $moderated_entity, $new_state, $original_state, $workflow) {
+    $this->moderatedEntity = $moderated_entity;
+    $this->newState = $new_state;
+    $this->originalState = $original_state;
+    $this->workflow = $workflow;
+  }
+
+  /**
+   * Get the entity that is being moderated.
+   *
+   * @return \Drupal\Core\Entity\ContentEntityInterface
+   *   The entity that is being moderated.
+   */
+  public function getModeratedEntity() {
+    return $this->moderatedEntity;
+  }
+
+  /**
+   * Get the new state of the content.
+   *
+   * @return string
+   *   The state the content has been changed to.
+   */
+  public function getNewState() {
+    return $this->newState;
+  }
+
+  /**
+   * Get the original state of the content.
+   *
+   * @return string
+   *   The state the content was before.
+   */
+  public function getOriginalState() {
+    return $this->originalState;
+  }
+
+  /**
+   * Get the ID of the workflow which allowed this state change.
+   *
+   * @return string
+   *   The ID of the workflow.
+   */
+  public function getWorkflow() {
+    return $this->workflow;
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateChangedEventTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateChangedEventTest.php
new file mode 100644
index 0000000000..0b46a950db
--- /dev/null
+++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateChangedEventTest.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Drupal\Tests\content_moderation\Kernel;
+
+use Drupal\content_moderation\Event\ContentModerationStateChangedEvent;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\language\Entity\ConfigurableLanguage;
+use Drupal\node\Entity\Node;
+use Drupal\node\Entity\NodeType;
+use Drupal\Tests\content_moderation\Traits\ContentModerationTestTrait;
+use Drupal\workflows\Entity\Workflow;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Tests events are properly fired during create/save of moderation states.
+ *
+ * @group content_moderation
+ */
+class ContentModerationStateChangedEventTest extends KernelTestBase {
+
+  use ContentModerationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'system',
+    'language',
+    'content_translation',
+    'content_moderation',
+    'user',
+    'workflows',
+    'node',
+  ];
+
+  /**
+   * A mock event dispatcher.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $eventDispatcher;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->installEntitySchema('content_moderation_state');
+    $this->installEntitySchema('user');
+    $this->installEntitySchema('node');
+    $this->installConfig('workflows');
+    $this->installSchema('node', ['node_access']);
+    $this->installConfig('content_moderation');
+
+    NodeType::create([
+      'title' => 'Test node',
+      'type' => 'example',
+    ])->save();
+
+    $workflow = $this->createEditorialWorkflow();
+    $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
+    $workflow->save();
+
+    $this->eventDispatcher = $this->getMock(EventDispatcherInterface::class);
+    $this->container->set('event_dispatcher', $this->eventDispatcher);
+
+    ConfigurableLanguage::createFromLangcode('fr')->save();
+  }
+
+  /**
+   * Tests events for adding and updating moderation state entities.
+   */
+  public function testCreateUpdateStates() {
+
+    $this->assertEventDispatchedAtIndex(0, function($event_name, ContentModerationStateChangedEvent $event) {
+      $this->assertEquals('content_moderation.state_changed', $event_name);
+      $this->assertEquals('editorial', $event->getWorkflow());
+      $this->assertEquals(FALSE, $event->getOriginalState());
+      $this->assertEquals('draft', $event->getNewState());
+      $this->assertEquals('node', $event->getModeratedEntity()->getEntityTypeId());
+      $this->assertEquals(1, $event->getModeratedEntity()->getRevisionId());
+    });
+
+    $this->assertEventDispatchedAtIndex(1, function($event_name, ContentModerationStateChangedEvent $event) {
+      $this->assertEquals('content_moderation.state_changed', $event_name);
+      $this->assertEquals('editorial', $event->getWorkflow());
+      $this->assertEquals('draft', $event->getOriginalState());
+      $this->assertEquals('published', $event->getNewState());
+      $this->assertEquals('node', $event->getModeratedEntity()->getEntityTypeId());
+      $this->assertEquals(2, $event->getModeratedEntity()->getRevisionId());
+    });
+
+    $this->assertEventDispatchedAtIndex(2, function($event_name, ContentModerationStateChangedEvent $event) {
+      $this->assertEquals('content_moderation.state_changed', $event_name);
+      $this->assertEquals('editorial', $event->getWorkflow());
+      $this->assertEquals('published', $event->getOriginalState());
+      $this->assertEquals('archived', $event->getNewState());
+      $this->assertEquals('node', $event->getModeratedEntity()->getEntityTypeId());
+      $this->assertEquals(3, $event->getModeratedEntity()->getRevisionId());
+    });
+
+    $this->assertEventDispatchedAtIndex(3, function($event_name, ContentModerationStateChangedEvent $event) {
+      $this->assertEquals('content_moderation.state_changed', $event_name);
+      $this->assertEquals('editorial', $event->getWorkflow());
+      $this->assertEquals('archived', $event->getOriginalState());
+      $this->assertEquals('published', $event->getNewState());
+      $this->assertEquals('node', $event->getModeratedEntity()->getEntityTypeId());
+      $this->assertEquals(4, $event->getModeratedEntity()->getRevisionId());
+      $this->assertEquals('fr', $event->getModeratedEntity()->language()->getId());
+    });
+
+    $this->assertEventDispatchedAtIndex(4, function($event_name, ContentModerationStateChangedEvent $event) {
+      $this->assertEquals('content_moderation.state_changed', $event_name);
+      $this->assertEquals('editorial', $event->getWorkflow());
+      $this->assertEquals('published', $event->getOriginalState());
+      $this->assertEquals('draft', $event->getNewState());
+      $this->assertEquals('node', $event->getModeratedEntity()->getEntityTypeId());
+      $this->assertEquals(5, $event->getModeratedEntity()->getRevisionId());
+      $this->assertEquals('fr', $event->getModeratedEntity()->language()->getId());
+    });
+
+    $node = Node::create([
+      'type' => 'example',
+      'title' => 'Foo',
+      'moderation_state' => 'draft',
+    ]);
+    $node->save();
+
+    $node->moderation_state = 'published';
+    $node->save();
+
+    $node->moderation_state = 'archived';
+    $node->save();
+
+    $french_node = $node->addTranslation('fr');
+    $french_node->title = 'French node';
+    $french_node->moderation_state = 'published';
+    $french_node->save();
+
+    $french_node->moderation_state = 'draft';
+    $french_node->save();
+  }
+
+  /**
+   * Assert the event information dispatched at a particular index.
+   *
+   * @param int $index
+   *   The index.
+   * @param callable $callback
+   *   A callback passed two arguments, the event name and event.
+   */
+  protected function assertEventDispatchedAtIndex($index, $callback) {
+    $this->eventDispatcher
+      ->expects($this->at($index))
+      ->method('dispatch')
+      ->willReturnCallback($callback);
+  }
+
+}
diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateStorageSchemaTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateStorageSchemaTest.php
index c7b84fd6ba..e8fe82e75a 100644
--- a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateStorageSchemaTest.php
+++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateStorageSchemaTest.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\Tests\content_moderation\Kernel;
 
+use Drupal\block_content\Entity\BlockContent;
+use Drupal\block_content\Entity\BlockContentType;
 use Drupal\content_moderation\Entity\ContentModerationState;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\node\Entity\Node;
@@ -28,7 +30,7 @@ class ContentModerationStateStorageSchemaTest extends KernelTestBase {
     'system',
     'text',
     'workflows',
-    'entity_test',
+    'block_content',
   ];
 
   /**
@@ -39,7 +41,7 @@ protected function setUp(): void {
 
     $this->installSchema('node', 'node_access');
     $this->installEntitySchema('node');
-    $this->installEntitySchema('entity_test');
+    $this->installEntitySchema('block_content');
     $this->installEntitySchema('user');
     $this->installEntitySchema('content_moderation_state');
     $this->installConfig('content_moderation');
@@ -47,8 +49,15 @@ protected function setUp(): void {
     NodeType::create([
       'type' => 'example',
     ])->save();
+
+    BlockContentType::create([
+      'label' => 'Test',
+      'id' => 'example',
+    ])->save();
+
     $workflow = $this->createEditorialWorkflow();
     $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example');
+    $workflow->getTypePlugin()->addEntityTypeAndBundle('block_content', 'example');
     $workflow->save();
   }
 
@@ -90,18 +99,22 @@ public function testUniqueKeys() {
     ], FALSE);
 
     // Different entity types should not trigger an exception.
-    $this->assertStorageException([
-      'content_entity_type_id' => 'entity_test',
-      'content_entity_id' => $node->id(),
-      'content_entity_revision_id' => $node->getRevisionId(),
-    ], FALSE);
+    $block_content = BlockContent::create([
+      'info' => 'Test block',
+      'type' => 'example',
+      'moderation_state' => 'draft',
+    ]);
+    $block_content->save();
+    $this->assertEquals($block_content->id(), $node->id());
+    $this->assertEquals($block_content->getRevisionId(), $node->getRevisionId());
 
     // Different entity and revision IDs should not trigger an exception.
-    $this->assertStorageException([
-      'content_entity_type_id' => $node->getEntityTypeId(),
-      'content_entity_id' => 9999,
-      'content_entity_revision_id' => 9999,
-    ], FALSE);
+    $second_node = Node::create([
+      'title' => 'Test title',
+      'type' => 'example',
+      'moderation_state' => 'draft',
+    ]);
+    $second_node->save();
 
     // Creating a version of the entity with a previously used, but not current
     // revision ID should trigger an exception.
