alizharb/laravel-themer
Laravel Themer is an enterprise-grade theme management package for Laravel and TALL stack apps. It offers per-theme Vite builds, NPM workspaces, theme-aware view overrides, Livewire 4 integration, interactive CLI wizards, safe theme cloning/activation, and a fast zero-IO production cache.
Install and bootstrap. The package targets Laravel 11/12 with Livewire 4 support and is zero-config by design, so the happy path is two commands:
composer require alizharb/laravel-themer
php artisan themer:install
themer:install is interactive and does three things worth knowing before you run it: publishes config/themer.php, creates the themes directory (default themes/), and optionally rewrites your vite.config.js to use the bundled themerLoader (via vite.themer.js). If you decline the Vite step, the command prints manual instructions — copy them down, because asset isolation is the whole point of the package and you will hit a wall later without it.
Where to look first. In order:
config/themer.php — the active theme key, theme path, discovery rules. This is the file you will actually edit.themes/{slug}/theme.json — the manifest. This is the contract: name, slug, version, parent, tokens, screenshots, tags, removable, disableable, requires, conflicts, provides. Read the schema before hand-editing; validation is strict.themes/{slug}/resources/views/ — where overrides live. The directory layout mirrors a normal Laravel app so overrides are intuitive.alizharb.github.io/laravel-themer) for anything beyond the README.First useful use case. Don't start with a full redesign. Start with the narrowest thing that proves the cascade:
php artisan theme:make "Dark Theme" # generates scaffold + theme.json
php artisan theme:activate dark-theme
Then copy a single existing view — resources/views/welcome.blade.php — into themes/dark-theme/resources/views/welcome.blade.php, change one line, and reload. You should see the theme version with zero application code changes. Once that works, you understand the resolution order (Active Theme → Parent → Application) and everything else is scale.
Only after the view cascade is verified should you touch theme:dev --theme=dark-theme and Vite, because asset piping introduces a second failure surface (workspace resolution + dev server ports) that will mask view-resolution mistakes.
The override cascade is the primary mental model. Theme resolution goes Active → Parent → Parent's Parent → App. That means a theme should contain only deltas. The anti-pattern is copying the entire resources/views tree into a theme "to be safe" — you immediately fork every view, and upstream bug fixes never reach the theme. Keep themes thin: override the layout, override three partials, inherit the rest. Multi-level nesting is supported (v1.1.0+), so brand-a → base → app is a legitimate structure.
Per-route theming via middleware. Global activation is rare in practice. The SetTheme middleware (v1.1.0+) is how you actually ship this:
Route::middleware('theme:dark-theme')->group(function () {
Route::get('/dashboard', Dashboard::class);
});
Use this for admin-vs-public branding, per-tenant subdomains, or a marketing section that diverges from the app shell. Keep it narrow — middleware-based theming is easy to reason about per group, but scattered theme:name middleware across a large routes/web.php makes debugging view resolution genuinely painful.
Ephemeral switching for non-HTTP contexts. Themer::forTheme() (v1.1.0+) is the tool for emails, PDFs, and queued jobs where a route middleware never fires:
$html = Themer::forTheme('invoice-brand', function () {
return view('invoices.pdf', $data)->render();
});
Wrap it in a closure and let it unwind the switch. This is the single most-used pattern in real deployments because emails and PDFs are where brand consistency actually gets noticed — and where global activation would leak into the web request.
Design tokens instead of hardcoded colors. v1.4.0 added tokens in theme.json with theme_token(), @themeTokens, and theme:tokens. Define brand colors, radii, and spacing per theme, then emit them once in your layout:
@themeTokens
This gives you a single place to answer "what is the primary color in theme X" and, importantly, a machine-readable surface for theme:tokens --css. Prefer this over hardcoded hex values in Blade — it's the difference between a themeable app and an app with two hardcoded palettes.
Asset workflow runs per theme. Each theme gets its own package.json and vite.config.js, plus NPM workspaces for shared dependencies. The daily loop:
php artisan theme:npm --theme=dark-theme install tailwind-merge
php artisan theme:dev --theme=dark-theme # dev server
php artisan theme:build --theme=dark-theme # production build
Treat the theme's package.json as the source of truth for that theme's assets. Don't try to reach across themes for imports — the isolation is deliberate and fighting it produces builds that work locally and fail in CI.
Production discipline: cache + diagnostics. The zero-IO discovery engine caches resource existence, which is where the performance story comes from. The trade-off is freshness. The pattern that works:
php artisan theme:refresh # after adding/removing theme views, providers, or Livewire components
php artisan theme:doctor # CI gate
php artisan theme:status
And for the "why is this view rendering" question — which will come up — php artisan theme:why dark-theme and theme:debug dark-theme --json are the intended answers. Put theme:doctor in CI; it catches circular parents, missing parents, and asset health before a deploy does.
Previewing before activating. For QA of an inactive theme, theme:preview dark-theme --signed --expires=30 produces a URL that resolves a non-active theme for a time-boxed session. This is how you review work from a designer without flipping the live site — wire it into your review workflow rather than temporarily calling theme:activate on staging and forgetting to revert.
Livewire follows the same cascade. php artisan make:livewire Header --theme=dark-theme targets the theme's Livewire directory, and component resolution walks the inheritance chain. The v1.1.0 fix for grandparent component registration matters here: if you're on an older patch, deeply nested Livewire components silently resolve to the wrong class.
This is a very young package — treat adoption as a bet. Eight stars, zero dependents, and a first release a short time ago. That is a real risk factor, not a footnote. Concretely: pin the exact version in composer.json, don't use ^ on a pre-1.x-if-possible boundary, and read the changelogs for breaking behavior before upgrading. Every release so far has shipped behavioral changes (v1.1.0 alone changed theme:make output and the boot phase). Budget for reading diff notes on each bump.
Cache staleness will bite you. The zero-IO discovery engine is the main performance feature and the main source of "I added the view and nothing happened." If a new theme view, provider, or Livewire class isn't resolving, run theme:refresh or theme:cache before you start debugging your Blade syntax. Make this reflex #1 — it will save hours.
removable and disableable are not decorative. theme:delete respects the removable flag in theme.json. If a theme refuses to delete, the manifest is doing its job, not malfunctioning. Check the flag before filing a bug. Same for disableable — a disabled theme will not activate regardless of what theme:activate says.
Naming and slug drift causes duplicate-discovery gremlins. There's a documented v1.1.2 fix for themes registered under multiple keys (name, slug, path) producing duplicates in ThemeManager::all(). The practical lesson: pick one slug convention, use it consistently in theme:make, --theme=, middleware, and theme.json, and never rename a slug by hand. Renaming means updating every reference — the package identifies themes by multiple keys internally, so a partial rename can leave a theme listed twice or not at all.
View namespace casing has a history. v1.1.0 fixed inconsistent casing in view namespace resolution and v1.1.2 fixed layouts:: / pages:: prefixes not registering as Blade component prefixes (so <x-layouts::app> failed). If you're on an older version and component tags mysteriously don't resolve, that's this. On current versions, still prefer explicit lowercase slugs — don't rely on case-insensitive matching.
Modular discovery needs a second package. laravel-modular is listed as required for modular theme discovery, and theme:check verifies required modules declared under requires in theme.json. If you're not using modules, ignore this entirely. If you are, install laravel-modular first or theme:check will report failures you can't fix from the theme side.
Livewire support is Livewire 4, not 3. The README says Livewire 4 explicitly. Upgrading an existing TALL app on Livewire 3 and expecting the component redirection to work is a mismatch that won't surface until runtime. Verify your Livewire version before adopting.
Route middleware ordering matters. theme:name sets context for the request; if you stack it with middleware that render views or capture output earlier in the chain, the theme won't be applied. Put theme: early in the group. And remember it only affects the current request — it does nothing for queued jobs, which is exactly why Themer::forTheme() exists.
Vite setup is the most likely manual-intervention point. If you declined the automatic vite.config.js rewrite during themer:install, you must wire themerLoader from vite.themer.js yourself. Symptom of getting this wrong: theme:dev starts, but assets 404 or the root app build picks up theme files. Re-run the installer or copy the documented config verbatim rather than improvising.
theme:make no longer generates a layout. v1.1.0 removed automatic layout generation deliberately. Generate layouts explicitly with php artisan livewire:layout --theme=dark-theme or write them by hand. Expecting a working scaffold out of theme:make and finding an empty views directory is a common first-ten-minutes confusion.
Preview URLs are a security surface. theme:preview with --signed is the safe form. An unsigned preview URL exposes an inactive theme to anyone who guesses it; with --expires it's time-boxed. Use --signed in shared or production-adjacent environments, and don't leave long-lived preview endpoints open.
Extend via the manifest, not by patching the package. theme.json already supports provides, requires, conflicts, and tokens, and the lifecycle events for caching, refresh, and preview give you hooks. Reach for those before forking — with a package this young and this few dependents, a fork means you own the maintenance entirely. If a genuine gap exists, contributing upstream is the better bet than a vendored copy.
How can I help you explore Laravel packages today?