Skip to content

Example: host-authored theme

The demo app ships CascadeTheme — a warm, serif-led theme for a hospitality brand — authored entirely inside the host application. It proves the theme contract needs nothing from the package beyond extending AbstractTheme: one class, one config line, one artisan command.

  • app/Theming/CascadeTheme.php — the theme class
  • config/filamentcraft.phpthemes.register — registration

The theme class

A theme is a settings schema plus identity metadata. settingsSchema() returns Category groups; each category holds setting types that map straight onto sidebar controls in the editor's theme panel. Trimmed only where entries repeat — every line shown is verbatim:

php
<?php

declare(strict_types=1);

namespace App\Theming;

use FilamentCraft\Settings\Types\Category;
use FilamentCraft\Settings\Types\Checkbox;
use FilamentCraft\Settings\Types\ColorSchemeGroup;
use FilamentCraft\Settings\Types\Font;
use FilamentCraft\Settings\Types\Range;
use FilamentCraft\Settings\Types\Select;
use FilamentCraft\Theming\AbstractTheme;

final class CascadeTheme extends AbstractTheme
{
    public function slug(): string
    {
        return 'cascade';
    }

    public function name(): string
    {
        return 'Cascade';
    }

    public function description(): ?string
    {
        return 'Warm, serif-led theme with soft surfaces — built for hospitality and lifestyle brands.';
    }

    public function settingsSchema(): array
    {
        return [
            $this->colorsCategory(),
            $this->typographyCategory(),
            $this->buttonsCategory(),
            $this->layoutCategory(),
        ];
    }

    private function colorsCategory(): Category
    {
        return Category::make('colors')
            ->label(__('filamentcraft::filamentcraft.editor.settings.colors'))
            ->icon('heroicon-o-swatch')
            ->settings([
                ColorSchemeGroup::make('color_scheme')->default('light'),
            ]);
    }

    private function typographyCategory(): Category
    {
        return Category::make('typography')
            ->label(__('filamentcraft::filamentcraft.editor.settings.typography'))
            ->icon('heroicon-o-language')
            ->settings([
                Font::make('heading_font')
                    ->label('Display font')
                    ->cssVar('--font-heading')
                    ->default('serif'),
                Font::make('default_font')
                    ->label('Body font')
                    ->cssVar('--font-default')
                    ->default('sans'),
                Range::make('body_size')
                    ->label('Body size')
                    ->cssVar('--font-body-size', 'px')
                    ->unit('px')->min(15)->max(20)->step(1)->default(17),
                Range::make('heading_scale')
                    ->label('Heading scale')
                    ->cssVar('--font-heading-scale')
                    ->unit('')->min(1.1)->max(2)->step(0.05)->default(1.4),
            ]);
    }

    private function buttonsCategory(): Category
    {
        return Category::make('buttons')
            ->label(__('filamentcraft::filamentcraft.editor.settings.buttons'))
            ->icon('heroicon-o-cursor-arrow-rays')
            ->settings([
                Range::make('button_radius')
                    ->label('Corner radius')
                    ->cssVar('--button-radius', 'px')
                    ->unit('px')->min(0)->max(40)->step(2)->default(28),
                // … button_padding_y / button_padding_x Range entries trimmed
                Checkbox::make('button_uppercase')
                    ->label('Uppercase labels')
                    ->cssVar('--button-text-transform')
                    ->cssValues('uppercase', 'none')
                    ->default(false),
            ]);
    }

    private function layoutCategory(): Category
    {
        return Category::make('general_settings')
            ->label(__('filamentcraft::filamentcraft.editor.settings.general_settings'))
            ->icon('heroicon-o-cog-6-tooth')
            ->settings([
                // … container_max_width / section_spacing Range entries trimmed
                Select::make('header_layout')
                    ->label('Header layout')
                    ->options([
                        'centered' => 'Centered',
                        'logo-left' => 'Logo left',
                        'transparent' => 'Transparent',
                    ])
                    ->default('centered'),
            ]);
    }
}

What the setting types give you

  • ColorSchemeGroup — the whole color system in one line. It renders the scheme picker in the editor and emits the full --fc-color-* token set (surfaces, on-colors, OkLch shade ramps) for whichever scheme the site owner picks. See color schemes.
  • Font — a curated font picker whose value flows to a CSS variable via cssVar().
  • Range — numeric sliders. The second cssVar() argument is the unit appended to the emitted value (->cssVar('--font-body-size', 'px') turns 17 into 17px); heading_scale omits it because a unitless multiplier is the correct CSS value.
  • Checkbox + cssValues() — maps a boolean onto two concrete CSS values, so button_uppercase becomes --button-text-transform: uppercase|none instead of leaking true/false into a stylesheet.
  • Select without cssVar()header_layout is a plain data setting: no CSS variable, just a value your header section reads from the theme settings and branches on. Not every theme setting has to be a token.

Category labels reuse the package's own translation keys (filamentcraft::filamentcraft.editor.settings.*) so the theme panel stays consistent — and translated — alongside built-in themes.

The rest of the contract is optional

Only slug(), name(), and settingsSchema() are mandatory — AbstractTheme stubs every other ThemeContract method with a sensible default, so a theme overrides them only when it has something to say:

  • description() — the one-line blurb shown in admin UIs (Cascade overrides it above).
  • previewImage() — a preview-image URL for the theme picker.
  • stylesheets() / scripts() — extra asset URLs the theme declares for the rendered site, on top of the package-shipped filamentcraft-site bundle. Use stylesheets() when the theme ships CSS of its own instead of inlining everything through tokens.

Registration + sync

Themes are explicitly registered (unlike sections, there is no path auto-scan — a theme is a deliberate, site-wide choice). Two equivalent doors:

php
'themes' => [
    'register' => [
        \App\Theming\CascadeTheme::class,
    ],
],

Or on the plugin, next to the rest of your panel configuration:

php
FilamentCraftPlugin::make()
    ->registerTheme(\App\Theming\CascadeTheme::class)

(->registerThemes([...]) registers several at once.)

Then sync once per deploy:

bash
php artisan filamentcraft:sync-themes

The command ensures every registered theme class has a matching Theme database row, so it appears in the site's theme picker.

Expected result:

  INFO  Theme rows synced.

  ⇂ [exists]  studio → Studio
  ⇂ [created] cascade → Cascade

Re-running is always safe — it uses Theme::firstOrCreate() keyed on the slug — but for the same reason it does not update existing rows: rename name() in the class and the old name stays in the database until you update or delete the row (or change the slug, which creates a fresh row and orphans the old one).

How sections consume the result

Every cssVar() declaration is emitted into a <style id="fc-tokens"> block on :root of the rendered site (and live-patched in the editor preview as sliders move). Section views never know which theme is active — they just read the variables with fallbacks:

blade
<h2 style="font-family: var(--font-heading, var(--font-default, ui-sans-serif, system-ui, sans-serif));">
blade
<a style="border-radius: var(--button-radius, 0.5rem);
          text-transform: var(--button-text-transform, none);">

Those lines are from the pricing section example — the same section renders as round, uppercase-buttoned Cascade or as any other theme purely through the token layer.