parisek / twig-attribute
A Twig extension for adding attributes to an element
Requires
- php: ^8.3
- twig/twig: ^3.27
Requires (Dev)
- ergebnis/composer-normalize: ^2.0
- friendsofphp/php-cs-fixer: ^3.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.0 || ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.7.0
- v1.6.1
- v1.6.0
- v1.5.0
- v1.4.1
- v1.4.0
- v1.3.0
- v1.2.0
- v1.1.0
- v1.0.0
- dev-ci/pin-actions-to-sha
- dev-docs/changelog-upgrade-notes
- dev-chore/upstream-reviewer
- dev-fix/validate-attribute-names
- dev-refactor/extension-to-src
- dev-ci/upstream-watch
- dev-test/raise-coverage
- dev-docs/readme-cleanup
- dev-ci/matrix-twig4-php85
- dev-fix/safe-class-autoescape
- dev-feat/without-filter
- dev-docs/agents-md-current
- dev-docs/timber-instantiation-example
- dev-chore/drop-composer-lock
- dev-chore/best-practices
This package is auto-updated.
Last update: 2026-10-09 16:14:34 UTC
README
A Twig 3 extension that gives templates a create_attribute() function for
collecting, sanitizing, and rendering HTML attributes. It is backed by a vendored
port of Drupal's Attribute class.
The package ships its own port (under Drupal\Component\Attribute) and does not
depend on Drupal core. The maintainer refreshes the vendored sources from
Drupal 11.x when upstream changes. The API matches what Drupal templates expect.
One deliberate difference: this package rejects unsafe attribute names.
Installation
composer require parisek/twig-attribute
Requires PHP ^8.3 and Twig ^3.27. No Drupal dependencies.
Usage
Plain PHP:
$twig = new \Twig\Environment($loader); $twig->addExtension(new \Parisek\Twig\AttributeExtension());
Symfony service:
services: Parisek\Twig\AttributeExtension: tags: [{ name: twig.extension }]
Timber 2 (WordPress). Timber creates the Twig environment itself, so do not
build your own. Add the extension with the
timber/twig filter,
for example in your theme's functions.php:
add_filter( 'timber/twig', function ( \Twig\Environment $twig ) { $twig->addExtension( new \Parisek\Twig\AttributeExtension() ); return $twig; } );
parisek/timber-kit already registers
the extension in its StarterBase. If you use the kit, you need no extra code.
In templates
{% set my_attribute = create_attribute() %}
{% set my_classes = [
'kittens',
'llamas',
isKitten ? 'cats' : 'dogs',
] %}
<div{{ my_attribute.addClass(my_classes).setAttribute('id', 'myUniqueId') }}>
{{ content }}
</div>
create_attribute() and without register the safe class by themselves. Under
Twig autoescape, call AttributeExtension::registerSafeClass($twig) once after
addExtension() as well. Without it, a collection that prints before the first
such call prints as " text. A subclass of AttributeCollection, or
another MarkupInterface class, that Twig already escaped stays escaped. See
Autoescape.
<div{{ create_attribute({'class': ['region', 'region--header']}) }}> {{ content }} </div>
Filters
without returns a copy of an attribute collection (or a plain array) without
the named keys. It never changes the original. Pass one name, several names or a list.
<div class="{{ attributes.class }} my-class"{{ attributes|without('class') }}> {{ content }} </div> {{ attributes|without('class', 'id') }} {{ attributes|without(['class', 'id']) }}
Unknown names are ignored. The behavior matches Drupal's without filter.
The filter itself is not marked safe. A collection prints as HTML only after
registerSafeClass() ran (see Autoescape).
Attribute names
A name is checked when the attribute is created: in the constructor,
setAttribute(), array access and create_attribute({...}). A name that is empty
or contains ASCII whitespace, a control character (NUL included) or any of " '
< > / = throws an \InvalidArgumentException. Without the check, a name
such as x onmouseover would render as two attributes.
Names for Alpine, Vue and Angular stay valid: @click, :class,
x-on:click.prevent, [hidden], (click), *ngIf, #ref. Drupal does not check
names, so this is a deliberate difference. Values are escaped as in Drupal.
Autoescape
With Twig autoescape on (for example 'autoescape' => 'html'), register the safe
class once, right after you add the extension:
$twig->addExtension(new \Parisek\Twig\AttributeExtension()); \Parisek\Twig\AttributeExtension::registerSafeClass($twig);
The call is safe to repeat. Call it before anything is rendered. It tells the Twig
escaper that objects implementing the package's MarkupInterface are HTML.
Strings, other Stringable values and arrays stay escaped.
Timber 2 turns autoescape off by default. A Timber theme, including one that uses
parisek/timber-kit, needs nothing.
What to know:
- The extension also calls
registerSafeClass()on the firstcreate_attribute()orwithoutcall. This is a fallback, and it depends on the order of calls. A collection from the template context that prints before the first such call is escaped (over-escaped, never printed raw). Do not rely on the fallback. - Twig caches the safe lookup per exact class on the first escape. A
MarkupInterfaceobject that Twig already escaped in that environment keeps being escaped, including a subclass ofAttributeCollectionor another implementation. OnlyAttributeCollectionand the interface are repaired. This fails closed: the output is over-escaped, never raw. - A class that implements
MarkupInterfaceis declared trusted HTML. Twig prints its whole__toString()output raw. - Custom
AttributeValueBasesubclasses are trusted developer input, as in Drupal. A value subclass that returns markup injects markup. Attribute values from the built-in classes are escaped. - Register on the environment you render with. Do not clone an environment (cloning is deprecated in Twig 3.30 and not allowed in Twig 4).
The full API (class methods, escape semantics, without filter behavior) mirrors
Drupal's Attribute class.
Development
composer install composer test # PHPUnit composer phpstan # static analysis composer cs # code style check
The vendored Drupal port comes from Drupal 11.x core at
git.drupalcode.org/project/drupal/-/tree/11.x/core/lib/Drupal/Core/Template.
A weekly workflow (upstream-watch) compares the vendored classes with upstream and
opens an issue when they differ or when a security advisory (SA-CORE) commit
appears. Run the same check by hand:
php scripts/check-upstream.php
When upstream changes, copy the relevant files into .upstream/ (a gitignored
scratch directory) and port them again. AGENTS.md describes the
refresh steps, the inline shims that let the package drop drupal/core-render
and drupal/core-utility, and the deliberate differences from Drupal.
Use cases
- Drupal - Pattern Lab
- WordPress - Timber
- Pimcore - Templates
- parisek/styleguide - uses this package.