igaster/laravel-theme
Add multi-theme support to Laravel apps with igaster/laravel-theme. Organize views, assets, config, and translations per theme, switch themes at runtime, and fall back gracefully to default views. Simple integration for building customizable, themeable Laravel applications.
igaster/laravel-theme adds a theme layer on top of Laravel's view finder: each theme gets its own views, assets, and optional parent theme, and view('home') resolves to the active theme first, then up the inheritance chain, then to your global resources/views.
Install and wire up
composer require igaster/laravel-theme
php artisan vendor:publish --provider="Igaster\LaravelTheme\ThemeServiceProvider"
Package discovery handles the service provider on modern Laravel; if you're on an older skeleton, register Igaster\LaravelTheme\ThemeServiceProvider and alias the facade ('Theme' => Igaster\LaravelTheme\Facades\Theme::class) in config/app.php.
Look here first, in this order
config/theme.php — where themes are discovered from (paths), the default/fallback theme, and asset URL settings.resources/views/themes/default/ containing theme.json (name, extends, views-path, asset-path, settings) plus assets/.Theme facade — Theme::set(), get(), current(), all(), exists(), find() is the entire day-to-day API surface.Smallest working use case
// app/Providers/AppServiceProvider.php boot()
Theme::set('default');
{{-- resources/views/themes/default/home.blade.php --}}
@extends('layouts.app') {{-- resolved inside the theme first --}}
@section('content')
<link rel="stylesheet" href="{{ theme_asset('css/app.css') }}">
<h1>Hello from the theme</h1>
@endsection
Then Route::get('/', fn () => view('home'));. If you see the themed output, everything else is configuration.
Per-request theme selection via middleware. Don't hardcode Theme::set() in a service provider for anything beyond a default. Resolve the theme from the request — subdomain, session, user preference, or a tenants.theme column — in a middleware registered on the web group so it runs before any view is composed.
class ResolveTheme
{
public function handle($request, Closure $next)
{
$theme = $request->user()?->theme
?? optional(Tenant::findByHost($request->getHost()))->theme
?? config('theme.default');
Theme::set(Theme::exists($theme) ? $theme : config('theme.default'));
return $next($request);
}
}
Parent/child inheritance instead of copy-paste. Put your shared layouts, partials, and components in a base theme and have real themes declare "extends": "base" in theme.json. Each child only ships the views it actually overrides. This is the package's strongest feature — a "seasonal skin" theme can be three Blade files.
Theme-relative assets, not asset(). Use theme_asset('css/app.css') anywhere a theme file is referenced, and the package's @css / @js Blade directives inside theme layouts. asset() will happily point at a file that doesn't exist for the active theme — silent 404s that only show up in staging.
Route groups with a themed admin/marketing split. Apply the middleware to route groups (->middleware('theme:marketing')) so a single app serves a themed public site while /admin stays on your stock views.
Testing. Theme::set('mytheme') in setUp() (or a Pest beforeEach) and assert against rendered output; then a second test case flipping the theme proves no view is accidentally resolved from the wrong place. Keep a test that hits a missing-theme-view path to confirm fallback behaviour is what you expect.
Deploys. Theme views are ordinary Blade files under the view cache, so php artisan view:clear in your deploy script covers renames and theme.json edits. Clear it in dev constantly — stale compiled views are the #1 source of "my change didn't apply."
@js / @css collided with Laravel 9+ — fixed in v2.0.24. Laravel 9 introduced its own @js($data) directive for passing PHP values to JavaScript. Earlier releases of this package registered @js and @css as their own asset-directive names, which produced compile errors or silently broke Laravel's native @js. If you are on Laravel 9/10/11 and previously worked around this by renaming the directives, defining your own, or skipping the theme directives entirely, revert that workaround after upgrading and re-verify both usages still compile: the package's @js('js/app.js') and Laravel's @js(['user' => $user]).
PHP 8.2+ deprecations fixed in v2.0.24. Older 2.0.x versions triggered Creation of dynamic property ... is deprecated notices on PHP 8.2/8.3. Upgrade is a patch-level bump with no API changes, but run your view-rendering tests — a deprecation promoted to an exception by your error handler will surface as a blank page or 500.
Silent fallback to global views. Because the resolver falls back to resources/views, a view you forgot to copy into the theme renders from the non-themed directory instead of erroring. When "the theme isn't applying," don't debug the middleware first — grep for the view filename in both trees and confirm the themed copy exists and is spelled identically (Linux is case-sensitive; macOS hid this during development).
Exception, mail, and notification views bypass your middleware. Error pages render outside the normal web stack, and mailables/notifications render in queue workers where no HTTP middleware ran. Any themed layout used by these must set the theme explicitly or accept the global fallback. A Theme::set() that never ran gives you an unthemed 500 page — exactly when you least want one.
Middleware ordering matters more than you think. Anything that renders a view (exception handler, response macros, view composers registered on *) can execute before your ResolveTheme middleware if it's registered late. Put it at the top of the web group.
theme_asset() and your build tool coexist awkwardly. Vite/Mix writes to public/build; theme_asset() resolves into the active theme's asset-path. Pick one: either emit theme assets into per-theme public folders and use theme_asset(), or keep Vite output global and use asset() for the build while reserving theme_asset() for theme-specific images/fonts. Mixing them is how you get a production 404 on CSS only.
Cache and config quirks. Theme metadata from theme.json is read from disk; long-running Octane/Swoole workers will hold a stale discovery result until the worker reloads. If you're on Octane, clear caches and restart workers as part of deploy, not just view:clear.
Facade alias collisions. Aliasing Theme globally is fine until another package claims the name. Prefer the fully-qualified facade or inject the underlying theme manager where you can.
Extension points worth knowing. Theme::all() / exists() for an admin theme-picker UI, and extends for inheritance, are stable and safe to build on. Anything reaching into the resolver internals is not — keep a couple of feature tests around view resolution so a future patch release (like this one) can be adopted without archaeology.
How can I help you explore Laravel packages today?