zicht/classes
zicht/classes is a Laravel/PHP package that provides a clean, reusable foundation for class-based definitions and helpers, helping developers organize PHP classes more consistently and integrate them smoothly with Laravel conventions for maintainable application code.
Install and locate the entry point.
composer require zicht/classes
Before writing any code, open the repository and check three things, in this order:
composer.json — look at the autoload section. If there is a files entry, the package registers a global helper function (e.g. classes(...)); if it only has psr-4, you will be instantiating a class directly. This single detail determines how you call the package everywhere else.src/ — for a package this small there is usually exactly one meaningful class (something like Classes, ClassNames, or ClassNameBuilder) plus a helper file. Read that class top to bottom; it will be under 100 lines and is the entire API surface. Do not rely on the README alone — for utilities at this adoption level the code is the documentation.tests/) — the test cases are effectively a specification of how nested arrays, falsy values, and associative keys are handled. This is where you answer the questions the README skips.Your first use case is almost certainly a view template where a class is conditional:
// Before
<div class="card <?= $isActive ? 'card--active' : '' ?> <?= $isLarge ? 'card--large' : '' ?>">
// After
<div class="<?= classes(['card', 'card--active' => $isActive, 'card--large' => $isLarge]) ?>">
Start by converting exactly one template — ideally a component with three or more conditional classes. That is the point at which the utility pays for itself; below that threshold plain ternaries are more readable.
Treat it as a classnames() port and mirror that mental model. The canonical input shapes are:
classes('btn'); // 'btn'
classes('btn', 'btn-primary'); // 'btn btn-primary'
classes(['btn', 'btn-lg']); // 'btn btn-lg'
classes(['btn', 'btn-active' => $active]); // associative = conditional
classes(['btn', null, false, '', 'btn-sm']); // falsy values dropped
classes(['btn', ['icon', 'icon--star']]); // nested, flattened
Verify each of these against the actual tests before you build abstractions on top — the differences between implementations are almost always in the associative and nested cases, not the string case.
Wrap it once in your application. Do not scatter the raw call across hundreds of templates. Create a thin application-level helper that fixes your conventions:
// app/Support/Html.php
public static function classes(array $classes): string
{
return \Zicht\Classes\...::make($classes); // exact entry point from src/
}
This gives you one place to swap the implementation later (see Gotchas) without touching templates, and one place to add behaviour the package does not have — deduplication, BEM prefixing, or debug assertions.
BEM / component builder pattern. The package joins; it does not compose names for you. Build the composition on top:
$block = 'card';
$classes = [
$block,
"{$block}--{$variant}" => $variant !== null,
"{$block}--active" => $isActive,
"{$block}--disabled" => $isDisabled,
$modifiers, // array of additional modifier strings, merged in
];
echo classes($classes);
Keep the name construction in PHP and let the package do only the final conditional join. Mixing the two responsibilities inside one nested array literal is where this gets unreadable.
Framework integration. If you use Blade, register a directive rather than echoing a helper:
Blade::directive('classes', fn ($expression) => "<?php echo e(classes{$expression}); ?>");
Note the e() — see the escaping gotcha below. For Twig, a small extension or filter is the equivalent move.
Test your class maps, not the library. The library is already tested; what is untested is your conditional logic. A cheap and high-value pattern is to snapshot the class string for a component's state matrix:
it('renders the active state', function () {
expect(classes(['card', 'card--active' => true]))->toBe('card card--active');
});
This locks in the output contract of a shared component, which is exactly what silently breaks during refactors.
Migration pattern. Because the output is a plain string, adoption is incremental and risk-free: replace one ternary at a time, in any order, with no change to rendered markup. There is no state, no config, and no bootstrapping, so you can migrate a large template tree over weeks without a flag.
It joins; it does not escape. The return value is raw markup-ready text. If any class name is derived from user input (a CMS-authored variant, a query parameter), you must e() / htmlspecialchars() at the output site. Because the package returns a string that looks safe, this is easy to forget — it is the most likely source of an XSS issue with this kind of utility.
Associative-array semantics are the trap. ['foo', 'bar'] means always include both. ['bar' => $cond] means include if truthy. The failure mode is ['btn', 'large'] where someone intended a conditional — 'large' is a value with numeric key 1, so it is unconditionally included and never evaluated. Always use string keys with boolean values for conditionals, and be suspicious of any array literal that mixes numeric and string keys.
Falsy handling is not the same as "empty" handling. Most implementations drop false, null, and '', but behaviour for 0, '0', and empty arrays varies. If you ever pass an integer (for example $count used as a modifier), check the tests first — a 0 silently vanishing is a subtle bug.
No deduplication. classes(['btn', 'btn']) yields btn btn. This is rarely a visual bug, but it does pollute the DOM, inflates snapshots, and can confuse CSS-in-JS tooling or class-count assertions. If you compose classes from multiple sources, dedupe in your wrapper: implode(' ', array_unique(explode(' ', $result))).
Whitespace and ordering. Output is typically space-separated with no normalisation of internal whitespace. If you build class strings by concatenation before passing them in, you can end up with double spaces or a trailing space. Pass arrays, not pre-joined strings.
Helper-function name collisions. If the package registers a global classes() (or similar) via composer.json files, it can collide with a function of the same name in your app or in another package. Composer will fatal on redeclaration. If you already have a classes() helper, use the class API directly instead, or function_exists()-guard yours.
Overlap with framework built-ins. If you are on Laravel, Arr::toCssClasses() (and its Blade equivalent @class) does essentially the same job with no extra dependency. On Symfony/Twig the same is true of a two-line custom filter. Before adopting this package, confirm it does something the framework does not — otherwise you are adding a dependency, an autoload file, and a supply-chain surface for a function you already have. Given the near-zero dependent count, being ready to drop the dependency is wise; the wrapper class from Implementation Patterns makes that a ten-minute swap.
Extension points are effectively nil. Packages this small have no service provider, no config file, and no interfaces — there is nothing to extend or override. Do not plan around customisation hooks; plan around the wrapper. The wrapper is your extension point.
Debugging. When a class is unexpectedly missing, bisect the input array rather than reading your component logic: pass the literal array to the function in php artisan tinker (or a scratch script) and inspect the output. Because the function is pure and dependency-free, it reproduces instantly outside the framework — which is also why it is pleasant to unit test.
Version pinning. With a single maintainer and no downstream users, the API can change between minor versions without much warning. Pin at the minor level in composer.json and rely on your wrapper to absorb a breaking rename.
How can I help you explore Laravel packages today?