Skip to content

Build Your Own Theme Presets ​

The Theme Presets page covers what operators see and the preset API. This page covers the other side: you run a platform, you want a set of presets (one per business type: bakery, fashion, services, and so on), and you want to build them fast.

By the end you will know:

  • how ThemePreset, SiteBlueprint, style families and section presets fit together,
  • how the four built-in presets were made, and why you should not copy that method,
  • a file layout that scales to 20 presets,
  • how to capture screenshots for every preset in one run,
  • three ways to produce presets: by hand, from an AI-generated site, or with a coding agent,
  • how to test and register the set.

How the pieces fit ​

Three layers, each with its own job:

LayerClassWhat it owns
ThemeThemeContract, for example StudioThemeWhich design knobs exist: fonts, radius, spacing, the 22 color tokens. A theme is not a look.
Theme presetThemePresetA set of values for those knobs (style()), plus optional content: pages and header/footer. The thing an operator picks.
Section presetPreset on a section's presets()A named variant of one section type (settings and blocks).
text
ThemePreset (is a SiteBlueprint)
├── style()    → values for the theme's knobs
├── regions()  → header / footer (BlueprintRegion)
└── pages()    → PresetPage or AbstractBlueprint
                 └── sections
                     ├── BlueprintSection::make(Hero::class)
                     └── BlueprintSection::fromPreset(Hero::class, 'bold')
                         └── reads Hero::presets() → Preset::make('bold', …)

Four things to know:

  • A ThemePreset is a SiteBlueprint. The same class shows up in the Themes panel and can create a new tenant's site: BakeryPreset::provision($tenant) for a class with no constructor arguments.
  • A style family is not a class. It is the convention that every built-in section keys its presets modern, editorial, bold and elegant. BlueprintSection::fromPreset(Hero::class, 'bold') plus fromPreset(Pricing::class, 'bold') gives two sections that match. StarterFamily is an enum for those four keys and nothing more. You do not have to use it.
  • fromPreset() looks the slug up on that section and falls back to the section's defaults when it does not exist. A typo gives you a default section, not an error.
  • ThemePresetRegistry (theme presets, keyed by slug) and BlueprintRegistry (page blueprints) are separate. Presets never go through BlueprintRegistry.

A host preset has three ways to deal with families:

  1. Reuse the four keys: fromPreset(Hero::class, 'editorial').
  2. Add your own key to built-in sections with addPresets() (see below).
  3. Skip section presets and write explicit ->settings([...]).

For a set of 20, option 3 with a few shared helpers is usually the least work.

How the built-in presets were made ​

By hand. There is no generator and no AI step behind Modern, Editorial, Bold and Elegant.

  • 39 built-in sections each define four hand-written variants in PHP, with copy in lang files so the presets are translated.
  • Each family has one home page composition, a header and a footer.
  • Photos are curated stock images (about 1 MB in total) and the logos are 28 SVG wordmarks outlined from open-licensed fonts.
  • The screenshots came from a throwaway render-and-capture script that is not in the package. Screenshots gives you a starting point.

That is a lot of work per look, because the built-ins style 39 section types four ways. A shop preset uses six to ten section types on a home page, so you can drive most of the variation from a table. See Workflow A.

Choose an approach ​

ApproachBest forCost per presetWatch out for
A. One parameterized class + a data tableA set of 10 to 50 presets that share structureMinutes, once the first one worksYou must design the shared page builders first.
B. Generate a site with the AI assistant, then exportOne-off looks, or a first draft to polishMinutes, plus reviewThe export has real limits.
C. Let a coding agent write themDoing A at speed, with checksMinutes, plus reviewYou still review copy, contrast and photos.
D. Import a JSON site specA site that must carry its own imagesNot a presetCreates a draft site, not a preset class.

Approaches combine. A common plan: build two presets by hand with A, then ask an agent (C) to produce the other 18 from your table.

Anatomy of a preset ​

A complete preset for a bakery shop. It lives in one file, which is fine for a single preset. Workflow A shows how to split this up for 20.

php
<?php

declare(strict_types=1);

namespace App\Themes\Presets;

use FilamentCraft\Blueprints\BlueprintRegion;
use FilamentCraft\Blueprints\BlueprintSection;
use FilamentCraft\Enums\RegionName;
use FilamentCraft\Sections\Builtin\CtaSection;
use FilamentCraft\Sections\Builtin\FeaturesSection;
use FilamentCraft\Sections\Builtin\FooterSection;
use FilamentCraft\Sections\Builtin\HeaderSection;
use FilamentCraft\Sections\Builtin\HeroSection;
use FilamentCraft\Sections\Builtin\ProductCarouselSection;
use FilamentCraft\Support\ColorSchemes;
use FilamentCraft\Theming\Presets\PresetPage;
use FilamentCraft\Theming\Presets\ThemePreset;

final class BakeryPreset extends ThemePreset
{
    public function slug(): string
    {
        return 'bakery';
    }

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

    public function description(): ?string
    {
        return 'Warm and handmade, for bakeries, patisseries and cafes.';
    }

    public function screenshots(): array
    {
        return ['images/themes/bakery/home.webp'];
    }

    public function style(): array
    {
        return [
            'color_scheme' => 'bakery',
            'schemes' => [
                'bakery' => [
                    'background' => '#fff8f0', 'on-background' => '#3b2417',
                    'surface' => '#ffffff', 'on-surface' => '#3b2417',
                    'surface-alt' => '#f6e7d4', 'on-surface-alt' => '#3b2417',
                    'primary' => '#b4531f', 'on-primary' => '#ffffff',
                    'secondary' => '#f3d9b8', 'on-secondary' => '#3b2417',
                    'accent' => '#5f7a1f', 'on-accent' => '#ffffff',
                    'neutral' => '#6b5a4d', 'on-neutral' => '#ffffff',
                    'info' => '#2563eb', 'on-info' => '#ffffff',
                    'success' => '#3f7d3a', 'on-success' => '#ffffff',
                    'warning' => '#b45309', 'on-warning' => '#ffffff',
                    'danger' => '#b91c1c', 'on-danger' => '#ffffff',
                ],
            ],
            'heading_font' => 'fraunces',
            'default_font' => 'work-sans',
            'heading_scale' => 1.3,
            'button_radius' => 14,
            'box_radius' => 16,
        ];
    }

    public function pages(): array
    {
        $inherit = ['scheme' => ColorSchemes::INHERIT];

        return [
            PresetPage::make('Home')->homepage()->withSections([
                BlueprintSection::make(HeroSection::class)->settings([
                    'eyebrow' => 'Baked at 4 a.m.',
                    'heading' => 'Bread the street wakes up for',
                    'subheading' => 'Sourdough, croissants and cakes, made fresh every morning.',
                    'cta_label' => 'Order for tomorrow',
                    'cta_url' => '/products',
                    'bg_image' => '/images/themes/bakery/hero.webp',
                    ...$inherit,
                ]),
                BlueprintSection::make(ProductCarouselSection::class)->settings([
                    'heading' => 'Fresh today',
                    'collection' => 'featured',
                    'limit' => 8,
                    ...$inherit,
                ]),
                BlueprintSection::make(FeaturesSection::class)
                    ->settings(['title' => 'Why people queue for us', ...$inherit])
                    ->blocks([
                        ['id' => 'feature-1', 'type' => 'feature', 'settings' => ['icon' => 'heroicon-o-clock', 'title' => 'Out of the oven by 7', 'description' => 'Everything is baked the same morning.']],
                        ['id' => 'feature-2', 'type' => 'feature', 'settings' => ['icon' => 'heroicon-o-cake', 'title' => 'Cakes to order', 'description' => 'Birthdays, weddings and office parties.']],
                        ['id' => 'feature-3', 'type' => 'feature', 'settings' => ['icon' => 'heroicon-o-truck', 'title' => 'Local delivery', 'description' => 'Warm bread at your door before work.']],
                    ]),
                BlueprintSection::make(CtaSection::class)->settings([
                    'heading' => 'Order by 6 p.m. for tomorrow morning',
                    'label' => 'Start an order',
                    'url' => '/products',
                    ...$inherit,
                ]),
            ]),
            PresetPage::make('Visit us', 'visit')->withSections([
                BlueprintSection::make(CtaSection::class)->settings(['heading' => 'Find the shop', ...$inherit]),
            ]),
        ];
    }

    public function regions(): array
    {
        return [
            BlueprintRegion::make(RegionName::Header)->sections([
                BlueprintSection::make(HeaderSection::class)->settings([
                    'brand_text' => 'Your bakery',
                    'cta_label' => 'Order',
                    'cta_url' => '/products',
                    'scheme' => ColorSchemes::INHERIT,
                ]),
            ]),
            BlueprintRegion::make(RegionName::Footer)->sections([
                BlueprintSection::make(FooterSection::class)->settings([
                    'brand' => 'Your bakery',
                    'description' => 'Handmade bread and cakes.',
                    'scheme' => ColorSchemes::INHERIT,
                ]),
            ]),
        ];
    }
}

What each part does:

MethodNotes
slug()Kebab-case and stable. The registry keys on it, and changing it later orphans anything that stored the old one.
style()Keyed like Site.settings_json. Only ids the site's theme declares are applied, so a preset can't set seo or ai.
screenshots()Paths under public/, root-relative paths or URLs. First is the cover.
pages()PresetPage for inline pages, or any AbstractBlueprint class or instance. Exactly one page should be the homepage.
regions()Header, footer and announcement. A Full apply replaces only the regions you declare.
themeSlug()Optional. Switches the site to another registered theme. null keeps the site's theme.

Find real setting ids and block types with the command the agent skills use:

bash
php artisan filamentcraft:sections                  # every section
php artisan filamentcraft:sections hero             # one section: settings, blocks, presets
php artisan filamentcraft:sections hero --json      # adds defaults and full preset payloads

Families and section presets ​

Use a built-in family when you want a section already designed:

php
BlueprintSection::fromPreset(PricingSection::class, 'elegant')

Override a few things on top of it. settings() and blocks() replace the preset's values:

php
BlueprintSection::fromPreset(HeroSection::class, 'bold')->settings(['heading' => 'Fresh, fast, local'])

To change a few settings and keep the rest of the preset, use set(). Each call merges and returns a new instance:

php
BlueprintSection::fromPreset(HeroSection::class, 'bold')
    ->set('heading', 'Fresh, fast, local')
    ->set('cta_label', 'Order now')
    ->set('overlay_opacity', 40)

The setting id also works as a method name, with camelCase mapped to the snake_case id: ->heading('…')->ctaLabel('…'). Ids with digits or acronyms do not round-trip (->image2() stores image2, not image_2), so use set('image_2', …) for those. PHPStan and IDEs can't see those magic methods, so prefer set() in code you run static analysis on. Names that look like accessors (getFoo(), withFoo()) throw BadMethodCallException to catch typos.

An id the section does not declare is stored as given. filamentcraft:sections hero --json lists the real ids.

settings() overrides the whole settings array, so repeat any preset value you still want. When in doubt, print the preset with filamentcraft:sections hero --json and copy from it.

To add your own variant to a built-in section, such as a bakery hero, without subclassing it, register it on the plugin. Then fromPreset(HeroSection::class, 'bakery') works and the preset appears in the editor's preset picker:

php
use FilamentCraft\Sections\Preset;

FilamentCraftPlugin::make()
    ->addPresets(HeroSection::class, [
        Preset::make('bakery', 'Bakery')->settings(['layout' => 'spotlight', 'content_align' => 'center']),
    ]);

See Presets without subclassing for hidePresets() too. Do this when the same variant appears in several of your presets, or when you want your operators to pick it from the section panel. For one-off tweaks, plain settings() is simpler.

Pages and regions ​

  • Pages. A shop preset usually needs Home, plus Catalogue, Product, Cart and Checkout pages. Write those once as shared page blueprint classes and reuse them in every preset. See Seeding & Blueprints.
  • Regions. Declare regions() with a header and footer. A Full apply replaces only the regions the preset declares, so a preset with an empty regions() keeps the previous header and footer, and the old brand's navigation survives the switch.
  • Locales. PresetPage copy is one language. For presets that must seed in several languages, implement LocalizedBlueprint on the page and BindsToSite where the copy depends on the tenant. See Copy in every language and Blueprints that need the site.
  • Apply modes. Full replaces the pages and regions the preset declares and sets the style. Style only resets style settings to their defaults and lays the preset's on top, and leaves content alone. Both keep seo, ai and the site's own color schemes. Both write a restore point first.

Assets and photo credits ​

Section settings that hold an image take a URL. In a preset, point them at files you ship in public/:

text
public/images/themes/
├── bakery/
│   ├── home.webp        ← screenshot (the card cover)
│   ├── hero.webp
│   ├── action.webp
│   └── CREDITS.md
└── fashion/
    └── …

Rules that keep a large set under control:

  • Use fixed role names: hero, action, team. Page builders ask for $type->photo('hero'), and fall back to a generic photo when a type has none.
  • Ship local files, never hotlinks. A remote URL breaks cards and previews offline, and rots.
  • Convert to WebP at a sensible size (1600 px wide for a hero is plenty).
  • Keep a CREDITS.md per preset: source URL, photographer and licence for every image. You will need it the first time someone asks where a photo came from.
  • Check the licence covers a product you resell. Many free licences allow it, but not all of them, and some forbid use in templates.
  • Photos of people: pick images you are certain you may use, and avoid identifiable people if you can. Photos of food, shop fronts, tools and products are the safest.
  • Bundled package photos are for the built-in presets only. Your presets bring their own files.

Screenshots ​

An operator's theme card shows screenshots()[0]. Without one it shows a generated specimen (a heading, body text, a button and swatches drawn from style()), which is fine while you work.

Size and format. The card crops to 16:10 from the top of the image and pans down the page on hover. So capture the full page, scale it to about 800 px wide, save as WebP at quality 80. The built-ins are 800 px wide and about 4,700 px tall, 85 to 150 KB each. More images in screenshots() show as thumbnails under the cover.

Rendering a preset without saving anything. ThemePresetPreview is the class the editor uses for its live preview. It renders a site as if the preset were applied, in memory, and returns the whole HTML document. There is no public route for it, so add a local-only one to your host app:

php
use FilamentCraft\Enums\ThemeApplyMode;
use FilamentCraft\Models\Site;
use FilamentCraft\Theming\Presets\ThemePresetPreview;
use FilamentCraft\Theming\Presets\ThemePresetRegistry;
use Illuminate\Support\Facades\Route;

if (app()->isLocal()) {
    Route::get('/_presets', fn () => array_keys(app(ThemePresetRegistry::class)->all()));

    Route::get('/_presets/{slug}', function (string $slug) {
        $preset = app(ThemePresetRegistry::class)->find($slug) ?? abort(404);
        $site = Site::query()->orderBy('id')->firstOrFail();

        return response(app(ThemePresetPreview::class)->render($site, $preset, ThemeApplyMode::Full, null, null));
    });
}

Notes on the route:

  • The site you pass only supplies the owner, theme and locale. Full mode ignores its content and renders the preset's own pages and regions. Use a site that belongs to a tenant if your presets read the tenant (through BindsToSite, or tenant()).
  • Full mode always renders the preset's homepage. Style mode restyles the page you pass as the fourth argument.
  • render() clears the design-token cache before and after, so rendering a preset in a loop gives each its own colors. If you write your own loop around the compiler, clear it between presets too.
  • Do not ship this route to production. The isLocal() guard is the minimum.

Capture them all. The script below is a starting point. It expects the preview route from the previous step. Install the tools once, in any folder:

bash
npm i -D playwright sharp
npx playwright install chromium

scripts/preset-screenshots.mjs:

js
import { chromium } from 'playwright';
import sharp from 'sharp';
import { mkdir } from 'node:fs/promises';

const base = process.env.APP_URL ?? 'http://localhost:8000';
const only = process.argv.slice(2);
const slugs = only.length ? only : await (await fetch(`${base}/_presets`)).json();

const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
const appHost = new URL(base).host;

for (const slug of slugs) {
    const page = await context.newPage();

    // Only the app and the font CDN. Analytics and widgets can hang a full-page capture.
    await page.route('**/*', (route) => {
        const host = new URL(route.request().url()).host;

        return host === appHost || host === 'fonts.bunny.net' || route.request().url().startsWith('data:')
            ? route.continue()
            : route.abort();
    });

    await page.goto(`${base}/_presets/${slug}`, { waitUntil: 'networkidle' });
    await page.evaluate(() => document.fonts.ready);
    await page.evaluate(async () => {
        for (let y = 0; y < document.body.scrollHeight; y += 600) {
            window.scrollTo(0, y);
            await new Promise((r) => setTimeout(r, 80));
        }
        window.scrollTo(0, 0);
    });
    await page.waitForTimeout(300);

    const png = await page.screenshot({ fullPage: true, type: 'png' });
    const dir = `public/images/themes/${slug}`;

    await mkdir(dir, { recursive: true });
    await sharp(png).resize({ width: 800 }).webp({ quality: 80 }).toFile(`${dir}/home.webp`);
    console.log(`${slug}: ${dir}/home.webp`);
    await page.close();
}

await browser.close();

Run it against your local app:

bash
APP_URL=https://shops.test node scripts/preset-screenshots.mjs            # every preset
APP_URL=https://shops.test node scripts/preset-screenshots.mjs bakery     # one preset

Gotchas:

  • Scroll the page before the capture. The script does, because images and effects load lazily and would otherwise show empty in the screenshot.
  • Fonts. Fonts come from Bunny Fonts. If the capture runs offline, headings fall back to a system font. Allow fonts.bunny.net, as the script does, or register a self-hosted BrandFont.
  • Emoji. Old headless Chromium builds crash on pages with emoji. Update Playwright's browser, or use Heroicons (the built-ins do).
  • Host chrome. If your app injects a debug bar or toolbar, turn it off for these routes.
  • Locale and direction. render() takes an optional locale as its last argument. For an Arabic card, add a /_presets/{slug}/{locale} route that passes it.
  • Photos. Photos in the preset must be local files, or the capture shows broken images.

Then point screenshots() at the file you wrote. filamentcraft:doctor warns when a listed file is missing from public/.

Workflow A: hand-author a set of 20 ​

Copy the shape of a real host app that ships 13 presets: one enum, one preset class that takes the enum, shared page builders. Adding a preset then means one enum case and a small file, not a copy of 300 lines.

text
app/
├── Shop/
│   ├── ShopType.php                  ← the table: one case per preset
│   ├── ShopPreset.php                ← one ThemePreset class for all of them
│   └── Pages/
│       ├── HomePage.php              ← abstract: section builders (hero(), products(), faq() …)
│       ├── Homes/
│       │   ├── BakeryHome.php        ← ~30 lines: orders the builders, tweaks a few knobs
│       │   └── FashionHome.php
│       ├── CataloguePage.php         ← shared by every preset
│       ├── ProductPage.php
│       └── CartPage.php
lang/en/shop-presets.php              ← copy per type
public/images/themes/{slug}/          ← screenshot, photos, CREDITS.md
tests/Feature/ShopPresetsTest.php
scripts/preset-screenshots.mjs

One class, many presets

app/Themes/Presets is auto-discovered (add more directories with config('filamentcraft.themes.preset_paths')), and discovery builds each class with no arguments. A class that takes the enum implements ProvidesThemePresets, so discovery and ->themePresets([ShopPreset::class]) register every variant it returns, in order. A class with constructor arguments that does not implement it is skipped with a log warning instead of breaking boot. An abstract base class in that folder is skipped, so a shared parent is safe.

1. The table. An enum is the single source of truth for what differs between presets:

php
enum ShopType: string
{
    case Bakery = 'bakery';
    case Fashion = 'fashion';
    case Services = 'services';

    public function label(): string
    {
        return __("shop-presets.{$this->value}.name");
    }

    /** @return array<string, string> */
    public function tokens(): array
    {
        return match ($this) {
            self::Bakery => ['background' => '#fff8f0', /* … all 22 tokens … */],
            self::Fashion => ['background' => '#ffffff', /* … */],
            self::Services => ['background' => '#f7f9fc', /* … */],
        };
    }

    /** @return array{heading: string, body: string, scale: float, radius: int} */
    public function type(): array
    {
        return match ($this) {
            self::Bakery => ['heading' => 'fraunces', 'body' => 'work-sans', 'scale' => 1.3, 'radius' => 14],
            self::Fashion => ['heading' => 'playfair-display', 'body' => 'inter', 'scale' => 1.5, 'radius' => 0],
            self::Services => ['heading' => 'plus-jakarta-sans', 'body' => 'inter', 'scale' => 1.25, 'radius' => 8],
        };
    }

    public function photo(string $role): string
    {
        $path = "images/themes/{$this->value}/{$role}.webp";

        return file_exists(public_path($path)) ? "/{$path}" : "/images/themes/shared/{$role}.webp";
    }

    /** @return class-string<HomePage> */
    public function homePage(): string
    {
        return match ($this) {
            self::Bakery => BakeryHome::class,
            self::Fashion => FashionHome::class,
            self::Services => ServicesHome::class,
        };
    }
}

2. The preset class. It delegates everything to the enum:

php
use FilamentCraft\Theming\Presets\Contracts\ProvidesThemePresets;

final class ShopPreset extends ThemePreset implements ProvidesThemePresets
{
    public function __construct(private readonly ShopType $type) {}

    /** @return list<self> */
    public static function all(): array
    {
        return array_map(static fn (ShopType $type): self => new self($type), ShopType::cases());
    }

    public static function themePresets(): iterable
    {
        return self::all();
    }

    public function slug(): string
    {
        return $this->type->value;
    }

    public function name(): string
    {
        return $this->type->label();
    }

    public function description(): ?string
    {
        return __("shop-presets.{$this->type->value}.description");
    }

    public function screenshots(): array
    {
        return ["images/themes/{$this->type->value}/home.webp"];
    }

    public function style(): array
    {
        $type = $this->type->type();

        return [
            'color_scheme' => $this->type->value,
            'schemes' => [$this->type->value => $this->type->tokens()],
            'heading_font' => $type['heading'],
            'default_font' => $type['body'],
            'heading_scale' => $type['scale'],
            'button_radius' => $type['radius'],
        ];
    }

    public function pages(): array
    {
        return [
            new ($this->type->homePage())($this->type),
            new CataloguePage,
            new ProductPage,
            new CartPage,
        ];
    }

    public function regions(): array
    {
        return [/* header and footer, with 'scheme' => ColorSchemes::INHERIT */];
    }
}

3. Shared page builders. The abstract HomePage extends AbstractBlueprint and holds one method per section: hero(), products(), faq(), cta(). It reads copy from the lang file. Each concrete home orders them and overrides a few knobs:

php
final class BakeryHome extends HomePage
{
    public function sections(): array
    {
        return [
            $this->hero(['content_align' => 'center']),
            $this->products(['limit' => 8]),
            $this->features(),
            $this->cta(),
        ];
    }
}

4. Register them all, and hide the built-ins:

php
FilamentCraftPlugin::make()
    ->withoutBuiltinThemePresets()
    ->themePresets([ShopPreset::class]);

The class name registers every preset themePresets() returns, in that order. It works the same in config('filamentcraft.themes.presets'). Instances work too: ->themePresets(ShopPreset::all()).

5. The loop. The fastest order of work:

  1. Write the table first: slug, name, one-line description, 22 tokens, fonts, radius, section order.
  2. Build one preset end to end: tokens, home page, header, footer, photos, screenshot, test.
  3. Move what you hard-coded into the shared builders. The second preset is the real test of them.
  4. Add the other presets as enum cases. Per preset: tokens and fonts, a 30-line home, copy, three or four photos plus CREDITS.md.
  5. Run the screenshot script and the test loop for the whole set.
  6. Look at every card in the Themes panel, and every full page on a phone and a desktop.

Budget time for the shared base first. After that, most of each preset's time goes into choosing photos and writing honest copy.

Per-tenant branding. If each tenant should get the preset recolored with their own brand color, implement BindsToSite on the preset and compute the scheme in forSite(). The applier and the preview both call it. See Blueprints that need the site.

Workflow B: generate with the AI assistant, then export ​

The AI assistant writes a normal site into the database. So the fastest route to a first draft of a preset is: generate a site, tune it, export it.

  1. In the editor, open the assistant and generate a Whole site and leave Also design the site on. Give it the business type, a tone and one of the four style families.

  2. Tune the result by hand. Fix the copy, swap photos, check the colors.

  3. Export it:

    bash
    php artisan make:filamentcraft-theme-preset Bakery --from-site=bakery-draft

    --from-site takes a slug or an id. Pass it with no value to pick from a list. It writes app/Themes/Presets/BakeryPreset.php. Add --slug=, --path=, --namespace= or --force as needed.

  4. Open the file and polish it. Only settings that differ from each section's defaults are written, so it stays readable.

  5. Capture a screenshot, add it to screenshots(), then test it.

What the export drops, and how to deal with each:

LimitWhy it mattersWorkaround
Default locale only.A site written in English and Arabic exports only its default locale.Implement LocalizedBlueprint on the exported page and return the sections per locale (how). Or move the strings to lang files and call __(). Or run the AI Translate after applying.
No seo and no ai settings.The export skips both. A preset's style() also can't carry them, because only theme-declared settings apply.Full apply keeps each tenant's existing seo and ai. To seed defaults for a new tenant, set them in your own provisioning code, or in a ThemePresetApplied listener.
No-code section builder sections are skipped.They live in the database, not in classes. The command prints a warning for each one.Rebuild them as built-in sections, or write a custom section class and re-export.
No media handling.Image settings keep whatever the site held: a media-library URL from that one site, or a remote URL. Those files don't exist on other tenants' sites.Copy the images to public/images/themes/{slug}/, and replace each URL in the file. Search the file for http and /storage/. If the site must carry its images, use filamentcraft:export or a JSON spec instead.
Copy is literal strings.There are no lang keys, so nothing is translated.Move the text into a lang file by hand, or let an agent do it (Workflow C).
Scheduled sections (schedule()) are not exported.Show-from and show-until dates are lost.Add ->schedule() after export if you need it.

Treat the exported file as a draft to review. Keep its structure: every page, section and block is the tuning you did. Rewrite only what belongs to one business (names, people, prices, contact details, links) in the same slots, rather than deleting the sections that held them. Then run php artisan filamentcraft:presets bakery --strict to catch setting ids that no longer exist.

Workflow C: build with a coding agent ​

There is no FilamentCraft MCP server today. Coding agents such as Claude Code and Cursor work with FilamentCraft through three things that exist today:

  • the agent skills, which teach the agent how FilamentCraft works,
  • artisan commands: filamentcraft:sections --json for discovery, filamentcraft:presets --strict to check the result,
  • the code in your repo, plus tests and Playwright to check the result.

Install the skills in the host app:

bash
php artisan boost:install        # tick filamentcraft/filamentcraft
# or, without Laravel Boost:
npx skills add filamentcraft/filamentcraft

Four skills matter for presets:

SkillWhat it gives the agent
filamentcraft-theme-presetsThe whole preset workflow: one preset, an enum-driven set, exporting a site, photos and credits, the screenshot and verify scripts, tests and traps. Start here.
filamentcraft-themingSchemes, tokens and fonts: what each style() key means.
filamentcraft-sectionsHow section presets and variants work.
filamentcraft-site-migrationA JSON site spec with images, and a Playwright check. Useful when the preset needs a finished site.

Give the agent the table, not a vague brief. A good prompt:

text
Build 20 FilamentCraft theme presets, one per shop type, using the filamentcraft-theme-presets skill.

Follow its preset-set path. Use one ShopPreset class driven by a ShopType enum
(app/Shop/), not 20 classes. Register it with ->themePresets([ShopPreset::class]) and
->withoutBuiltinThemePresets().

Start with bakery and fashion only. Stop and show me the screenshots before you do the rest.

Types, brand feel and fonts are in docs/shop-types.csv.
Photos: use only the files in storage/photos/{type}/. Never download or hotlink images.
Copy: write it in English in lang/en/shop-presets.php. No invented awards, press quotes,
prices, reviews or people.

The steps the agent should follow, and the ones to check:

  1. Read the skill and app/Shop/* if it exists. Run php artisan filamentcraft:sections --json to get the real section slugs, setting ids and preset keys. It must not guess ids.
  2. Write the table (the enum), then one preset end to end.
  3. Run the checks: php artisan filamentcraft:presets --strict (0 problems, 0 warnings), then the Pest test for the set, which calls assertStrict() and assertRendersEveryLocale().
  4. Add the preview route and capture screenshots with the script above.
  5. Look at the images. A good agent opens each screenshot and critiques it: contrast, empty sections, broken photos, text overflow, sections that stayed teal.
  6. Iterate on what it sees, then run the checks again.
  7. Expand to the rest of the set only after you approve the first two.

What to review in the agent's output, because the machine can't fully judge these:

  • Contrast of every on-* color (4.5:1 for text).
  • No invented claims, awards, prices, reviews or people.
  • Every image is a local file, listed in CREDITS.md, with a licence that fits.
  • Sections inherit the scheme ('scheme' => ColorSchemes::INHERIT), so none stay teal.
  • regions() is declared, and default_font and heading_font are set.
  • The copy reads well in every language you ship. Have a native speaker check translations.

An agent can also be the bridge in Workflow B: ask it to take the exported class, split the copy into lang files and add the other locales.

Test the set ​

Run the doctor checks and a real render of every page, for every preset, in one test:

php
use App\Shop\ShopPreset;
use App\Shop\ShopType;
use FilamentCraft\Testing\ThemePresetTester;

it('ships a working preset for every shop type', function (ShopType $type): void {
    ThemePresetTester::for(new ShopPreset($type))
        ->assertStrict()
        ->assertRendersEveryLocale();
})->with(ShopType::cases());

it('has a screenshot for every shop type', function (ShopType $type): void {
    expect(public_path("images/themes/{$type->value}/home.webp"))->toBeFile();
})->with(ShopType::cases());

assertValid() checks the slug, the theme, the color scheme, the fonts, duplicate page slugs, more than one homepage, pages() and regions() that throw, and screenshot files. assertRenders() creates a site from the preset, renders every page and fails on a section that cannot render (fc-missing-section). ->site() returns the provisioned site if you want to assert more, for example that each page uses its own scheme.

assertStrict() runs every assertValid() check plus warnings for mistakes that still render, just not the way you meant:

  • a section class that is not registered;
  • a fromPreset() slug the section does not have (it silently falls back to defaults);
  • setting or block ids the section does not declare, and values outside a select's options;
  • a header or footer that pins a scheme other than inherit or the preset's color_scheme;
  • empty regions(), so Full apply keeps the site's current header and footer;
  • heading_font without default_font;
  • style() keys that are not settings of the preset's theme.

Pages and regions are checked in every locale from locales(). A warning only another locale hits is prefixed with it, for example [nl] page [home] hero: setting [kop] is not in the schema. assertRendersEveryLocale() is assertRenders() for every locale, not just the default one.

The same checks run from the shell, which is handy in CI or for an AI agent:

bash
php artisan filamentcraft:presets            # table: slug, class, source, problem and warning counts
php artisan filamentcraft:presets bakery --json
php artisan filamentcraft:presets --strict   # exit 1 on any warning, not only on problems

filamentcraft:doctor shows the same numbers under a Theme presets group. A preset that only builds its pages once bound to a site (BindsToSite) is checked unbound, so its pages are reported by problems() rather than linted.

Font checks only run when the full Bunny font list (more than 100 fonts) is loaded. When the fetch fails, the catalog falls back to four fonts and the check is skipped, so a mistyped font slug can pass in offline CI. Run app(\FilamentCraft\Support\FontCatalog::class)->flush() after a failed fetch.

Register them and hide the built-ins ​

php
FilamentCraftPlugin::make()
    ->withoutBuiltinThemePresets()
    ->themePresets([ShopPreset::class]);
  • Order in the Themes panel is registration order, so sort themePresets() the way you want it shown.

  • A host preset with a built-in's slug (modern, editorial, bold, elegant) replaces it.

  • withoutBuiltinThemePresets() hides the four built-ins. Existing sites that already applied one keep it.

  • If a preset needs a Brand Kit palette or font allow-list, the card is disabled with the reason. See Brand Kit.

  • New-site dialogs show a Start from picker with the same presets. To create a tenant's site from one in your onboarding code, pass the instance to the provisioner. ShopPreset::provision() would try to build the class with no arguments, so don't use it here:

    php
    use FilamentCraft\Blueprints\SiteProvisioner;
    
    $site = app(SiteProvisioner::class)->provision(new ShopPreset(ShopType::Bakery), $tenant, 'Joe\'s Bakery', 'joes-bakery');
  • Operators keep what they applied. Editing a preset later does not change existing sites. A new apply replaces the pages it declares, and the restore point lets them undo it.

Common traps ​

SymptomCauseFix
The page background changes but every section stays stock teal.Built-in sections pin a scheme (modern, silk, velocity, neutral). A pinned scheme beats the site's.Pass 'scheme' => ColorSchemes::INHERIT on every section you write. Or use fromPreset() with a section preset that inherits.
After a Full apply the old header and footer are still there.regions() is empty by default and Full replaces only declared regions.Declare a header and a footer in regions().
The body font changed even though the preset never mentions it.Applying resets theme settings to their defaults, and the stock font value is a placeholder.Set both default_font and heading_font in style().
A font does not load.The slug is not in the Bunny catalog.Check with FontCatalog::find(), or register a BrandFont.
A color looks wrong in one section.A scheme with only some tokens inherits the rest from modern or the parent.Author all 22 tokens.
The preset never shows in the Themes panel.The class is outside app/Themes/Presets and was never registered, or it needs constructor arguments.Register it on the plugin, or in themes.presets. Add more directories to scan with config('filamentcraft.themes.preset_paths'). Discovery is top-level files only, and builds classes with no arguments: implement ProvidesThemePresets for a class that takes one. filamentcraft:presets lists what registered.
A card shows a specimen instead of a screenshot.screenshots() is empty, or the file is missing from public/.Run the capture script and run filamentcraft:doctor.
A section looks default after fromPreset().The slug does not exist on that section, so it fell back to defaults.Check filamentcraft:sections <slug> for the preset keys.
One preset replaces a built-in by accident.Same slug as modern, editorial, bold or elegant.Rename the slug.
A card is disabled.The preset breaks the panel's Brand Kit.Read the reason on the card, then fix the font, scheme or palette.
Photos are missing on some tenants.The images were media-library files of one site, or hotlinks.Ship them from public/images/themes/ and use root-relative URLs.
PresetImage::photo() gives a broken URL.That helper points at the package's own bundled photos.Use your own paths.

Checklist ​

Before you call a preset done:

  • [ ] Slug is kebab-case, unique, and will not change.
  • [ ] name() and description() say who it is for in one line.
  • [ ] All 22 color tokens are authored, with on-* colors at 4.5:1 or better.
  • [ ] heading_font and default_font are set, and exist in the catalog.
  • [ ] Every section inherits the scheme.
  • [ ] regions() declares a header and a footer.
  • [ ] Exactly one homepage. Shared pages (catalogue, product, cart) are present for shops.
  • [ ] Every image is a local file with an entry in CREDITS.md.
  • [ ] No invented claims, people, prices or reviews in the copy.
  • [ ] Copy is translated for every language you ship, and checked by a native speaker.
  • [ ] A screenshot at about 800 px wide is listed in screenshots().
  • [ ] filamentcraft:doctor is clean, and the Pest test passes.
  • [ ] You looked at it in the Themes panel, on a phone and on a desktop.

Next steps ​