Theme Options and the CSS Pipeline

Every control on Appearance → Theme options is declared in the active theme's theme.json. This page explains the schema, where values are stored, how a page can override them, and how they end up in CSS.

theme.json "defaults"  --ThemeOptionsSchema-->  Theme options screen (one tab per "tab")
                                                   |  save
                                                   v
                         settings.theme_options[{theme}]   (ThemeOptionStore)
                                 |                                   |
                   theme_option('key') in Blade         ThemeCssCompiler (only if a CSS option changed)
                                                                     |
                     built Vite CSS (resources/themes/{theme}/assets/sass/app.scss)
                     + option values  ->  storage/app/public/css/themes/{theme}/app.css
                                                                     |
                                     FrontTheme shares $themeStylesheetUrl -> layout links it

The option schema

Each key of the defaults object is one option. Echo declares 161 of them.

resources/themes/echo/theme.json (two entries)

"primary_color": {
    "default": "#345D32",
    "type": "color",
    "tab": "light-colors",
    "label": "Primary color",
    "helpTooltip": "Sets the main brand color used by primary controls, active states, focus rings, and derived primary variants."
},
"dropdown_background_color": {
    "default": "#FFFFFF",
    "type": "color",
    "tab": "header",
    "label": "Dropdown background (custom menu colors)",
    "conditions": [{ "name": "theme[menu_color_mode]", "value": ["custom"] }],
    "helpTooltip": "Used only when Menu colors is set to Custom colors."
}

Keys of an option

KeyMeaning
typeOne of color, text, textarea, number, range, select, image-select, radio, switch, font, spacing, file (the settings field types)
defaultThe value used until the site saves one. spacing defaults are objects ({"top": "2.5rem", "right": "0", ...}); switch values are the strings yes/no
tabThe tab the field appears on. Echo uses light-colors, dark-colors, typography, layout, components, header, top-bar, navigation, breadcrumbs, footer; the core adds an Advanced tab with reset and regenerate buttons
label, helpTooltip, helpField label, tooltip and help text
options, imagesChoices for select, radio and image-select (value → label, value → preview image URL)
validationLaravel rules; when omitted a rule is derived from the type
condition + conditionValue, or conditionsShow the field only while another option has a value. Refer to other options as theme[key]
allow_alpha, allow_transparentColour picker extras
min, max, step, rows, acceptedPassed to the field (range, textarea, file)
persistfalse keeps the option out of the stored defaults

The schema is cached per file fingerprint, so edits to theme.json show up on the next request. The full list of Echo's options is in the Echo theme options catalogue.

Where values live

App\Services\Themes\ThemeOptionStore keeps all values in one database setting, theme_options, keyed by theme name. Only values the site has saved are stored; everything else comes from the schema.

CallDoes
theme_option('header_preset', 'logo-left')Stored value → schema default → your fallback, for the active front theme
theme_nested_option('primary_color')Reads a key inside a nested option array (legacy colour groups)
app(ThemeOptionStore::class)->forTheme('echo')All values for a theme, including request overrides
->put('echo', $options)Persist values and flush the response cache. It replaces the theme's whole stored set, so merge with persistedForTheme('echo') first when you change one key
->forget('echo')Reset a theme to its defaults

Per-request and per-page overrides

A page can change options for itself only. The page form stores choices such as header preset, floating or sticky header, menus and breadcrumb mode in the page's settings JSON; when the page renders, PageSlugRenderer calls override_theme_options($page->settings). Overrides live for the current request and are never saved.

php — in your own controller

set_theme_option('header_preset', 'centered');     // this request only
override_theme_options(['show_breadcrumbs' => 'no', 'footer_widgets' => 'no']);

return view('testimonials::front.wall');

A page that pins primary_menu keeps that menu even when the site's primary menu changes. When menu changes "do nothing", check the page's settings first.

How options become CSS

App\Services\Themes\ThemeCssCompiler does not run Sass. It takes the stylesheet Vite already built for the theme (resources/themes/{theme}/assets/sass/app.scss in public/build/manifest.json), rewrites its CSS custom properties with the saved values, appends a few component overrides, and writes the result to the public disk.

After npm run build, regenerate the theme CSS

The public site serves the compiled copy, not the fresh Vite build. After changing Echo's Sass and rebuilding, open Appearance → Theme options → Advanced and click Regenerate Theme CSS (route admin.settings.theme-options.compile-css), or save the options again. Until then the site keeps the old compiled stylesheet.

Using a custom option in your markup

Because new options do not reach the compiled CSS, apply them where you render. Validate the value in the schema and still escape it:

theme.json + Blade

{{-- theme.json --}}
"harbor_ribbon_color": { "default": "#0E7490", "type": "color", "tab": "components",
    "label": "Ribbon colour", "helpTooltip": "Background of the announcement ribbon.",
    "validation": ["nullable", "regex:/^#[0-9a-fA-F]{6}$/"] }

{{-- components/ribbon.blade.php --}}
<div class="harbor-ribbon" style="--harbor-ribbon: {{ theme_option('harbor_ribbon_color') }}">
    {{ $slot }}
</div>

In your Sass, read the variable (background: var(--harbor-ribbon, var(--bs-primary));) so the default still follows the theme's primary colour.

Colours in plugin and template CSS

Testing

use App\Services\Themes\ThemeOptionStore;

it('uses the configured ribbon colour', function (): void {
    $store = app(ThemeOptionStore::class);
    $store->put('echo', [...$store->persistedForTheme('echo'), 'harbor_ribbon_color' => '#123456']);

    expect(theme_option('harbor_ribbon_color'))->toBe('#123456');
});