zicht/twig-classes
A Twig extension for building CSS class lists in templates. Add classes conditionally, merge class arrays, and output clean class attributes without messy inline logic, keeping Twig markup readable and maintainable in Laravel/PHP projects.
Install both halves of the stack. zicht/twig-classes is a thin Twig adapter around zicht/classes, so Composer will pull in the underlying class-list library:
composer require zicht/twig-classes
Register the Twig extension. Find the class in vendor/zicht/twig-classes/src/ that extends Twig\Extension\AbstractExtension (or the legacy Twig_Extension on older Twig). Register it with your environment:
$twig->addExtension(new \Zicht\Twig\ClassesExtension());
In Symfony, tag it as twig.extension (or let autoconfiguration handle it if the package ships service definitions). In Timber, add it through the timber/twig filter. In a Laravel/Twig bridge, register it in the same place you add other Twig extensions.
Where to look first. Read getFilters() / getFunctions() on the extension class — that is the authoritative list of what the package exposes to templates. Then read the zicht/classes README for the underlying API (add, remove, merge, __toString, and how truthy/falsy values are handled). Those two files tell you everything the package can do; the rest is convention.
First use case. Replace hand-built conditional class strings with a map that only emits truthy entries:
<div class="{{ { 'card': true, 'card--active': isActive, 'card--disabled': not enabled }|classes }}">
This is the package's whole value proposition: class logic stays declarative inside the template and the extension handles filtering, joining, and stringifying.
Map-based conditionals. The most common pattern is a Twig hash literal where keys are class names and values are booleans:
<button class="{{ { 'btn': true, 'btn--primary': variant == 'primary', 'is-loading': loading }|classes }}">
Compose from PHP when logic is non-trivial. Build a Classes instance in the controller/view model and pass it into the template. The template then just prints it, which keeps complex rules testable in PHP:
$view->buttonClasses = Classes::create()
->add('btn')
->addIf($isPrimary, 'btn--primary');
<button class="{{ buttonClasses }}">…</button>
Reusable class maps via {% set %}. Define a base map once and merge per-use to avoid repeating shared classes:
{% set base = { 'card': true, 'u-shadow': true } %}
<div class="{{ base|merge({ 'card--active': active })|classes }}">
Macros for component boundaries. Put the class map inside a macro so callers pass semantic flags rather than class names:
{% macro button(label, variant, disabled) %}
<button class="{{ { 'btn': true, 'btn--' ~ variant: variant, 'is-disabled': disabled }|classes }}"
{{ disabled ? 'disabled' : '' }}>{{ label }}</button>
{% endmacro %}
BEM modifiers. The map pattern maps cleanly onto block--modifier naming; build the modifier key with string concatenation inside the hash key and let the filter drop falsy values.
Mixing static and dynamic classes. If the filter accepts arrays/lists as well as maps, pass a list for unconditional classes and a map for conditionals, then let the extension normalize them. Check the exact signature in getFilters() before relying on mixed input.
Keep templates dumb. Any rule involving more than a couple of booleans belongs in the view model as a Classes object. The Twig filter is for presentation decisions, not business logic.
"Unknown filter/function" means the extension isn't registered in that specific Twig environment. Timber, Symfony, and standalone Twig each have a different registration point; verify the one your app actually uses.
Twig 2 vs Twig 3 namespaces. Older releases may use Twig_Extension / Twig_SimpleFilter; newer Twig requires Twig\Extension\AbstractExtension and Twig\TwigFilter. Match the package version to your Twig major version, and check composer.json constraints before upgrading Twig.
Autoescaping is usually fine, but verify. A class string contains no HTML, so the HTML escaper will not corrupt it. Do not reach for |raw reflexively — and never pass user-controlled data through |raw just to make classes render.
Empty results render an empty class="". If that matters for your markup or snapshot tests, guard the attribute:
{% set cls = { 'x': cond }|classes %}
<div{{ cls ? ' class="' ~ cls ~ '"' : '' }}>
Watch truthiness semantics. Values like 0, '0', '', and null may be treated differently by the underlying zicht/classes logic than by Twig's {% if %}. Confirm how the library filters entries before relying on edge-case values.
Duplicate classes. Check whether the underlying Classes implementation deduplicates. If it doesn't, the same class added from a base map and a modifier map can appear twice; normalize in the view model if that breaks your CSS or tests.
Cache clearing after upgrades. Compiled Twig templates cache filter/function references. After bumping the package or changing extension registration, clear the Twig/Symfony cache or you'll debug a problem that no longer exists.
Debugging. {{ dump(someClassesObject) }} (with Twig's debug extension enabled) shows the underlying object; a plain {{ someClassesObject }} exercises __toString(). If output looks wrong, check whether the problem is the map you built or the filter's normalization.
Extension points. If you need behaviour the package doesn't expose (custom normalization, HTML attribute helpers), write a small extension that wraps or composes zicht/classes rather than forking. For anything complex, drop down to the PHP Classes API — that's the stable surface; the Twig layer is deliberately thin.
Performance is a non-issue, but avoid loops. Building a class string per render is cheap. Looping over large collections inside a template to accumulate classes is not — precompute those in the controller and pass a Classes instance.
How can I help you explore Laravel packages today?