diff --git a/core/includes/bootstrap.inc b/core/includes/bootstrap.inc
index 462283a..2c6bab9 100644
--- a/core/includes/bootstrap.inc
+++ b/core/includes/bootstrap.inc
@@ -16,6 +16,7 @@
 use Drupal\Core\Site\Settings;
 use Drupal\Core\Utility\Error;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\Utility\Message;
 
 /**
  * Minimum supported version of PHP.
@@ -514,6 +515,12 @@ function drupal_get_messages($type = NULL, $clear_queue = TRUE) {
   @trigger_error('drupal_get_message() is deprecated in Drupal 8.5.0 and will be removed before Drupal 9.0.0. Use \Drupal\Core\Messenger\MessengerInterface::all() or \Drupal\Core\Messenger\MessengerInterface::messagesByType() instead. See https://www.drupal.org/node/2774931', E_USER_DEPRECATED);
   $messenger = \Drupal::messenger();
   if ($messages = $messenger->all()) {
+    // Convert \Drupal\Core\Utility\Message objects to render arrays.
+    array_walk_recursive($messages, function (&$message) {
+      if ($message instanceof Message) {
+        $message = $message->toRenderArray();
+      }
+    });
     if ($type) {
       if ($clear_queue) {
         $messenger->deleteByType($type);
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index ac085b4..c046b73 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -1863,5 +1863,8 @@ function drupal_common_theme() {
     'field_multiple_value_form' => [
       'render element' => 'element',
     ],
+    'status_message' => [
+      'variables' => ['message' => NULL, 'sub_messages' => []],
+    ],
   ];
 }
diff --git a/core/lib/Drupal/Core/Utility/Message.php b/core/lib/Drupal/Core/Utility/Message.php
new file mode 100644
index 0000000..9d2919f
--- /dev/null
+++ b/core/lib/Drupal/Core/Utility/Message.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\Core\Utility;
+
+use Drupal\Component\Utility\SafeMarkup;
+use Drupal\Component\Utility\SafeStringInterface;
+use Drupal\Core\Render\SafeString;
+
+/**
+ * Allows drupal_set_message() to render messages with sub messages.
+ *
+ * @see drupal_set_message()
+ */
+class Message {
+
+  /**
+   * The main message.
+   *
+   * @var \Drupal\Component\Utility\SafeStringInterface|string
+   */
+  protected $message;
+
+  /**
+   * The sub messages.
+   *
+   * @var \Drupal\Component\Utility\SafeStringInterface[]|string[]
+   */
+  protected $subMessages = [];
+
+  /**
+   * Constructs a Message object.
+   *
+   * @param \Drupal\Component\Utility\SafeStringInterface|string $message
+   *   The main message.
+   * @param \Drupal\Component\Utility\SafeStringInterface[]|string[] $sub_messages
+   *   The sub messages.
+   */
+  public function __construct($message, array $sub_messages = []) {
+    $this->message = $this->ensureSafeString($message);
+    $this->subMessages = array_map(function ($sub_message) {
+      return $this->ensureSafeString($sub_message);
+    }, $sub_messages);
+  }
+
+  /**
+   * Convert the Message object to a render array.
+   *
+   * @return array
+   *   The message as a render array.
+   */
+  public function toRenderArray() {
+    $render_array = [
+      '#theme' => 'status_message',
+      '#message' => $this->message,
+    ];
+    if (count($this->subMessages)) {
+      $render_array['#sub_messages'] = [
+        '#theme' => 'item_list',
+        '#items' => $this->subMessages,
+      ];
+    }
+    return $render_array;
+  }
+
+  /**
+   * Ensures that safeness is preserved.
+   *
+   * @param $string
+   *
+   * @return \Drupal\Component\Utility\SafeStringInterface|string
+   */
+  protected function ensureSafeString($string) {
+    if ($string instanceof SafeStringInterface) {
+      return $string;
+    }
+    $string = (string) $string;
+    if (SafeMarkup::isSafe($string)) {
+      return SafeString::create($string);
+    }
+    return $string;
+  }
+
+}
diff --git a/core/modules/system/templates/status-message.html.twig b/core/modules/system/templates/status-message.html.twig
new file mode 100644
index 0000000..bc62dd3
--- /dev/null
+++ b/core/modules/system/templates/status-message.html.twig
@@ -0,0 +1,16 @@
+{#
+/**
+ * @file
+ * Default theme implementation a status message with sub messages.
+ *
+ * Available variables:
+ * - message: The main message.
+ * - sub_messages: Any sub messges for this message.
+ *
+ * @ingroup themeable
+ */
+#}
+{{ message }}
+{% if sub_messages %}
+  {{ sub_messages }}
+{% endif %}
diff --git a/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php b/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php
index e350bc2..18a61c6 100644
--- a/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php
+++ b/core/modules/system/tests/modules/system_test/src/Controller/SystemTestController.php
@@ -10,6 +10,7 @@
 use Drupal\Core\Render\Markup;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\Core\Url;
+use Drupal\Core\Utility\Message;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -142,6 +143,10 @@ public function drupalSetMessageTest() {
     // Test auto-escape of non safe strings.
     drupal_set_message('<em>This<span>markup will be</span> escaped</em>.');
 
+    // Test using the Message class.
+    $message = new Message(t('Message object with <em>markup</em>'), [t('Sub message 1'), t('Sub message 2 <em>with markup</em>'), 'Unsafe sub message 3 <em>with markup</em>']);
+    drupal_set_message($message);
+
     return [];
   }
 
diff --git a/core/modules/system/tests/src/Functional/Bootstrap/DrupalSetMessageTest.php b/core/modules/system/tests/src/Functional/Bootstrap/DrupalSetMessageTest.php
index 976e8be..fc01c12 100644
--- a/core/modules/system/tests/src/Functional/Bootstrap/DrupalSetMessageTest.php
+++ b/core/modules/system/tests/src/Functional/Bootstrap/DrupalSetMessageTest.php
@@ -43,6 +43,13 @@ public function testDrupalSetMessage() {
 
     // Ensure that strings that are not marked as safe are escaped.
     $this->assertEscaped('<em>This<span>markup will be</span> escaped</em>.');
+
+    // Ensure that \Drupal\Core\Utility\Message objects are rendered correctly.
+    $this->assertRaw('Message object with <em>markup</em>');
+    // Ensure the sub messages are rendered in an item list. The second sub
+    // message should contain markup but the third sub message should have
+    // escaped markup.
+    $this->assertRaw('<div class="item-list"><ul><li>Sub message 1</li><li>Sub message 2 <em>with markup</em></li><li>Unsafe sub message 3 &lt;em&gt;with markup&lt;/em&gt;</li></ul></div>');
   }
 
 }
