tales-from-a-dev/twig-tailwind-extra
A Twig extension adding Tailwind CSS filters: tailwind_merge() integrates tailwind-merge-php to resolve conflicting utility classes, while tailwind_classes() builds mergeable class lists for Twig's html_attr() and html_attr_merge(). Requires PHP 8.1+, Twig 3+, and Tailwind 4+.
What it actually is. Two Twig filters, nothing more:
tailwind_merge — runs a class string through tailwind-merge-php so later utilities win over conflicting earlier ones (p-2 p-4 → p-4).tailwind_classes — builds a mergeable list object intended to be handed to Twig's html_attr() / html_attr_merge(), so a component's default classes and the caller's overrides are resolved together.Reality check for Laravel. Laravel renders Blade, not Twig. This package is worth installing only if you are running Twig inside Laravel (via a Twig bridge such as rcrowe/twigbridge) or are building a Symfony/standalone-Twig app with Laravel-adjacent tooling. If you are pure Blade, stop here — the conflict-resolution semantics are the only thing you can't trivially reproduce, and you'd be better served by a small PHP helper that wraps tailwind-merge-php directly rather than dragging in a Twig extension. Do not install it expecting Blade directives.
Minimal setup (Twig environment):
composer require tales-from-a-dev/twig-tailwind-extra
Requirements: PHP ≥ 8.1, Twig ≥ 3.0 (Twig 4 supported from v1.5.0), Tailwind ≥ 4.0. That last one is not a soft preference — see Gotchas.
Where to look first:
docs/index.md in the repo — Prerequisites → Installation → Usage → Cache. This is the real documentation; the README is a pointer.src/ for the extension class FQCN and its constructor arguments (merger config, cache pool, cacheSize). You need the exact class name to register it.tailwind-merge-php repo for the config shape (prefix, custom class groups, theme keys) — the Twig package just forwards it.Registration. With a standalone Twig Environment, $twig->addExtension(new ...Extension(...)). With a Laravel Twig bridge, add the FQCN to the bridge's extension list in config/twigbridge.php (or the bridge's equivalent) and let the container resolve it. With Symfony, the bundle auto-registers and wires the cache.twig.tailwind pool.
First use case — component defaults + caller overrides. This is the whole reason to care:
{# templates/components/button.twig #}
{% set base = 'inline-flex items-center rounded-md px-3 py-2 text-sm font-medium' %}
{% set variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
ghost: 'bg-transparent text-slate-700 hover:bg-slate-100',
} %}
<button {{ html_attr({
class: tailwind_classes([
base,
variants[variant|default('primary')],
attrs.class|default(''),
]),
}) }}>{{ slot }}</button>
The caller writes {% include 'components/button.twig' with { attrs: { class: 'py-4 px-6' } } %} and gets py-4 px-6 — the conflict is resolved by the merger, not by CSS source order. That determinism is the value proposition.
1. tailwind_merge for one-off inline strings. Cheapest entry point; use it when you already have a finished string and just need dedupe:
<div class="{{ 'p-2 md:p-4 p-6'|tailwind_merge }}">{# p-6 md:p-4 #}</div>
Because variants are distinct class groups, md:p-4 survives alongside p-6. That is correct Tailwind behaviour (media-query specificity), not a bug — don't "fix" it.
2. tailwind_merge over an array for conditional class logic. Prefer this to a pile of inline {% if %} concatenation, since the filter resolves the winner regardless of emit order:
{% set cls = tailwind_merge([
'rounded-lg border',
isError ? 'border-red-500 bg-red-50' : 'border-slate-200 bg-white',
isCompact ? 'p-2 text-sm' : 'p-4 text-base',
isDisabled ? 'opacity-50 pointer-events-none',
]) %}
3. Component contract: base → variant → size → attrs.class. Standardise the merge order across every partial in resources/views/components/. Last argument always wins, so the caller's class goes last — that's the documented contract your team should internalise. Write it down once; it removes 90% of "why is my override ignored" conversations.
4. tailwind_classes + html_attr_merge when merging into an existing attribute bag. If your component receives an attrs hash that may already carry class, style, data-*, use html_attr_merge() with tailwind_classes() for the class key so the other attributes pass through untouched:
<button {{ html_attr_merge(attrs, { class: tailwind_classes([base, variant, attrs.class|default('')]) }) }}></button>
5. Memoise aggressively and let the cache do its job. Repeated identical calls hit an in-process LRU (500 entries, cacheSize) in front of the Symfony pool; from v1.4.0 cache keys embed a fingerprint of the merge configuration. This means the sweet spot is a small, repeated vocabulary of class strings. Component props that resolve to a handful of variants[foo] values are ideal. Fully dynamic strings are not (next section).
6. Pass a custom merge config when your Tailwind setup is non-default. If tailwind.config.js sets a prefix, extends spacing/fonts, or you use plugins that emit utilities the merger doesn't know, give the extension a config so conflict groups resolve correctly. Configure once at the extension/container level — v0.5.0 reuses a single merger instance when no per-call config is passed, so avoid per-call config objects in hot templates.
7. Clear the merge cache on deploy after any version bump. tailwind-merge-php moves in minor releases of this package, and merge output can legitimately change. Treat this package like Tailwind itself: bump, cache:clear (Symfony), re-render your component library, diff.
8. Tailwind content scanning is unaffected. These filters run at render time; the Tailwind scanner still reads your source files. So your class vocabulary must appear as literal strings in templates (or be @source'd / safelisted) — the filter cannot invent CSS for you.
Tailwind v4 only — this is the big one. The package requires Tailwind ≥ 4.0 and the underlying merger assumes v4 utility semantics. If your app is still on Tailwind 3, the renamed utilities will mis-merge or fail to be recognised as conflicting: shadow-sm ↔ shadow-xs swapped meanings, ring defaulting to 1px instead of 3px, bg-opacity-*/text-opacity-* gone, outline-none → outline-hidden. You can silently get both classes emitted and spend an afternoon on specificity. Verify your Tailwind major before adopting.
tailwind_classes returns an object, not a string. It exists to be consumed by html_attr() / html_attr_merge(). Do not concatenate it, do not {{ }} it into a bare class="..." and expect merging against a neighbouring literal — the merge only happens when the attribute helper renders the list. This is the number-one confusion.
html_attr / html_attr_merge are not in twig/twig. They ship in twig/html-extra (HtmlExtension). If you get "Unknown function html_attr_merge" the fix is installing and registering that extension, not this package. Symptom looks like a broken install; it's a missing sibling.
Whitespace behaviour changed in v1.4.0. 0.4 of the merger splits on any run of whitespace, where 0.3 split on single spaces only. Multi-line class strings — which you almost certainly have from pretty-printed Twig — previously passed through as one unrecognised chunk and now merge properly. If you upgraded and a layout suddenly lost classes, this is why; the old output was the bug.
Don't build class names by interpolation. bg-{{ color }}-500 breaks Tailwind's scanner regardless of what the merger does, so you'll get a perfectly merged class string with no corresponding CSS. The merger only removes conflicts; it cannot generate utilities. Use a literal map (variants[color]) and let tailwind_merge do the conflict work.
Cache cardinality. Cache keys are content-derived. w-[{{ px }}px] or user-supplied class fragments produce unbounded entries — you'll thrash the LRU and grow the pool. Keep dynamic input to a bounded enum of variants. Tune cacheSize up only after you've bounded the vocabulary, not before.
After upgrading from < v1.4.0, clear the pool once. Old 0.3-era entries use the previous key format and will never be read again — they're just dead weight in cache.twig.tailwind. A normal cache:clear handles it in Symfony; in a Laravel bridge you own the pool lifecycle, so wire the clear into your deploy script.
Typos are invisible. bg-slate-700 mistyped as bg-slate-70O is, to the merger, an unrecognised class: passed through untouched, emitted in the HTML, styled by nothing. twigcs/twig:lint won't catch it and Tailwind will quietly drop it. If you care, add a CI step that extracts rendered class attributes and checks each token against your Tailwind build output.
Non-Tailwind classes are opaque. Custom CSS classes, prose, plugin utilities the merger doesn't know, and anything from a third-party @plugin are forwarded in order without conflict resolution. Fine for most uses; surprising if you assumed prose prose-sm would collapse.
Null is safe, empty string is not free. Since v0.3.0 a null argument won't throw. But tailwind_classes([base, null, '']) still produces an object that may render class="" — prefer omitting falsy keys ({% if x %} around the array entry, or a filtered list) over relying on the merger to swallow empties.
Debugging tip. Twig wraps filter failures in RuntimeError with unhelpful frames inside the extension. Reproduce outside the template: call the merger directly in php artisan tinker (or bin/console) with the same string and config. If it reproduces, it's a merge-config problem; if not, it's an argument-type problem (array vs string vs object) at the Twig boundary.
Extension point worth knowing. The merger instance is shared when you don't pass a per-call config (v0.5.0) — so don't mutate a shared config, and don't construct a fresh extension per request. Register once, resolve from the container, and let the LRU + pool do the caching.
How can I help you explore Laravel packages today?