TL;DR
- Blade templates that branch
@if($isTailwind) ... @elseif($isBootstrap)for CSS classes don't scale. Adding a Flux theme meant a third branch in every file. - I moved all class strings into one static map (
ThemeStyles) and gave Blades a single seam:themeClasses('key'). Themes now override keys, not re-implement templates. - Kept it closure-free so it survives
config:cache, and guarded every migration with characterization tests that assert the rendered HTML is byte-for-byte unchanged.
This is from the public cleaniquecoders/laravel-livewire-tables v4 fork.
The problem
The package ships Tailwind and Bootstrap themes. Every Blade that needed styling did this:
@class([
'px-3 py-2 ...' => $isTailwind,
'p-2 border ...' => $isBootstrap,
])
Fine for two themes. But I was adding a Flux theme, and the honest question was: do I really want a third @elseif in every one of ~40 Blade partials? That's the smell. When adding a variant means editing every template, your variation axis is in the wrong place.
Analogy: this is a light switch wired directly to the bulb. Want a dimmer? You rewire every room. Better to run everything through one panel.
The seam
One static class holds per-theme class strings, keyed by a dotted name:
class ThemeStyles
{
protected static array $classes = [
'tailwind' => [
'table.wrapper' => 'shadow overflow-y-auto border-b ... sm:rounded-lg',
// ...
],
'flux' => [ /* only the keys Flux overrides */ ],
];
public static function for(string $theme, string $key): string { /* ... */ }
}
Blades stop branching and just ask for a key:
{{-- component trait: themeClasses() delegates to ThemeStyles::for() --}}
<td @class([$this->themeClasses('td.collapsed.base')])>
The trait behind it is three lines:
public function themeClasses(string $key): string
{
return ThemeStyles::for($this->getTheme(), $key);
}
Two design choices matter here.
Flux falls back to Tailwind. Flux is Tailwind-based — it only differs on a handful of keys (dropdown panels, pills, empty state). So for() resolves the theme's key, and if it's missing, falls back to the tailwind map. A new theme defines only its diffs, not the whole surface.
No closures in the map. Tempting to store fn () => ... for dynamic classes. Don't — closures can't be serialized, so php artisan config:cache (and Octane) chokes. Plain strings keep it cache-safe. Anything genuinely dynamic stays in the Blade around the seam, not inside the map.
| Before | After | |
|---|---|---|
| Add a theme | Edit ~40 Blades | Add one map entry, override diffs only |
| Class source | Scattered inline | One file |
| Cache-safe | n/a | Yes (no closures) |
| Blade job | Branch per theme | Ask for a key |
Don't trust yourself — guard with characterization tests
Migrating 40 templates by hand is exactly where you silently drop a class and shift a border 1px. So before touching a Blade, I pinned its current output:
it('renders the collapsed cell identically after the seam migration', function () {
$html = BooleanColumn::make('Active')
->render(/* ... */)
->toHtml();
expect($html)->toContain('p-3 table-cell text-center');
});
A characterization test doesn't care whether the output is good — only that it didn't change. Migrate one Blade group, run the per-theme visual suite, confirm green, commit, next group. One slice at a time.
One real find along the way: an empty-string class key rendered subtly differently through @class() than an inlined blank — the kind of thing you only catch because the test compares exact output, not intent.
Takeaway
When adding a variant forces you to edit every file, extract the variation into a seam the files consult. For a Blade UI package that's a keyed class map plus a one-line themeClasses() accessor. Keep the map serializable so it survives config caching, and let characterization tests carry the risk of a large mechanical migration. The payoff: the Flux theme landed as a set of key overrides, not a fourth copy of every template.
Top comments (0)