Themes
Each site picks a theme in sites.theme. Themes live in themes/: default holds every content
view, and gopromo is a child overriding some header and footer pieces and CSS variables.
Two layers do the work:
- qirolab/laravel-themer finds views: the active theme’s folder first, then its parent’s.
app-modules/content-management/src/Theming/reads each theme’stheme.phpmanifest, which is what the admin and renderer use to know which menu locations, widget areas, templates and layouts exist.
Activation
Section titled “Activation”Http\Middleware\SetActiveSiteTheme runs on the storefront route group:
$theme = $site->theme ?? 'default';Theme::set($theme, $this->themes->get($theme)?->parent);The parent comes from the manifest. config/theme.php leaves active null, and the middleware does
not run on admin routes, so Theme::active() is null in the admin. Admin code that needs a
site’s theme reads $site->theme directly, and ThemeRegistry::getCurrent() falls back to
default there.
Blade templates for package namespaces override the same way: themer adds each theme’s
views/vendor/<namespace>/ ahead of the package’s views, child before parent. So
themes/gopromo/views/vendor/content-management/widgets/html.blade.php would replace that widget’s
view on gopromo sites only.
The manifest
Section titled “The manifest”themes/<name>/theme.php returns an array. ThemeRegistry scans the folder, and ThemeManifest is
the readonly result. A theme with no manifest is valid and declares nothing.
| Key | Holds |
|---|---|
display_name |
Shown in the site form’s Theme select |
parent |
Another theme’s name. One level only: a parent with a parent throws |
colors |
Role to hex, for surfaces that cannot read compiled CSS (the customer panel). A hand-kept duplicate of the CSS palette |
menu_locations |
Key to description and menu_type. Rendered with <x-nav-menu::navigation location="..."> |
widget_areas |
Key to name and description: site chrome slots |
registered_types.<handle>.widget_areas |
Slots on an entity’s page, with defaults and inherit flags. See widgets |
registered_types.<handle>.templates |
Named templates: name, description, optional view (defaults to the key) |
registered_types.<handle>.layouts |
Named layouts: name, description, optional component (defaults to layouts.<handle>.<key>) |
registered_types.<handle>.default_layout, front_page_layout |
Required once a type has several layouts. Naming an undeclared layout throws |
site_media.logo |
Spatie conversions for the site logo: fit, width, height, optional format, quality |
image_roles.<kind>.<role> |
Which size a role uses; see below |
Parent and child
Section titled “Parent and child”ManifestConfigMerger folds the parent’s raw array into the child’s before any value objects are
built. There is one rule everywhere: the child wins per key, other keys are inherited, and
['remove' => true] drops an inherited entry.
return [ 'display_name' => 'GoPromotional', 'parent' => 'default', 'colors' => ['primary' => '#6ea435', 'secondary' => '#327dc5'], // menu_locations omitted: inherited from default];Single entries are replaced whole, not deep merged. To change one flag on an area, restate that area.
Image roles
Section titled “Image roles”Templates ask for an image by role, not by size name, and the theme says which size each role uses.
Modules register their kinds with defaults (Theming\ImageRoles): content-management’s
overview_image, catalog’s product_photo. A theme names only the roles it changes. A size must be
one the image already has, or original; sizes are the same for every theme.
Which view renders an entry
Section titled “Which view renders an entry”SiteDocumentController picks a view and a layout independently: the template is what the entry
shows, and the layout is the frame around it.
Template
Section titled “Template”First match wins:
- Entity file:
entries/<handle>-<id>, thenentries/<handle>-<slug on this site>, across the theme and its parent (Theming\EntityTemplateFiles). Use it for a one-off such as a bespoke landing page. - Field set’s template: the entry’s custom field set names a template key
(
Theming\Models\FieldSetTemplate: its row for the site, else its all-sites row), looked up in the manifest. So picking the “Team Member” set gives a page both its fields andteam-member.blade.php. - Default: the view named for the handle,
page.blade.php.
An unknown key, or a template whose Blade file is missing, falls back to the default. A key the site’s theme does not declare simply renders the default there, so a shared entry can use a template on one theme only.
Templates are edited on the field set form, added by Filament\Support\FieldSetTemplateExtension
through custom-fields’ set form hook. See templates.
Widget areas are declared per type, not per template. A template’s Blade uses whichever of the type’s areas it wants.
Layout
Section titled “Layout”For a type with layouts:
- The entry’s own key (
HasLayout::layoutKey()). - With no key on the site’s front page:
front_page_layout. - Otherwise
default_layout.
An undeclared key or a missing components.<component> view degrades to the default. The default
theme declares sidebar, full-width and canvas for pages, and sidebar and full-width for
posts. Each is an anonymous component under components/layouts/page/ or
components/layouts/post/, and a template passes $layout to a dispatcher:
<x-base-layout> <x-page.layout :page="$page" :document="$document" :layout="$layout"> <h1 class="page-title">{{ $document->resolved_title }}</h1> </x-page.layout></x-base-layout><x-base-layout> is App\View\Components\BaseLayout, rendering the theme’s
layouts/base.blade.php. It holds the document head, including SEO::generate(), the header, menu,
footer and <x-vite />.
The layout is stored on the entry, not per site. A site whose theme lacks the chosen layout renders
its default, and the edit form warns which sites those are (Filament\Support\LayoutField).
The content-management README.md has a longer recipe for layouts and per-site templates.
Assets
Section titled “Assets”Each theme is self-contained: its own vite.config.js and CSS, built to public/<theme>/. The root
vite.config.js is for the admin only.
| Build | Command | Output |
|---|---|---|
| Admin | ddev npm run dev / build |
public/build/ |
| Default theme | ddev npm run dev:default / build:default |
public/default/ |
| GoPromo | ddev npm run dev:gopromo / build:gopromo |
public/gopromo/ |
These configs all use port 5173 with strictPort, so run one dev server at a time. If a change does
not appear, check that the right theme’s build or dev server is the one running.
A child inherits views through the file system, but CSS through an explicit import. Its app.css
imports the shared ../../default/css/base.css (Tailwind, plugins, components, neutral tokens),
not default’s app.css, so the parent’s fonts do not ride along. It then overrides variables in
@theme. The intended shape for a child is variables, not restated structure. Content blocks, the
sidebar and the layered navigation all expose --cb-*, --sidebar-* and --ln-* variables for
this.
components/vite.blade.php decides what a page loads, and gopromo’s override loads both bundles:
@vite(['themes/default/css/app.css', 'themes/default/js/app.js'], 'default')@vite(['themes/gopromo/css/app.css'], 'gopromo')The second argument is the build directory.
Adding a theme
Section titled “Adding a theme”A worked example of a child theme named acme.
1. Manifest. themes/acme/theme.php:
<?php
return [ 'display_name' => 'Acme', 'parent' => 'default', 'colors' => ['primary' => '#0f766e', 'secondary' => '#1d4ed8'],];2. CSS. themes/acme/css/app.css:
@import '../../default/css/base.css';
@source "../views/**/*.blade.php";
@theme { --color-primary-500: #0f766e; /* the rest of the palette */}3. Vite. Copy themes/gopromo/vite.config.js to themes/acme/vite.config.js, changing the
three theme-specific values:
laravel({ input: ['themes/acme/css/app.css'], buildDirectory: 'acme', refresh: ['themes/acme/views/**/*.blade.php'],}),4. Scripts. Add to package.json:
"dev:acme": "vite --config themes/acme/vite.config.js","build:acme": "vite build --config themes/acme/vite.config.js"Add public/acme to .gitignore, and add the build to your deploy; see
deploying.
5. Load the bundles. themes/acme/views/components/vite.blade.php, as gopromo’s above. Add
components/fonts.blade.php if the theme brings its own fonts.
6. Override views. Add only what differs, at the same path as in themes/default/views/. To add
a page template, declare it and add the view:
'registered_types' => [ 'page' => [ 'templates' => [ 'landing' => ['name' => 'Landing', 'description' => 'A campaign page.'], ], ],],7. Use it. Build, then choose Acme in the Theme select on the site’s edit page.