Skip to content

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’s theme.php manifest, which is what the admin and renderer use to know which menu locations, widget areas, templates and layouts exist.

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.

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

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.

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.

SiteDocumentController picks a view and a layout independently: the template is what the entry shows, and the layout is the frame around it.

First match wins:

  1. Entity file: entries/<handle>-<id>, then entries/<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.
  2. 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 and team-member.blade.php.
  3. 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.

For a type with layouts:

  1. The entry’s own key (HasLayout::layoutKey()).
  2. With no key on the site’s front page: front_page_layout.
  3. 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.

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.

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.