diff --git a/src/Template/TwigExtension.php b/src/Template/TwigExtension.php
index dd7e44d37287c321a1f739bbb7cc5676345610b0..f23b9379d283547f8b53af9f083f658cf807922a 100644
--- a/src/Template/TwigExtension.php
+++ b/src/Template/TwigExtension.php
@@ -26,6 +26,9 @@ class TwigExtension extends AbstractExtension {
    */
   public function getFilters() {
     return [
+      'recursive_merge' => new TwigFilter('recursive_merge', [
+        'Drupal\components\Template\TwigExtension', 'recursiveMergeFilter',
+      ]),
       'set' => new TwigFilter('set', [
         'Drupal\components\Template\TwigExtension', 'setFilter',
       ]),
@@ -111,7 +114,7 @@ class TwigExtension extends AbstractExtension {
    * Recursively merges an array into the element, replacing existing values.
    *
    * @code
-   * {{ form|set( {'element': {'attributes': {'placeholder': 'Label'}}} ) }}
+   * {{ form|recursive_merge( {'element': {'attributes': {'placeholder': 'Label'}}} ) }}
    * @endcode
    *
    * @param array|iterable|\Traversable $element
@@ -125,14 +128,52 @@ class TwigExtension extends AbstractExtension {
    * @throws \Twig\Error\RuntimeError
    *   When $element is not an array or "Traversable".
    */
-  public static function setFilter($element, $array) {
+  public static function recursiveMergeFilter($element, $array) {
     if (!twig_test_iterable($element)) {
-      throw new RuntimeError(sprintf('The set filter only works with arrays or "Traversable", got "%s" as first argument.', gettype($element)));
+      throw new RuntimeError(sprintf('The recursive_merge filter only works on arrays or "Traversable" objects, got "%s".', gettype($element)));
     }
 
     return array_replace_recursive($element, $array);
   }
 
+  /**
+   * Sets a deeply-nested property on an array.
+   *
+   * If the deeply-nested property exists, the existing data will be replaced
+   * with the new value.
+   *
+   * @code
+   * {{ form|set( 'element.#attributes.placeholder', 'Label' ) }}
+   * @endcode
+   *
+   * @param array|iterable|\Traversable $element
+   *   The parent renderable array to set into.
+   * @param string|iterable|array $path
+   *   The dotted-path to the deeply nested element to set. (Or an array value
+   *   to merge, if using the backwards-compatible 2.x syntax.)
+   * @param mixed $value
+   *   The value to set.
+   *
+   * @return array
+   *   The merged renderable array.
+   *
+   * @throws \Twig\Error\RuntimeError
+   *   When $element is not an array or "Traversable".
+   */
+  public static function setFilter($element, $path, $value = NULL) {
+    if (!twig_test_iterable($element)) {
+      throw new RuntimeError(sprintf('The set filter only works on arrays or "Traversable" objects, got "%s".', gettype($element)));
+    }
+
+    // Backwards-compatibility with old 8.x-2.x version of set filter.
+    if (!is_string($path)) {
+      @trigger_error('Calling the "set" filter with an array is deprecated in components:8.x-2.3 and will be removed in components:3.0.0. Update to the new syntax or use the "recursive_merge" filter instead. See https://www.drupal.org/project/components/issues/3209440', E_USER_DEPRECATED);
+      return self::recursiveMergeFilter($element, $path);
+    }
+
+    return self::addOrSetFilter($element, $path, $value, FALSE);
+  }
+
   /**
    * Adds a deeply-nested property on an array.
    *
@@ -141,7 +182,7 @@ class TwigExtension extends AbstractExtension {
    * the new value will be merged into the existing array.
    *
    * @code
-   * {{ form|add( 'element.attributes.class', 'new-class' ) }}
+   * {{ form|add( 'element.#attributes.class', 'new-class' ) }}
    * @endcode
    *
    * @param array|iterable|\Traversable $element
@@ -159,9 +200,28 @@ class TwigExtension extends AbstractExtension {
    */
   public static function addFilter($element, string $path, $value) {
     if (!twig_test_iterable($element)) {
-      throw new RuntimeError(sprintf('The add filter only works with arrays or "Traversable", got "%s" as first argument.', gettype($element)));
+      throw new RuntimeError(sprintf('The add filter only works on arrays or "Traversable" objects, got "%s".', gettype($element)));
     }
 
+    return self::addOrSetFilter($element, $path, $value, TRUE);
+  }
+
+  /**
+   * Helper function for the set/add filters.
+   *
+   * @param array|iterable|\Traversable $element
+   *   The parent renderable array to merge into.
+   * @param string $path
+   *   The dotted-path to the deeply nested element to replace.
+   * @param mixed $value
+   *   The value to set.
+   * @param bool $is_add_filter
+   *   Which filter is being called.
+   *
+   * @return array
+   *   The merged renderable array.
+   */
+  protected static function addOrSetFilter($element, string $path, $value, $is_add_filter = FALSE) {
     if ($element instanceof \ArrayAccess) {
       $filtered_element = clone $element;
     }
@@ -182,8 +242,9 @@ class TwigExtension extends AbstractExtension {
       $child_element =& $child_element[$child_path];
     }
 
-    // If the targeted child element is an array, add the value to it.
-    if (isset($child_element[$last_path]) && is_array($child_element[$last_path])) {
+    // If this is the add() filter and if the targeted child element is an
+    // array, add the value to it.
+    if ($is_add_filter && isset($child_element[$last_path]) && is_array($child_element[$last_path])) {
       if (is_array($value)) {
         $child_element[$last_path] = array_merge($child_element[$last_path], $value);
       }
diff --git a/tests/modules/components_twig_extension_test/components_twig_extension_test.module b/tests/modules/components_twig_extension_test/components_twig_extension_test.module
index 1915d47413d758c2b6749f496c4bb472088a5894..b934fad5d5d814b505c15e8d991fe964d1d9e956 100644
--- a/tests/modules/components_twig_extension_test/components_twig_extension_test.module
+++ b/tests/modules/components_twig_extension_test/components_twig_extension_test.module
@@ -12,6 +12,9 @@ function components_twig_extension_test_theme($existing, $type, $theme, $path):
   $items['components_twig_extension_test_add_filter'] = [
     'render element' => 'element',
   ];
+  $items['components_twig_extension_test_recursive_merge_filter'] = [
+    'render element' => 'element',
+  ];
   $items['components_twig_extension_test_set_filter'] = [
     'render element' => 'element',
   ];
diff --git a/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-recursive-merge-filter.html.twig b/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-recursive-merge-filter.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..073b154068d86785317b184e4403cafd74304012
--- /dev/null
+++ b/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-recursive-merge-filter.html.twig
@@ -0,0 +1 @@
+{{ element|recursive_merge( {'list': {'#items': [{'#attributes': {'class': 'new-class'}}]}} ) }}
diff --git a/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-set-filter.html.twig b/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-set-filter.html.twig
index cb2396cbc130b47e944c76f565c3a24ffbf7be39..0de424d30aa6e2783917f199ecaaa12d023765f5 100644
--- a/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-set-filter.html.twig
+++ b/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-set-filter.html.twig
@@ -1 +1 @@
-{{ element|set( {'list': {'#items': [{'#attributes': {'class': 'new-class'}}]}} ) }}
+{{ element|set( 'list.#items.0.#attributes', { 'class': 'new-class' } ) }}
diff --git a/tests/src/Kernel/TwigExtensionTest.php b/tests/src/Kernel/TwigExtensionTest.php
index f2b1f89702e690c86660990b8e95f1f6a95ffb62..249fb5d3ee1618c82cc9aeb8eb5f58fa5594056f 100644
--- a/tests/src/Kernel/TwigExtensionTest.php
+++ b/tests/src/Kernel/TwigExtensionTest.php
@@ -50,6 +50,36 @@ class TwigExtensionTest extends ComponentsKernelTestBase {
     }
   }
 
+  /**
+   * Ensures the Twig "recursive_merge" filter works inside a Drupal instance.
+   *
+   * @covers ::recursiveMergeFilter
+   */
+  public function testRecursiveMergeFilter() {
+    try {
+      $element = [
+        '#theme' => 'components_twig_extension_test_recursive_merge_filter',
+        'list' => [
+          '#theme' => 'item_list',
+          '#items' => [
+            [
+              '#type' => 'container',
+              '#attributes' => [
+                'id' => 'the_element_id',
+                'class' => ['original-container-class'],
+              ],
+            ],
+          ],
+        ],
+      ];
+      $result = $this->render($element);
+    }
+    catch (\Exception $e) {
+      $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown during: ' . $this->getName());
+    }
+    $this->assertStringContainsString('<div id="the_element_id" class="new-class"></div>', $result);
+  }
+
   /**
    * Ensures the Twig "set" filter works inside a Drupal instance.
    *
@@ -66,6 +96,7 @@ class TwigExtensionTest extends ComponentsKernelTestBase {
             [
               '#type' => 'container',
               '#attributes' => [
+                'id' => 'the_element_id',
                 'class' => ['original-container-class'],
               ],
             ],
diff --git a/tests/src/Unit/TwigExtensionFiltersTest.php b/tests/src/Unit/TwigExtensionFiltersTest.php
index b82bba60247736ce5c6f4e0969168d784d5cd462..bf21eefb29d87710f29a79ec9db504651114f8bb 100644
--- a/tests/src/Unit/TwigExtensionFiltersTest.php
+++ b/tests/src/Unit/TwigExtensionFiltersTest.php
@@ -47,6 +47,94 @@ class TwigExtensionFiltersTest extends UnitTestCase {
     $this->coreExtension = new CoreExtension();
   }
 
+  /**
+   * Tests exceptions during recursive_merge filter.
+   *
+   * @covers ::recursiveMergeFilter
+   */
+  public function testRecursiveMergeFilterException() {
+    try {
+      TwigExtension::recursiveMergeFilter('not-an-array', ['key' => 'value']);
+      $exception = FALSE;
+    }
+    catch (\Exception $e) {
+      $this->assertStringContainsString('The recursive_merge filter only works on arrays or "Traversable" objects, got "string".', $e->getMessage());
+      $exception = TRUE;
+    }
+    if (!$exception) {
+      $this->fail('Expected Exception, none was thrown.');
+    }
+  }
+
+  /**
+   * Tests the recursive_merge filter.
+   *
+   * @param array $element
+   *   The element to alter.
+   * @param array $value
+   *   The value to set.
+   * @param array $expected
+   *   The expected result.
+   *
+   * @covers ::recursiveMergeFilter
+   *
+   * @dataProvider providerTestRecursiveMergeFilter
+   */
+  public function testRecursiveMergeFilter(array $element, array $value, array $expected) {
+    $result = NULL;
+    try {
+      $result = TwigExtension::recursiveMergeFilter($element, $value);
+    }
+    catch (\Exception $e) {
+      $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown during: ' . $this->getName());
+    }
+    $this->assertEquals($expected, $result, $this->getName());
+    $this->assertEquals(array_replace_recursive($element, $value), $result, $this->getName());
+  }
+
+  /**
+   * Data provider for testRecursiveMergeFilter().
+   *
+   * @see testRecursiveMergeFilter()
+   */
+  public function providerTestRecursiveMergeFilter(): array {
+    return [
+      'Recursively sets values' => [
+        'element' => [
+          'existing' => 'value',
+          'element' => [
+            '#type' => 'element',
+            '#attributes' => [
+              'class' => ['old-value-1', 'old-value-2'],
+              'id' => 'element',
+            ],
+          ],
+        ],
+        'value' => [
+          'extra' => 'extra-value',
+          'element' => [
+            '#attributes' => [
+              'class' => ['new-value'],
+              'placeholder' => 'Label',
+            ],
+          ],
+        ],
+        'expected' => [
+          'existing' => 'value',
+          'extra' => 'extra-value',
+          'element' => [
+            '#type' => 'element',
+            '#attributes' => [
+              'class' => ['new-value', 'old-value-2'],
+              'id' => 'element',
+              'placeholder' => 'Label',
+            ],
+          ],
+        ],
+      ],
+    ];
+  }
+
   /**
    * Tests exceptions during set filter.
    *
@@ -54,11 +142,11 @@ class TwigExtensionFiltersTest extends UnitTestCase {
    */
   public function testSetFilterException() {
     try {
-      TwigExtension::setFilter('not-an-array', ['key' => 'value']);
+      TwigExtension::setFilter('not-an-array', 'key', 'value');
       $exception = FALSE;
     }
     catch (\Exception $e) {
-      $needle = 'The set filter only works with arrays or "Traversable", got "string" as first argument.';
+      $needle = 'The set filter only works on arrays or "Traversable" objects, got "string".';
       if (method_exists($this, 'assertStringContainsString')) {
         $this->assertStringContainsString($needle, $e->getMessage());
       }
@@ -77,7 +165,10 @@ class TwigExtensionFiltersTest extends UnitTestCase {
    *
    * @param array $element
    *   The element to alter.
-   * @param array $value
+   * @param string|array $path
+   *   The dotted-path to the deeply nested element to set. (Or an array value
+   *   to merge, if using the backwards-compatible 8.x-2.x syntax.)
+   * @param mixed $value
    *   The value to set.
    * @param array $expected
    *   The expected result.
@@ -86,16 +177,15 @@ class TwigExtensionFiltersTest extends UnitTestCase {
    *
    * @dataProvider providerTestSetFilter
    */
-  public function testSetFilter(array $element, array $value, array $expected) {
+  public function testSetFilter(array $element, $path, $value, array $expected) {
     $result = NULL;
     try {
-      $result = TwigExtension::setFilter($element, $value);
+      $result = TwigExtension::setFilter($element, $path, $value);
     }
     catch (\Exception $e) {
       $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown during: ' . $this->getName());
     }
     $this->assertEquals($expected, $result, $this->getName());
-    $this->assertEquals(array_replace_recursive($element, $value), $result, $this->getName());
   }
 
   /**
@@ -105,32 +195,78 @@ class TwigExtensionFiltersTest extends UnitTestCase {
    */
   public function providerTestSetFilter(): array {
     return [
-      'Recursively sets values' => [
+      'Sets a new value' => [
+        'element' => [
+          'element' => [
+            '#type' => 'element',
+            '#attributes' => [
+              'class' => ['old-value-1', 'old-value-2'],
+              'id' => 'element',
+            ],
+          ],
+        ],
+        'path' => 'element.#attributes.placeholder',
+        'value' => 'Label',
+        'expected' => [
+          'element' => [
+            '#type' => 'element',
+            '#attributes' => [
+              'class' => ['old-value-1', 'old-value-2'],
+              'id' => 'element',
+              'placeholder' => 'Label',
+            ],
+          ],
+        ],
+      ],
+      'Replaces a targeted array' => [
+        'element' => [
+          'element' => [
+            '#type' => 'element',
+            '#attributes' => [
+              'class' => ['old-value-1', 'old-value-2'],
+              'id' => 'element',
+            ],
+          ],
+        ],
+        'path' => 'element.#attributes.class',
+        'value' => ['new-value'],
+        'expected' => [
+          'element' => [
+            '#type' => 'element',
+            '#attributes' => [
+              'class' => ['new-value'],
+              'id' => 'element',
+            ],
+          ],
+        ],
+      ],
+      'Uses 8.x-2.x syntax for backwards-compatibility' => [
         'element' => [
           'existing' => 'value',
           'element' => [
-            'type' => 'element',
-            'attributes' => [
+            '#type' => 'element',
+            '#attributes' => [
               'class' => ['old-value-1', 'old-value-2'],
               'id' => 'element',
             ],
           ],
         ],
-        'value' => [
+        'path' => [
           'extra' => 'extra-value',
           'element' => [
-            'attributes' => [
+            '#attributes' => [
               'class' => ['new-value'],
               'placeholder' => 'Label',
             ],
           ],
         ],
+        'value' => NULL,
         'expected' => [
           'existing' => 'value',
           'extra' => 'extra-value',
           'element' => [
-            'type' => 'element',
-            'attributes' => [
+            '#type' => 'element',
+            '#attributes' => [
               'class' => ['new-value', 'old-value-2'],
               'id' => 'element',
               'placeholder' => 'Label',
@@ -152,7 +288,7 @@ class TwigExtensionFiltersTest extends UnitTestCase {
       $exception = FALSE;
     }
     catch (\Exception $e) {
-      $needle = 'The add filter only works with arrays or "Traversable", got "string" as first argument.';
+      $needle = 'The add filter only works on arrays or "Traversable" objects, got "string".';
       if (method_exists($this, 'assertStringContainsString')) {
         $this->assertStringContainsString($needle, $e->getMessage());
       }
@@ -170,9 +306,9 @@ class TwigExtensionFiltersTest extends UnitTestCase {
    * Tests the add filter.
    *
    * @param string $path
-   *   The dotted-path to the deeply nested element to replace.
+   *   The dotted-path to the deeply nested element to add.
    * @param mixed $value
-   *   The value to set.
+   *   The value(s) to add.
    * @param array $expected
    *   The expected render array.
    *
@@ -184,8 +320,8 @@ class TwigExtensionFiltersTest extends UnitTestCase {
     $element = [
       'existing' => 'value',
       'element' => [
-        'type' => 'element',
-        'attributes' => [
+        '#type' => 'element',
+        '#attributes' => [
           'class' => ['old-value-1', 'old-value-2'],
           'id' => 'element',
         ],
@@ -210,13 +346,13 @@ class TwigExtensionFiltersTest extends UnitTestCase {
   public function providerTestAddFilter(): array {
     return [
       'replacing a value' => [
-        'path' => 'element.attributes.id',
+        'path' => 'element.#attributes.id',
         'value' => 'new-value',
         'expected' => [
           'existing' => 'value',
           'element' => [
-            'type' => 'element',
-            'attributes' => [
+            '#type' => 'element',
+            '#attributes' => [
               'class' => ['old-value-1', 'old-value-2'],
               'id' => 'new-value',
             ],
@@ -224,13 +360,13 @@ class TwigExtensionFiltersTest extends UnitTestCase {
         ],
       ],
       'setting a new property on an existing array' => [
-        'path' => 'element.attributes.placeholder',
+        'path' => 'element.#attributes.placeholder',
         'value' => 'new-value',
         'expected' => [
           'existing' => 'value',
           'element' => [
-            'type' => 'element',
-            'attributes' => [
+            '#type' => 'element',
+            '#attributes' => [
               'class' => ['old-value-1', 'old-value-2'],
               'id' => 'element',
               'placeholder' => 'new-value',
@@ -239,13 +375,13 @@ class TwigExtensionFiltersTest extends UnitTestCase {
         ],
       ],
       'targeting an existing array with a string' => [
-        'path' => 'element.attributes.class',
+        'path' => 'element.#attributes.class',
         'value' => 'new-value',
         'expected' => [
           'existing' => 'value',
           'element' => [
-            'type' => 'element',
-            'attributes' => [
+            '#type' => 'element',
+            '#attributes' => [
               'class' => ['old-value-1', 'old-value-2', 'new-value'],
               'id' => 'element',
             ],
@@ -253,13 +389,13 @@ class TwigExtensionFiltersTest extends UnitTestCase {
         ],
       ],
       'targeting an existing array with an array' => [
-        'path' => 'element.attributes.class',
+        'path' => 'element.#attributes.class',
         'value' => ['new-value-1', 'new-value-2'],
         'expected' => [
           'existing' => 'value',
           'element' => [
-            'type' => 'element',
-            'attributes' => [
+            '#type' => 'element',
+            '#attributes' => [
               'class' => [
                 'old-value-1',
                 'old-value-2',
@@ -272,18 +408,18 @@ class TwigExtensionFiltersTest extends UnitTestCase {
         ],
       ],
       'targeting a non-existent parent property' => [
-        'path' => 'new-element.attributes.class',
+        'path' => 'new-element.#attributes.class',
         'value' => ['new-value'],
         'expected' => [
           'existing' => 'value',
           'element' => [
-            'type' => 'element',
-            'attributes' => [
+            '#type' => 'element',
+            '#attributes' => [
               'class' => ['old-value-1', 'old-value-2'],
               'id' => 'element',
             ],
           ],
-          'new-element' => ['attributes' => ['class' => ['new-value']]],
+          'new-element' => ['#attributes' => ['class' => ['new-value']]],
         ],
       ],
     ];