Skip to content

[RFC] UX Html (Attributes) #3269

@smnandre

Description

@smnandre

Tip

TL;DR: I'm proposing a new component in Symfony UX to manipulate HTML attributes.

--

I've been working for some time now on a UX HTML component. The starting point is an HTML attributes helper and builder: safe by default, fast, and easy to use and extend. The goal is simple: one solid foundation we can rely on across all Symfony UX packages, and that other packages or CMSes could also use or build on.

Recently, a nice PR with a different approach that was discussed a year ago has resurfaced in the Twig repository, and I think it's worth discussing the options.

The two approaches are almost opposites. One could even argue they are not solving the same problem. But I strongly believe this problem is not how we merge complex structured arrays or attributes.... The problem is that we use complex arrays for something that should be much simpler and more direct. Something that still allows conditionals, callables, and runtime customization, but can also offer more specific implementations (data-, aria-, Stimulus, etc.).

Best way to explain, I'm pasting my README below. Hopefully that's enough to kick off the discussion. I do not have a lot of time these days, so I'd rather not sink time into implementation details if the component itself is not something we want in UX.

Open to any questions, feedback, ideas, or suggestions :)


Why Symfony UX HTML?

The Problem

Building HTML attributes programmatically in PHP is tedious and error-prone:

// Traditional approach
$classes = ['btn', 'btn-primary'];
if ($isDisabled) {
    $classes[] = 'disabled';
}
$classAttr = htmlspecialchars(implode(' ', $classes));

$attrs = [];
$attrs[] = 'class="' . $classAttr . '"';
$attrs[] = 'id="my-button"';
if ($isDisabled) {
    $attrs[] = 'disabled';
}
$attrs[] = 'aria-label="' . htmlspecialchars($label) . '"';

echo '<button ' . implode(' ', $attrs) . '>Click</button>';

The Solution

Symfony UX HTML provides a fluent, type-safe, and secure API:

// in PHP code

$attrs = Attributes::create()
    ->id('my-button')
    ->class('btn btn-primary')
    ->toggle('disabled', $isDisabled)
    ->ariaLabel($label);

return sprintf('<button %s>Click</button>', $attrs);
{# in a Twig template #}

{% set attrs = attributes()
    .class('btn btn-primary')
    .ariaLabel('Submit form')
    .disabled(not post.isEnabled)
%}

<button{{ attrs }}>Submit</button>

Key Benefits

Feature Traditional UX HTML
Safety Manual htmlspecialchars() Auto-escaped ✅
Immutability Mutable arrays Immutable objects ✅
Readability String concatenation Fluent API ✅
Type Safety Strings only Typed methods ✅
Framework Integration Manual Twig/Stimulus helpers ✅

Perfect For

✅ Symfony/Twig applications
✅ Component libraries (buttons, forms, modals)
✅ Stimulus.js integration
✅ Building accessible HTML (ARIA helpers)
✅ Teams valuing code quality and DX

When NOT to Use

❌ Simple static HTML (overkill)
❌ Performance-critical hot paths with millions of attributes (never)
❌ Non-PHP projects

HTML Attributes is a fluent, immutable API for building and rendering HTML attribute strings in PHP. It was designed with performance, security, and developer experience in mind. Out of the box, it supports:

  • Immutable Operations: Every modification returns a new instance.
  • Fluent Builder API: Chain methods like set(), add(), remove(), and toggle().
  • Magic Methods: Enable natural method calls (e.g. ->ariaLabel('Close') or ->disabled()).
  • Namespaced Helpers: Dedicated helpers for ARIA, Stimulus, and generic data attributes.
  • Secure Rendering: All output is properly escaped.
  • High Performance: Optimized for the most common attribute operations.

Installation

composer require symfony/ux-html-attributes

Basic Usage

The library provides a single entry point to build an attribute collection and render it as a string:

Core API

use Symfony\UX\Html\Attribute\Attributes;

$attributes = Attributes::create()
    ->set('id', 'my-id')
    ->add('class', 'btn')
    ->add('class', 'btn-primary')
    ->toggle('disabled', true)
    ->remove('hidden');

DX-oriented API

$attr = Attributes::create()
    ->enableMagicMethods()
    ->href('/smnandre')                  // Named methods
    ->class('btn btn-sm btn-red')        // Set multiple classes
    ->rel('external me')                 // Join multiple values
    ->disabled(true)                     // Boolean attributes
    ->ariaLabel('Hello')                 // Aria namespaced helpers
    ->title('Ah < Bh');                  // String escaping

echo $attr->render();                    // Safe HTML rendering
<output
   href="/smnandre" class="btn btn-sm btn-red"
   rel="external me" disabled aria-label="Hello" title="Ah &lt; Bh" />

Twig Extension

Use the Twig helper to fluently compose attributes inside your templates:

{# templates/components/button.html.twig #}
{% set attrs = attributes()
    .class('btn btn-primary')
    .ariaLabel('Submit form')
    .disabled(not isEnabled)
%}

<button{{ attrs }}>Submit</button>

Features

Core API

Immutable

Each method returns a new instance.

Basic Methods:

  • set(string $name, string|bool|null $value): self
    Create or replace an attribute. Use true for boolean attributes and false or null to remove them. Join arrays with spaces before passing.
  • add(string $name, string|bool|null $value): self
    Append to an existing attribute. When both values are strings they are concatenated with a space.
  • remove(string $name): self
    Remove an attribute from the collection.
  • toggle(string $name, bool $condition): self
    Add the attribute when $condition is true, otherwise remove it.
  • get(string $name): string|bool|null
    Fetch the raw value of an attribute.
  • all(): array
    Return all attributes as an associative array.
  • render(): string
    Render the attribute string with proper escaping.

Magic Methods:

Calls such as ->disabled(), ->ariaLabel('Close'), or ->foo('bar') are automatically converted to attribute names
in kebab-case and handled by the core API.

Namespaced Helpers

ARIA Attributes

Use the dedicated helper to set ARIA attributes:

$attributes->aria()->set('label', 'Close');
// Sets "aria-label" to "Close"

Data Attributes

Use the generic data helper to manage custom data-* attributes:

$attributes->data()->set('foo', 'bar');
// Sets "data-foo" to "bar"

Stimulus Attributes

Use the Stimulus helper to manage data-* attributes and controllers:

$attributes = Attributes::create()
    ->stimulus()->setController('dropdown')
    ->stimulus()->addController('modal')
    ->stimulus()->set('action', 'click->example#toggle');

echo $attributes->render();
// data-controller="dropdown modal" data-action="click->example#toggle"

Advanced Usage

Boolean and Array Values

Boolean attributes are enabled by passing true and removed when false or
null is used:

$attributes->disabled();       // Adds "disabled"
$attributes->hidden(false);    // Removes "hidden"
$attributes->hidden(true);     // Adds "hidden"

When working with arrays of values (e.g. classes) join them with spaces before
calling set() or add():

$classes = ['btn', 'btn-primary'];
$attributes->set('class', implode(' ', $classes));
echo $attributes->render();

Magic Methods

$attributes = Attributes::create()->enableMagicMethods();
$attributes->ariaLabel('Accessible Label'); // Sets aria-label="Accessible Label"
$attributes->foo('bar'); // Sets foo="bar"


> [!IMPORTANT]
> The magic methods are NOT enabled by default. Call `enableMagicMethods()` on the factory or an instance to enable them.

### Combining Attributes

```php
$attributes = Attributes::create()
    ->set('id', 'example')
    ->aria()->set('expanded', true)
    ->stimulus()->addController('dropdown');

echo $attributes->render();
// Output might be: id="example" aria-expanded="true" data-controller="dropdown"

Stimulus Helpers

$attributes->stimulus()->setController('modal');
$attributes->stimulus()->addController('dropdown');
$attributes->stimulus()->set('action', 'click->dropdown#toggle');

Future Features

Additional merging strategies and extensions are already planned for future releases (tailwind merge, CSS scoping, references...)

[...]


Metadata

Metadata

Assignees

No one assigned

    Labels

    RFCRFC = Request For Comments (proposals about features that you want to be discussed)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions