diff --git a/CHANGELOG b/CHANGELOG
index 0eb1aacba88..ba2bd9dea05 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,6 +1,6 @@
-# 3.24.1 (2026-XX-XX)
+# 3.25.0 (2026-XX-XX)
- * n/a
+ * Make `html_attr()` return an `HtmlAttributes` object that is both `Stringable` and `IteratorAggregate`, allowing spreading onto Twig Components via `{{ ...html_attr(merged) }}`
# 3.24.0 (2026-03-17)
diff --git a/extra/html-extra/HtmlAttributes.php b/extra/html-extra/HtmlAttributes.php
new file mode 100644
index 00000000000..34d476d346b
--- /dev/null
+++ b/extra/html-extra/HtmlAttributes.php
@@ -0,0 +1,136 @@
+Click
+ *
+ * {# On a Twig Component (spread as key-value pairs) #}
+ * Click
+ *
+ * @implements \IteratorAggregate
+ */
+final class HtmlAttributes implements \Stringable, \IteratorAggregate, \Countable
+{
+ /**
+ * @param array $attributes The raw merged attributes
+ */
+ public function __construct(
+ private readonly array $attributes,
+ private readonly EscaperRuntime $escaper,
+ ) {
+ }
+
+ public function __toString(): string
+ {
+ $result = '';
+
+ foreach ($this->resolveAttributes() as $name => $value) {
+ $result .= $this->escaper->escape($name, 'html_attr_relaxed').'="'.$this->escaper->escape($value).'" ';
+ }
+
+ return trim($result);
+ }
+
+ /**
+ * @return \Traversable
+ */
+ public function getIterator(): \Traversable
+ {
+ return new \ArrayIterator($this->resolveAttributes());
+ }
+
+ public function count(): int
+ {
+ return \count($this->resolveAttributes());
+ }
+
+ /**
+ * Resolves the raw attributes into their final scalar values.
+ *
+ * This applies the same transformation logic as the original htmlAttr():
+ * - aria-*: booleans converted to "true"/"false" strings
+ * - data-*: non-scalar values JSON-encoded, true converted to "true"
+ * - Iterables converted to SeparatedTokenList or InlineStyle
+ * - AttributeValueInterface resolved via getValue()
+ * - true becomes empty string
+ * - null/false causes the attribute to be omitted
+ *
+ * @return array The resolved attributes with scalar string values
+ */
+ private function resolveAttributes(): array
+ {
+ $resolved = [];
+
+ foreach ($this->attributes as $name => $value) {
+ if (str_starts_with($name, 'aria-')) {
+ if (true === $value) {
+ $value = 'true';
+ } elseif (false === $value) {
+ $value = 'false';
+ }
+ }
+
+ if (str_starts_with($name, 'data-')) {
+ if (!$value instanceof AttributeValueInterface && null !== $value && !\is_scalar($value)) {
+ try {
+ $value = json_encode($value, \JSON_THROW_ON_ERROR);
+ } catch (\JsonException $e) {
+ throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e);
+ }
+ } elseif (true === $value) {
+ $value = 'true';
+ }
+ }
+
+ if (!$value instanceof AttributeValueInterface && is_iterable($value)) {
+ if ('style' === $name) {
+ $value = new InlineStyle($value);
+ } else {
+ $value = new SeparatedTokenList($value);
+ }
+ }
+
+ if ($value instanceof AttributeValueInterface) {
+ $value = $value->getValue();
+ }
+
+ if (null === $value || false === $value) {
+ continue;
+ }
+
+ if (true === $value) {
+ $resolved[$name] = '';
+ continue;
+ }
+
+ if (\is_object($value) && !$value instanceof \Stringable) {
+ throw new RuntimeError(\sprintf('The "%s" attribute value should be a scalar, an iterable, or an object implementing "%s", got "%s".', $name, \Stringable::class, get_debug_type($value)));
+ }
+
+ $resolved[$name] = (string) $value;
+ }
+
+ return $resolved;
+ }
+}
diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php
index 966b43327d2..54342a6628e 100644
--- a/extra/html-extra/HtmlExtension.php
+++ b/extra/html-extra/HtmlExtension.php
@@ -192,71 +192,11 @@ public static function htmlAttrMerge(iterable|string|false|null ...$arrays): arr
}
/** @internal */
- public static function htmlAttr(Environment $env, iterable|string|false|null ...$args): string
+ public static function htmlAttr(Environment $env, iterable|string|false|null ...$args): HtmlAttributes
{
- $attr = self::htmlAttrMerge(...$args);
-
- $result = '';
- $runtime = $env->getRuntime(EscaperRuntime::class);
-
- foreach ($attr as $name => $value) {
- if (str_starts_with($name, 'aria-')) {
- // For aria-*, convert booleans to "true" and "false" strings
- if (true === $value) {
- $value = 'true';
- } elseif (false === $value) {
- $value = 'false';
- }
- }
-
- if (str_starts_with($name, 'data-')) {
- if (!$value instanceof AttributeValueInterface && null !== $value && !\is_scalar($value)) {
- // ... encode non-null non-scalars as JSON
- try {
- $value = json_encode($value, \JSON_THROW_ON_ERROR);
- } catch (\JsonException $e) {
- throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e);
- }
- } elseif (true === $value) {
- // ... and convert boolean true to a 'true' string.
- $value = 'true';
- }
- }
-
- // Convert iterable values to token lists
- if (!$value instanceof AttributeValueInterface && is_iterable($value)) {
- if ('style' === $name) {
- $value = new InlineStyle($value);
- } else {
- $value = new SeparatedTokenList($value);
- }
- }
-
- if ($value instanceof AttributeValueInterface) {
- $value = $value->getValue();
- }
-
- // In general, ...
- if (true === $value) {
- // ... use attribute="" for boolean true,
- // which is XHTML compliant and indicates the "empty value default", see
- // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 and
- // https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes
- $value = '';
- }
-
- if (null === $value || false === $value) {
- // omit null-valued and false attributes completely (note aria-* has been processed before)
- continue;
- }
-
- if (\is_object($value) && !$value instanceof \Stringable) {
- throw new RuntimeError(\sprintf('The "%s" attribute value should be a scalar, an iterable, or an object implementing "%s", got "%s".', $name, \Stringable::class, get_debug_type($value)));
- }
-
- $result .= $runtime->escape($name, 'html_attr_relaxed').'="'.$runtime->escape((string) $value).'" ';
- }
-
- return trim($result);
+ return new HtmlAttributes(
+ self::htmlAttrMerge(...$args),
+ $env->getRuntime(EscaperRuntime::class),
+ );
}
}
diff --git a/extra/html-extra/Tests/HtmlAttrTest.php b/extra/html-extra/Tests/HtmlAttrTest.php
index 41f704cbadf..bbd1d394f52 100644
--- a/extra/html-extra/Tests/HtmlAttrTest.php
+++ b/extra/html-extra/Tests/HtmlAttrTest.php
@@ -268,7 +268,7 @@ public function getIterator(): \Traversable
$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $object);
- self::assertSame('data-controller="dropdown tooltip" data-action="click->dropdown#toggle mouseover->tooltip#show"', $result);
+ self::assertSame('data-controller="dropdown tooltip" data-action="click->dropdown#toggle mouseover->tooltip#show"', (string) $result);
}
public function testDataAttributeWithNonJsonEncodableValueThrowsRuntimeError()
@@ -276,7 +276,7 @@ public function testDataAttributeWithNonJsonEncodableValueThrowsRuntimeError()
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('The "data-bad" attribute value cannot be JSON encoded.');
- HtmlExtension::htmlAttr(
+ (string) HtmlExtension::htmlAttr(
new Environment(new ArrayLoader()),
['data-bad' => [\INF]] // INF cannot be JSON-encoded
);
@@ -287,11 +287,85 @@ public function testNonStringableObjectAsAttributeValueThrowsRuntimeError()
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('The "title" attribute value should be a scalar, an iterable, or an object implementing "Stringable"');
- HtmlExtension::htmlAttr(
+ (string) HtmlExtension::htmlAttr(
new Environment(new ArrayLoader()),
['title' => new \stdClass()]
);
}
+
+ public function testHtmlAttributesIsStringable()
+ {
+ $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ['class' => 'btn', 'id' => 'my-btn']);
+
+ self::assertSame('class="btn" id="my-btn"', (string) $result);
+ }
+
+ public function testHtmlAttributesIsIterable()
+ {
+ $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ['class' => 'btn', 'id' => 'my-btn']);
+
+ self::assertSame(['class' => 'btn', 'id' => 'my-btn'], iterator_to_array($result));
+ }
+
+ public function testHtmlAttributesIsCountable()
+ {
+ $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ['class' => 'btn', 'id' => 'my-btn']);
+
+ self::assertCount(2, $result);
+ }
+
+ public function testHtmlAttributesIterationReturnsScalarValues()
+ {
+ $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), [
+ 'class' => ['btn', 'btn-primary'],
+ 'data-action' => new SeparatedTokenList(['click->dialog#open', 'mouseover->tooltip#show']),
+ 'style' => ['color' => 'red', 'font-size' => '16px'],
+ 'required' => true,
+ 'disabled' => false,
+ 'aria-hidden' => true,
+ ]);
+
+ $attrs = iterator_to_array($result);
+
+ self::assertSame('btn btn-primary', $attrs['class']);
+ self::assertSame('click->dialog#open mouseover->tooltip#show', $attrs['data-action']);
+ self::assertSame('color: red; font-size: 16px;', $attrs['style']);
+ self::assertSame('', $attrs['required']);
+ self::assertArrayNotHasKey('disabled', $attrs);
+ self::assertSame('true', $attrs['aria-hidden']);
+ }
+
+ public function testHtmlAttributesSpreadAfterMerge()
+ {
+ $alertTrigger = [
+ 'data-action' => new SeparatedTokenList('click->alert-dialog#open'),
+ 'data-alert-dialog-target' => 'trigger',
+ ];
+
+ $tooltipTrigger = [
+ 'data-action' => new SeparatedTokenList('mouseenter->tooltip#show mouseleave->tooltip#hide'),
+ 'data-tooltip-target' => 'trigger',
+ ];
+
+ $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $alertTrigger, $tooltipTrigger);
+
+ $attrs = iterator_to_array($result);
+
+ self::assertSame('click->alert-dialog#open mouseenter->tooltip#show mouseleave->tooltip#hide', $attrs['data-action']);
+ self::assertSame('trigger', $attrs['data-alert-dialog-target']);
+ self::assertSame('trigger', $attrs['data-tooltip-target']);
+ }
+
+ public function testHtmlAttributesCountWithOmittedAttributes()
+ {
+ $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), [
+ 'class' => 'btn',
+ 'disabled' => false,
+ 'title' => null,
+ ]);
+
+ self::assertCount(1, $result);
+ }
}
class StringableStub implements \Stringable