diff --git a/core/lib/Drupal/Component/Utility/UrlHelper.php b/core/lib/Drupal/Component/Utility/UrlHelper.php
index 20c2748f2c..316f598c21 100644
--- a/core/lib/Drupal/Component/Utility/UrlHelper.php
+++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
@@ -160,7 +160,7 @@ public static function parse($url) {
       }
       // If there is a query string, transform it into keyed query parameters.
       if (isset($parts[1])) {
-        parse_str($parts[1], $options['query']);
+        $options['query'] = static::parseQueryString($parts[1]);
       }
     }
     // Internal URLs.
@@ -171,7 +171,7 @@ public static function parse($url) {
       // Strip the leading slash that was just added.
       $options['path'] = substr($parts['path'], 1);
       if (isset($parts['query'])) {
-        parse_str($parts['query'], $options['query']);
+        $options['query'] = static::parseQueryString($parts['query']);
       }
       if (isset($parts['fragment'])) {
         $options['fragment'] = $parts['fragment'];
@@ -181,6 +181,45 @@ public static function parse($url) {
     return $options;
   }
 
+  /**
+   * A wrapper for parse_str() that restores original parameter names.
+   *
+   * The parse_str() function can be used to set variables in the current scope,
+   * although this is deprecated. Because variables in PHP cannot have periods
+   * and spaces in their names, parse_str() converts them to underscores. This
+   * breaks external links and front end libraries that require specific
+   * parameter names. It can also cause name collisions.
+   *
+   * @param string $query
+   *   A query string to parse.
+   *
+   * @return array
+   *   The result of parse_str() with original parameter names restored.
+   */
+  public static function parseQueryString($query) {
+    // Create an array to map parsed parameters to original names.
+    $parsed = [];
+    foreach (explode('&', $query) as $param) {
+      list($name) = explode('=', $param, 2);
+      $name = rawurldecode($name);
+      // Extract the first item of a potentially nested parameter name.
+      $name = strstr($name, '[', true) ?: $name;
+      // Parse the query parameter.
+      parse_str($param, $param);
+      // Merge the parameter into the parsed array.
+      if (isset($parsed[$name])) {
+        if (!is_array($parsed[$name])) {
+          $parsed[$name] = [$parsed[$name]];
+        }
+        $parsed[$name] = NestedArray::mergeDeepArray([$parsed[$name], reset($param)], TRUE);
+      }
+      else {
+        $parsed[$name] = reset($param);
+      }
+    }
+    return $parsed;
+  }
+
   /**
    * Encodes a Drupal path for use in a URL.
    *
diff --git a/core/lib/Drupal/Core/Url.php b/core/lib/Drupal/Core/Url.php
index 82c2c600b3..1c9bab02e4 100644
--- a/core/lib/Drupal/Core/Url.php
+++ b/core/lib/Drupal/Core/Url.php
@@ -303,8 +303,7 @@ public static function fromUri($uri, $options = []) {
     }
 
     if (!empty($uri_parts['query'])) {
-      $uri_query = [];
-      parse_str($uri_parts['query'], $uri_query);
+      $uri_query = UrlHelper::parseQueryString($uri_parts['query']);
       $uri_options['query'] = isset($uri_options['query']) ? $uri_options['query'] + $uri_query : $uri_query;
       unset($uri_parts['query']);
     }
diff --git a/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php b/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
index 613ecfffbe..8c583c84e2 100644
--- a/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
+++ b/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
@@ -269,6 +269,30 @@ public static function providerTestParse() {
           'fragment' => 'footer',
         ],
       ],
+      'query parameters with characters not allowed in PHP variable names' => [
+        'http://www.example.com/my/path?single.value=&single value=&single_value=&multi.value[first]=&multi.value[second]=&multi value[third]=&multi value[fourth]=&multi_value[fifth]=&multi_value[sixth]=',
+        [
+          'path' => 'http://www.example.com/my/path',
+          'query' => [
+            'single.value' => '',
+            'single value' => '',
+            'single_value' => '',
+            'multi.value' => [
+              'first' => '',
+              'second' => '',
+            ],
+            'multi value' => [
+              'third' => '',
+              'fourth' => '',
+            ],
+            'multi_value' => [
+              'fifth' => '',
+              'sixth' => '',
+            ],
+          ],
+          'fragment' => '',
+        ],
+      ],
       'absolute fragment, no query' => [
         'http://www.example.com/my/path#footer',
         [
diff --git a/core/tests/Drupal/Tests/Core/UrlTest.php b/core/tests/Drupal/Tests/Core/UrlTest.php
index 450f03407c..4004ff5c86 100644
--- a/core/tests/Drupal/Tests/Core/UrlTest.php
+++ b/core/tests/Drupal/Tests/Core/UrlTest.php
@@ -333,6 +333,16 @@ public function testGetUriForExternalUrl() {
     $this->assertEquals('http://example.com/test', $url->getUri());
   }
 
+  /**
+   * Tests the getUri() method for external URLs with parameters.
+   *
+   * @covers ::getUri
+   */
+  public function testGetUriForExternalUrlWithParameters() {
+    $url = Url::fromUri('http://www.example.com/my/path?single.value=&single value=&single_value=&multi.value[first]=&multi.value[second]=&multi value[third]=&multi value[fourth]=&multi_value[fifth]=&multi_value[sixth]=');
+    $this->assertEquals('http://www.example.com/my/path?single.value=&single value=&single_value=&multi.value[first]=&multi.value[second]=&multi value[third]=&multi value[fourth]=&multi_value[fifth]=&multi_value[sixth]=', $url->getUri());
+  }
+
   /**
    * Tests the getUri() and isExternal() methods for protocol-relative URLs.
    *