twig/html-extra
Twig extension adding data_uri, html_classes, and html_cva filters/functions for generating RFC 2397 data URLs, conditionally joining class names, and managing class variants with Cva objects.
Install and register. The package is a plain Twig extension — one class, no service provider, no config file of its own.
composer require twig/html-extra:^3.29
Register it on the Twig environment your app actually renders with:
use Twig\Extra\Html\HtmlExtension;
// Standalone Twig
$twig = new \Twig\Environment($loader);
$twig->addExtension(new HtmlExtension());
// Laravel + a Twig bridge (e.g. twigbridge / twig/laravel):
// add to config/twigbridge.php → 'extensions' => [HtmlExtension::class]
Where to look first. Four doc pages, all short: data_uri (filter), html_classes (function), html_cva (function), html_attr (function). html_attr arrived in 3.24 and html_attr_value in 3.29 — neither appears in older blog posts, so check composer show twig/html-extra before copying snippets.
First use case — conditional classes. The thing you will reach for daily is html_classes, replacing the class="btn {{ $active ? 'is-active' : '' }}" mess:
<a class="{{ html_classes('nav-link', {'is-active': isCurrent, 'is-disabled': not enabled}) }}">
{{ label }}
</a>
That is the whole 80% use case. Get this working in one partial before touching anything else.
html_classes is variadic, not a filter. You can mix strings, nested arrays, and condition maps in one call; falsy conditions are dropped and null/false entries vanish. It also accepts objects implementing __toString, which is handy when a PHP view model exposes a badge() value.
{{ html_classes('card', sizeClass, ['shadow', 'rounded'], {'card--muted': isMuted}) }}
Wrap it in a macro, not in every template. The real win is one place that knows your class conventions:
{# partials/_classes.twig #}
{% macro button(variant, size, extra = {}) %}
{{ html_classes('btn', 'btn--' ~ variant, 'btn--' ~ size, extra) }}
{% endmacro %}
html_cva for variant sets. Define the variant map once (a _components.twig imported with {% import %}), then apply per call site:
{% set button = html_cva('btn', {
color: {primary: 'btn--primary', danger: 'btn--danger'},
size: {sm: 'btn--sm', lg: 'btn--lg'}
}, {}, {color: 'primary', size: 'sm'}) %}
<button class="{{ button.apply({color: 'danger'}) }}">Delete</button>
Treat the Cva object as a component definition: define once, apply() with the runtime state, and stringify at the point of output. Because it is immutable, apply() returns a new object — never expect apply() to mutate the set variable.
html_attr for attribute bags. The one that removes the most template noise. It takes a map and returns escaped, ready-to-print attributes, skipping null/false values:
<div {{ html_attr({
'id': id,
'class': html_classes('panel', {'panel--open': open}),
'data-controller': 'panel',
'aria-expanded': open ? 'true' : 'false',
'style': 'opacity: ' ~ opacity,
'hidden': not open
}) }}>
Note the space before {{ — the function does not emit leading whitespace.
html_attr_value for hand-rolled attributes (3.29+). The per-value escaping inside html_attr is now exposed on its own, for the cases the map form cannot cover: attribute names computed at runtime, attributes split across conditional branches, or a loop over a data-* array.
{% for name, value in dataAttributes %}
{{ name }}="{{ html_attr_value(value) }}"
{% endfor %}
You get the same escaping and null/false handling as html_attr without re-implementing |e guards next to every dynamic name. Prefer html_attr whenever the whole map is known up front — it also handles the skipping — and reach for html_attr_value only when the name is not.
data_uri for inline assets. Good for small icons, fallback images, and email templates where external requests are blocked:
<img src="{{ asset_path|data_uri }}" alt="">
The filter accepts either a filesystem path (it reads the file and guesses the MIME type) or a raw string, with an optional second argument to force the type: {{ svg_source|data_uri('image/svg+xml') }}. In Laravel, resolve the path in PHP (public_path(...)) and pass it in — do not rely on relative paths from templates.
Recommended split of responsibility. Do the heavy computation in PHP (a view composer or a small ClassBuilder), keep only boolean toggles and variant selection in Twig. html_cva is a good home for design-system variants; it is a bad home for business rules.
'btn'|html_classes(...) will not work. It is a function, not a filter. A lot of copied snippets online get this wrong, and the error (Unknown "html_classes" filter) sends people looking in the wrong place. Same for html_cva, html_attr, and html_attr_value.html_classes returns a plain string and is escaped normally — fine inside class="...". html_attr and html_attr_value are marked safe because they escape their inputs themselves; never wrap any of them in |raw to "fix" output, that is where XSS creeps in.html_attr needs 3.24+, html_attr_value needs 3.29+, and the zero-value html_attr fix below landed in 3.29. Pin "twig/html-extra": "^3.29" and stop chasing Unknown "html_attr" function compile errors caused by a stale lockfile.html_attr (fixed in 3.29). Earlier 3.x builds dropped style declarations whose value was zero, so 'style': 'opacity: ' ~ 0 or similar could silently disappear from the markup — a nasty, invisible-in-the-diff regression for transitions and animation states. If you render zero-valued styles or counters, upgrade and add a rendered-output test that asserts opacity: 0 survives.html_attr_value escapes a value, it does not build an attribute. It emits no name="..." and does not skip the attribute for you on every path — if the value is null/false you still decide whether the attribute should be printed at all. Use html_attr when you have the full map, and never feed an attribute name through it expecting sanitisation.HtmlExtension both in config/twigbridge.php and a boot() method throws "extension already registered". Pick one. After changing registration in Laravel, php artisan config:clear and clear the compiled Twig cache directory — Twig caches compiled templates and will happily serve the old extension set (which looks exactly like a missing html_attr_value).addExtension(new HtmlExtension()). "Works in views, fails in emails" is almost always this.data_uri has no size guard. It will base64 a 2 MB hero image into your HTML, inflating it ~33%, killing caching, and blowing past any Content-Security-Policy that does not allow data:. Enforce a size threshold in PHP before calling it; a <10KB rule of thumb is common.data_uri and untrusted input. data:text/html and similar payloads in src/href are an XSS vector in some browsers. Only feed it paths or strings you control.@class([...]) and $attributes->merge([...]) already cover html_classes and most of html_attr; data_uri is a three-line helper. Adopting this package only pays off if Twig is already your rendering engine — do not introduce a Twig bridge solely for these three functions.new \Twig\Environment(new \Twig\Loader\ArrayLoader([...])), add the extension, and assert on the rendered markup. The extension is isolated enough that unit tests need no Laravel bootstrapping — which also makes it a safe place to add regression tests around your class conventions and the zero-value rendering above.How can I help you explore Laravel packages today?