Two sibling posts on this site have already poked at individual corners of Brndle’s settings layer: one registered a client’s brand color as a 13th scheme through brndle/color_presets, the other injected environment-driven design tokens through brndle/css_variables. Both posts quoted the same two lines in passing without ever documenting them properly: Settings::get() as the read path, and an authenticated REST route at brndle/v1/settings as the write path for anything that is not a human clicking through Brndle → Colors.

That gap matters more than it looks. A theme’s settings layer is the one surface every piece of agency tooling eventually has to touch: a deploy script that needs to set a brand color on forty client sites without opening wp-admin forty times, a staging-to-production sync job that has to know which keys are safe to copy, a WP-CLI command wired into CI, an external dashboard reading five sites’ configuration into one view. None of that is possible to build correctly from two posts that each verified one filter hook. It needs the actual read/write contract: every method on Settings, every route under brndle/v1, exactly what gets sanitized and how, and the complete, real list of every key that contract covers.

We pulled app/Settings/Settings.php, app/Settings/Defaults.php, app/Settings/Sanitizer.php, and app/Providers/SettingsServiceProvider.php straight from the brndle-themes/brndle repository’s default branch, read on 12 November 2026. This post is the reference the other two should have been able to link to: the full PHP API, the full REST contract, the full schema, two methods that genuinely do not exist (no Settings::set(), no public sanitizer bypass), and a worked example that scripts a bulk settings rollout across several sites through the REST route rather than the admin UI.

Table of contents

The PHP API: all, get, save, reset, colorPalette, fontPair, cssVariables

Brndle\Settings\Settings is a plain static class, not a singleton instance you resolve from the container. Every method is called directly as Settings::methodName(). Here is the complete, current surface of the class, in the order it appears in the file:

namespace Brndle\Settings;

class Settings
{
    public const OPTION_KEY = 'brndle_settings';
    public const VERSION = 1;

    public static function all(): array {}
    public static function get(string $key, mixed $default = null): mixed {}
    public static function save(array $settings): bool {}
    public static function reset(): bool {}
    public static function colorPalette(): array {}
    public static function fontPair(): array {}
    public static function cssVariables(): string {}
    public static function migrate(array $saved): array {}
    public static function clearCache(): void {}
}

Settings::all(): array is the method everything else is built on. It reads the brndle_settings option (stored as a serialized PHP array via update_option(), not as a JSON string, though the method defensively json_decode()s it if it ever comes back as a string), runs it through migrate(), and merges it over Defaults::all() with array_merge(), saved values winning. The result is cached in a private static property for the rest of the request:

public static function all(): array
{
    if (self::$cache !== null) {
        return self::$cache;
    }

    $saved = get_option(self::OPTION_KEY, []);

    if (is_string($saved)) {
        $saved = json_decode($saved, true) ?: [];
    }

    if (! is_array($saved)) {
        $saved = [];
    }

    $saved = self::migrate($saved);

    self::$cache = array_merge(Defaults::all(), $saved);

    return self::$cache;
}

Settings::get(string $key, mixed $default = null): mixed is a one-line convenience wrapper around all():

public static function get(string $key, mixed $default = null): mixed
{
    $all = self::all();

    return $all[$key] ?? $default;
}

Two things worth being precise about, because they are easy to get wrong by assuming instead of reading. First, the $default parameter is a true fallback that is only used if the key is missing from both the saved option and Defaults::all(), for every key documented in this post, Defaults::all() already defines a value, so Settings::get('font_pair') with no second argument returns 'inter', never null, unless you are reading a key that genuinely does not exist in the schema (a typo, or a key a filter added that this theme version does not define). Second, there is no type coercion in get() itself. If you ask for font_size_base, you get back whatever is in the merged array, which is an int coming from defaults but could in principle be a numeric string if something bypassed Sanitizer and wrote to the option directly. Callers that need a guaranteed type cast explicitly, exactly the way cssVariables() does it internally: (int) ($all['font_size_base'] ?? 16).

A practical pattern worth adopting from the theme’s own source: read once into a local array rather than calling Settings::get() repeatedly in a loop. Both approaches hit the same per-request cache, so there is no performance difference, but $all = Settings::all(); $all['font_pair']; $all['header_style']; makes it visually obvious you are reading from one consistent snapshot, which matters if something elsewhere in the same request calls Settings::save() partway through (rare, but it happens in import/export flows) and you want to reason about whether your reads are before or after that write.

Settings::save(array $settings): bool is the only write path into the option, and it is intentionally a merge, not a replace:

public static function save(array $settings): bool
{
    $sanitized = Sanitizer::sanitizeAll($settings);

    $saved = get_option(self::OPTION_KEY, []);

    if (is_string($saved)) {
        $saved = json_decode($saved, true) ?: [];
    }

    if (! is_array($saved)) {
        $saved = [];
    }

    $merged = array_merge($saved, $sanitized);
    $merged['_version'] = self::VERSION;

    $result = update_option(self::OPTION_KEY, $merged, false);

    self::$cache = array_merge(Defaults::all(), $merged);
    self::$cssVarsCache = null;

    return $result;
}

Pass a partial array, Settings::save(['font_pair' => 'grotesk']), and every other previously-saved key is untouched, array_merge($saved, $sanitized) only overwrites the keys you actually passed. This is why the REST route’s “partial update” behavior (covered below) is not a REST-layer convenience bolted on top; it is exactly how the underlying PHP method already behaves. update_option() is called with autoload explicitly set to false (the third argument), which matters at scale: brndle_settings is not loaded on every single page request via the alloptions autoload cache, it is fetched on demand, the right call for an option that is read via Settings::all() on most requests anyway but does not need to bloat the autoloaded options blob further.

Settings::reset(): bool does not merge anything back to defaults by writing them, it deletes the option outright:

public static function reset(): bool
{
    self::$cache = null;
    self::$cssVarsCache = null;

    return delete_option(self::OPTION_KEY);
}

After reset(), the next call to Settings::all() finds nothing in the database, so $saved is [], and the merge is array_merge(Defaults::all(), []), i.e. pure defaults. Functionally identical to “restore defaults,” implemented as “delete the override,” which is the simpler and more honest of the two designs: there is no separate defaults-option to drift out of sync with Defaults::all().

Settings::colorPalette() and Settings::fontPair() are the two settings-to-derived-data resolvers, both already covered in detail by the sibling posts: colorPalette() checks custom_accent first, falls back to the active color_scheme preset, and runs the chosen hex through ColorPalette::generate(); fontPair() resolves the font_pair key through FontPairs::resolve(). Neither is a settings accessor in its own right, both call self::get() or self::all() internally, same as any other consumer would.

Settings::cssVariables(): string, covered fully in the brndle/css_variables post, is the method that turns every color, font, and type-scale setting into one minified CSS string, cached per-request in $cssVarsCache and invalidated by save(), reset(), and clearCache().

There is no Settings::set(), why save() is the only write path

It is worth stating directly, because it is the first thing a developer coming from a framework with a Config::set($key, $value) convention reaches for: Brndle’s Settings class has no set() method, singular-key or otherwise. A full search of app/Settings/Settings.php for the string function set returns nothing. The only write entry point is save(array $settings), which always takes an array, even for a single key:

// There is no Settings::set('font_pair', 'grotesk').
// The real API always takes an array, even for one key:
Settings::save(['font_pair' => 'grotesk']);

This is a deliberate, if understated, design choice rather than a missing convenience method. Every write has to go through Sanitizer::sanitizeAll(), which dispatches per-key based on Defaults::boolKeys(), intKeys(), colorKeys(), and so on (the full list is in the next section). A hypothetical single-key set(string $key, mixed $value) would still have to route through the exact same Sanitizer::sanitize($key, $value) call that sanitizeAll() makes per-key internally, so it would not save any real logic, only shave a few characters off call sites that construct a one-element array. The theme’s own internal call sites, the REST handlers in SettingsServiceProvider, never reach for a single-key write either; they always build an array of allowlisted, sanitized keys and pass the whole thing to save() once. Writing one setting from your own code follows the same pattern:

// Single key, still an array:
Settings::save(['color_scheme' => 'northstar']);

// Several keys in one write, same call, same merge semantics:
Settings::save([
    'color_scheme' => 'northstar',
    'font_pair'    => 'grotesk',
    'footer_style' => 'minimal',
]);

Both calls merge over whatever is already saved; neither one touches a key it did not mention. There is also no Settings::delete(string $key) to remove a single key back to its default, the only way to drop a key to its default value is to write the default value explicitly (Settings::save(['custom_accent' => '']), since '' is the real default for that key) or to call Settings::reset(), which clears everything, not one key.

The full REST contract: every route, every method, every auth check

SettingsServiceProvider::registerRestRoutes(), hooked to rest_api_init, registers five endpoints, all under the brndle/v1 namespace, all gated by the identical permission callback:

public function registerRestRoutes(): void
{
    $namespace = 'brndle/v1';

    register_rest_route($namespace, '/settings', [
        ['methods' => 'GET',    'callback' => [$this, 'handleGetSettings'],   'permission_callback' => [$this, 'checkManageOptions']],
        ['methods' => 'POST',   'callback' => [$this, 'handleSaveSettings'],  'permission_callback' => [$this, 'checkManageOptions']],
        ['methods' => 'DELETE', 'callback' => [$this, 'handleResetSettings'], 'permission_callback' => [$this, 'checkManageOptions']],
    ]);

    register_rest_route($namespace, '/settings/export', [
        'methods' => 'GET', 'callback' => [$this, 'handleExportSettings'], 'permission_callback' => [$this, 'checkManageOptions'],
    ]);

    register_rest_route($namespace, '/settings/import', [
        'methods' => 'POST', 'callback' => [$this, 'handleImportSettings'], 'permission_callback' => [$this, 'checkManageOptions'],
    ]);

    register_rest_route($namespace, '/cache/purge', [
        'methods' => 'POST', 'callback' => [$this, 'handlePurgeCache'], 'permission_callback' => [$this, 'checkManageOptions'],
    ]);
}

public function checkManageOptions(): bool
{
    return current_user_can('manage_options');
}

Six call signatures, one guard. checkManageOptions() is the confirmed, current auth requirement for every one of these routes, there is no separate REST-specific capability, no API key scheme, no public read-only mode. An unauthenticated request, or a request from a user who is not an administrator (on a default install, manage_options is an administrator-only capability), gets WordPress’s standard REST rest_forbidden 401/403 response before the callback ever runs. Anything scripting against this route needs a logged-in session cookie plus a valid X-WP-Nonce (same-origin, inside wp-admin) or Application Password / Basic Auth credentials for an administrator account (cross-origin, from an external script). The worked example later in this post uses the latter.

MethodRouteHandlerAuthPurpose
GET/wp-json/brndle/v1/settingshandleGetSettings()manage_optionsReturns Settings::all() verbatim
POST/wp-json/brndle/v1/settingshandleSaveSettings()manage_optionsAllowlist + sanitize + Settings::save() on the JSON body
DELETE/wp-json/brndle/v1/settingshandleResetSettings()manage_optionsCalls Settings::reset(), returns Defaults::all()
GET/wp-json/brndle/v1/settings/exporthandleExportSettings()manage_optionsIdentical payload to GET /settings today, separate route for a distinct intent (backup/clone)
POST/wp-json/brndle/v1/settings/importhandleImportSettings()manage_optionsSame allowlist + sanitize + save path as POST /settings, for a bulk/import payload
POST/wp-json/brndle/v1/cache/purgehandlePurgeCache()manage_optionsClears compiled Blade views, Settings::clearCache(), and wp_cache_flush()

GET /settings is the simplest handler in the file, one line:

public function handleGetSettings(): WP_REST_Response
{
    return new WP_REST_Response(Settings::all(), 200);
}
curl -s https://example.com/wp-json/brndle/v1/settings \
  -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" | python3 -m json.tool | head -20
{
    "site_logo_light": "",
    "site_logo_dark": "",
    "social_links": {
        "twitter": "",
        "linkedin": "",
        "github": "",
        "instagram": ""
    },
    "color_scheme": "sapphire",
    "custom_accent": "",
    "dark_mode_default": "light",
    "dark_mode_toggle": true,
    "font_pair": "inter",
    "font_size_base": 16,
    "header_style": "sticky",
    "_version": 1
}

The response body is the full merged settings array, defaults and saved overrides together, plus the internal _version key used by migrate(). There is no partial-read query parameter; a client that only wants color_scheme still gets all fifty-plus keys back and picks out what it needs.

POST /settings is where the real logic lives, and it is worth quoting in full because every behavior a scripted client depends on is decided here, not in Settings::save():

public function handleSaveSettings(WP_REST_Request $request): WP_REST_Response
{
    $input = $request->get_json_params();

    if (! is_array($input) || empty($input)) {
        return new WP_REST_Response(['success' => false, 'message' => __('Invalid settings data.', 'brndle')], 400);
    }

    $allowedKeys = array_keys(Defaults::all());
    $filtered = array_intersect_key($input, array_flip($allowedKeys));

    if (empty($filtered)) {
        return new WP_REST_Response(['success' => false, 'message' => __('No valid settings keys provided.', 'brndle')], 400);
    }

    $sanitized = Sanitizer::sanitizeAll($filtered);

    Settings::save($sanitized);

    return new WP_REST_Response(['success' => true, 'settings' => Settings::all()], 200);
}

Three behaviors confirmed directly from this code, each one a real gotcha if assumed otherwise:

Unknown keys are silently dropped, not rejected. array_intersect_key($input, array_flip($allowedKeys)) filters the request body down to only the keys present in Defaults::all(). POST a body containing {"color_scheme": "northstar", "made_up_key": "x"} and the response’s settings object will reflect color_scheme changing; made_up_key is simply gone from $filtered, never sanitized, never saved, and the request still returns 200 with success: true as long as at least one key survived the filter. Only an empty-after-filtering request (every key unknown) returns the 400 “No valid settings keys provided” response.

A successful save returns the entire settings object, not just what you sent. The response body’s settings key is Settings::all() called fresh after the save, same shape as GET /settings. A client that only inspects the keys it posted and ignores the rest is throwing away a free, authoritative confirmation of the full current state, useful for detecting a value that silently resolved differently than expected (the color_scheme-not-allowlist-validated behavior documented in the color_presets post, for instance).

Sanitization runs on the filtered keys, so an allowlisted key with a bad value is coerced, not rejected. There is no validation step that returns an error for, say, a font_size_base of "banana"; Sanitizer::sanitizeInt() runs absint("banana"), which returns 0, and 0 gets saved. The practical implication for scripted writes: validate your own payload before sending it, because the API will accept and coerce nonsense rather than bounce it back as a 400.

await fetch('https://example.com/wp-json/brndle/v1/settings', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'X-WP-Nonce': brndleAdmin.nonce, // inside wp-admin only
    },
    body: JSON.stringify({
        color_scheme: 'northstar',
        font_pair: 'grotesk',
        header_cta_text: 'Get Started',
        header_cta_url: 'https://example.com/pricing',
    }),
});
// -> { "success": true, "settings": { ...all 50+ keys, with the four above changed... } }

DELETE /settings resets everything in one call, no confirmation step, no partial reset:

public function handleResetSettings(): WP_REST_Response
{
    Settings::reset();

    return new WP_REST_Response(['success' => true, 'settings' => Defaults::all()], 200);
}
curl -s -X DELETE https://example.com/wp-json/brndle/v1/settings \
  -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx"
# -> {"success":true,"settings":{...every key back to its Defaults::all() value...}}

There is no scoped version of this route (no DELETE /settings?keys=color_scheme,font_pair to reset two keys and leave the rest alone). A targeted reset of a handful of keys has to be done the same way a targeted write is: POST the default values for just those keys, which is functionally a reset for that subset without touching anything else, since save() only merges the keys you send.

GET /settings/export and GET /settings currently return byte-for-byte the same payload, handleExportSettings() is also a one-line call to new WP_REST_Response(Settings::all(), 200). The two routes exist separately because they express different intent to a caller (and to anyone reading the REST route list to understand what this theme exposes), not because the data differs today; treat them as the same contract and do not rely on either one gaining export-specific fields (a timestamp, a theme version marker) that the other lacks, since nothing in the current source adds any.

POST /settings/import runs the identical allowlist-then-sanitize-then-save pipeline as POST /settings, including the same silent-drop-unknown-keys and coerce-rather-than-reject behavior:

curl -s -X POST https://staging.example.com/wp-json/brndle/v1/settings/import \
  -u "admin:xxxx xxxx xxxx xxxx xxxx xxxx" \
  -H "Content-Type: application/json" \
  -d @production-settings-export.json
# -> {"success":true,"settings":{...}}

That makes export‘s GET response a directly valid body for import‘s POST, since both speak the same Settings::all() shape, including the harmless _version key (it is a known key only in the sense that Defaults::all() does not define it, so it is filtered out by the same allowlist as any other unknown key, not specially handled).

POST /cache/purge is the one route in this group that is not a settings read or write at all, it is a cache-invalidation utility that happens to live in the same service provider and share the same auth guard:

public function handlePurgeCache(): WP_REST_Response
{
    $cleared = [];

    $viewsPath = defined('WP_CONTENT_DIR')
        ? WP_CONTENT_DIR . '/cache/acorn/framework/views'
        : ABSPATH . 'wp-content/cache/acorn/framework/views';

    if (is_dir($viewsPath)) {
        $files = glob($viewsPath . '/*.php');
        $count = 0;
        if ($files) {
            foreach ($files as $file) {
                if (unlink($file)) { $count++; }
            }
        }
        $cleared['blade_views'] = $count;
    } else {
        $cleared['blade_views'] = 0;
    }

    Settings::clearCache();
    $cleared['settings_cache'] = true;

    wp_cache_flush();
    $cleared['object_cache'] = true;

    return new WP_REST_Response([
        'success' => true,
        'message' => __('All caches purged successfully.', 'brndle'),
        'cleared' => $cleared,
    ], 200);
}

Useful as the last step of the bulk-rollout script later in this post: a scripted settings write that changes color_scheme or font_pair is immediately visible via Settings::all() in the same request (the in-memory cache is updated directly inside save()), but a separate full-page cache, CDN, or object-cache layer (Redis, WP Rocket) sitting in front of the next page load may still serve a pre-change response until something purges it. This route purges compiled Blade views and the WordPress object cache; it does not reach a CDN or reverse proxy sitting further out, the same caveat the css_variables post already flagged for environment-driven tokens.

How input gets sanitized before it ever reaches the database

Sanitizer::sanitize(string $key, mixed $value) is a type dispatcher keyed entirely off which list in Defaults a given key belongs to, falling through to plain text sanitization for anything not explicitly typed:

public static function sanitize(string $key, mixed $value): mixed
{
    if ($key === 'homepage_sections') {
        return self::sanitizeHomepageSections($value);
    }

    return match (true) {
        in_array($key, Defaults::colorKeys(), true) => self::sanitizeColor($value),
        in_array($key, Defaults::urlKeys(), true) => self::sanitizeUrl($value),
        in_array($key, Defaults::emailKeys(), true) => self::sanitizeEmail($value),
        in_array($key, Defaults::htmlKeys(), true) => self::sanitizeHtml($value),
        in_array($key, Defaults::boolKeys(), true) => self::sanitizeBool($value),
        in_array($key, Defaults::intKeys(), true) => self::sanitizeInt($value),
        in_array($key, Defaults::floatKeys(), true) => self::sanitizeFloat($value),
        in_array($key, Defaults::arrayKeys(), true) => self::sanitizeArray($value),
        default => self::sanitizeString($value),
    };
}
Type listSanitizer functionWP function usedMember keys
colorKeys()sanitizeColor()sanitize_hex_color()custom_accent (only one)
urlKeys()sanitizeUrl()esc_url_raw()site_logo_light, site_logo_dark, header_cta_url, form_webhook_url
emailKeys()sanitizeEmail()sanitize_email()form_notification_email (only one)
htmlKeys()sanitizeHtml()wp_kses_post()footer_copyright (only one)
boolKeys()sanitizeBool()(bool) $value22 keys, see schema table
intKeys()sanitizeInt()absint()font_size_base, footer_columns, archive_posts_per_page
floatKeys()sanitizeFloat()filter_var(..., FILTER_SANITIZE_NUMBER_FLOAT)heading_scale (only one)
arrayKeys()sanitizeArray() (recursive)sanitize_text_field() / esc_url_raw() per valuesocial_links, homepage_sections (special-cased, see below)
everything elsesanitizeString()sanitize_text_field()every remaining key, including color_scheme, font_pair, header_style, every select-control key

The last row is the detail the color_presets post already leaned on and is worth repeating precisely here because it is the single most common surprise in this API: select-type keys like color_scheme, font_pair, header_style, and archive_layout have no allowlist check against their documented option values. They fall through to sanitize_text_field(), which strips tags and extra whitespace but does not validate that the string is one of the choices the admin UI’s <select> actually offers. Settings::save(['color_scheme' => 'literally-anything']) saves cleanly through both the REST route and direct PHP calls; the only place such a value gets a fallback is Settings::colorPalette()‘s own lookup, $presets[$scheme] ?? $presets['sapphire'], which is specific to that one consumer, not a property of the settings layer itself. A key like header_style has no equivalent fallback anywhere that we found in this read of the source; an invalid value there is passed through to whatever Blade partial switches on it, and its behavior is only as safe as that template’s own default case.

homepage_sections gets a dedicated sanitizer instead of the generic array handler, worth knowing if you are ever writing to it programmatically:

private static function sanitizeHomepageSections(mixed $value): array
{
    if (! is_array($value)) {
        return [];
    }

    $allowedStyles = Defaults::homepageSectionStyles();
    $sanitized = [];

    // Hard cap at 12 sections -- each section issues its own SELECT on the
    // homepage; without this guard a misconfigured list could explode into
    // 100+ queries per pageview on a high-traffic blog.
    $rows = array_slice(array_values($value), 0, 12);

    foreach ($rows as $row) {
        if (! is_array($row)) { continue; }

        $style = (string) ($row['style'] ?? 'grid-3col');
        if (! in_array($style, $allowedStyles, true)) {
            $style = 'grid-3col';
        }

        $sanitized[] = [
            'category_id'   => absint($row['category_id'] ?? 0),
            'style'         => $style,
            'count'         => max(1, min(10, absint($row['count'] ?? 4))),
            'show_title'    => ! empty($row['show_title']),
            'show_view_all' => ! empty($row['show_view_all']),
        ];
    }

    return $sanitized;
}

This is the one place in the whole settings layer that does enforce an allowlist on a string value (style, checked against Defaults::homepageSectionStyles(): featured-hero, grid-3col, magazine-strip, list-with-thumb, mixed-2x2, ticker, editorial-pair), and it enforces a hard cap of 12 rows and clamps count to between 1 and 10, specifically to keep a magazine-style homepage from issuing an unbounded number of per-section queries. No other array-shaped or string-shaped setting in the schema gets this kind of structural validation; it is scoped to this one key because of the N+1 query risk the inline comment calls out directly.

The complete real settings schema: all 50 keys, by section

Defaults::all() defines exactly fifty settings across eleven sections, each one filterable via apply_filters('brndle/settings_defaults', $defaults) at the end of the method, meaning a plugin can add a fifty-first key the same way the color_presets post’s pattern adds a thirteenth color scheme. This is the full list, quoted directly from the source, not reconstructed from the admin UI or guessed from the README:

KeyDefaultTypeSection
site_logo_light''urlSite Identity
site_logo_dark''urlSite Identity
social_links{twitter,linkedin,github,instagram: ''}arraySite Identity
color_scheme'sapphire'string (unvalidated)Colors
custom_accent''colorColors
dark_mode_default'light'string (unvalidated)Dark Mode
dark_mode_toggletrueboolDark Mode
dark_mode_toggle_position'bottom-right'string (unvalidated)Dark Mode
font_pair'inter'string (unvalidated)Typography
font_size_base16intTypography
heading_scale1.25floatTypography
header_style'sticky'string (unvalidated)Header
header_cta_text''stringHeader
header_cta_url''urlHeader
header_sticky_mode'sticky-fixed'string (unvalidated)Header
header_search_enabledfalseboolHeader
header_banner_text'Free shipping on all orders'stringHeader
header_mobile_style'slide'string (unvalidated)Header
footer_columns3intFooter
footer_copyright''html (wp_kses_post)Footer
footer_show_socialtrueboolFooter
footer_style'dark'string (unvalidated)Footer
archive_layout'grid'string (unvalidated)Blog Archive
archive_posts_per_page12intBlog Archive
archive_show_sidebarfalseboolBlog Archive
archive_show_category_filtertrueboolBlog Archive
homepage_sections_enabledfalseboolBlog Homepage
homepage_sections[]array (validated, capped at 12)Blog Homepage
single_layout'standard'string (unvalidated)Single Post
single_show_progress_bartrueboolSingle Post
single_show_reading_timetrueboolSingle Post
single_show_author_boxtrueboolSingle Post
single_show_social_sharetrueboolSingle Post
single_show_related_poststrueboolSingle Post
single_show_tocfalseboolSingle Post
single_show_post_navtrueboolSingle Post
single_show_updated_datetrueboolSingle Post
perf_remove_emojitrueboolPerformance
perf_remove_embedtrueboolPerformance
perf_lazy_imagestrueboolPerformance
perf_preload_fontstrueboolPerformance
perf_remove_global_stylesfalseboolPerformance
perf_view_transitionstrueboolPerformance
perf_critical_csstrueboolPerformance
mailchimp_api_key''stringForms & Integrations
mailchimp_list_id''stringForms & Integrations
form_webhook_url''urlForms & Integrations
form_notification_email''emailForms & Integrations
form_store_submissionstrueboolForms & Integrations
form_email_notificationstrueboolForms & Integrations

Two keys deserve a flag beyond the table. mailchimp_api_key is typed as a plain string, sanitized with sanitize_text_field() like any other unclassified key, not with any secret-specific handling, encryption, or masking in the sanitizer. It is stored in brndle_settings and returned in full, in plain text, by both GET /settings and GET /settings/export to any request that passes the manage_options check. That is a real operational detail for anyone building the multi-site rollout script below: a settings export is a credentials export the moment a site has Mailchimp configured, and should be handled with the same care as any other secret in a deploy pipeline, not committed to a repo alongside design-token presets.

Second, Defaults::schema(), a separate method from all(), carries the admin-UI metadata (section, label, description, control type, and for range controls a min/max/step) for every one of these fifty keys, and a CI script, bin/check-settings-consistency.mjs per the inline docblock, verifies the two methods’ key lists stay in sync. If you are building an external admin surface or a documentation generator against this API, Defaults::schema() (not exposed over REST today, PHP-only) is the structured source for labels and control types; Defaults::all() is the source for the actual default values the unauthenticated-by-filter-only validation above allows through.

Worked example: a bulk settings rollout script across multiple sites

The brndle/css_variables post built an environment-driven token override for a single codebase deployed to staging and production. This is the companion problem for an agency running the same Brndle build across several independently-hosted client sites from one workstation or CI job: push an approved settings change, verified once, to every site, without opening wp-admin on each one.

The script below uses WordPress Application Passwords (Users → Profile → Application Passwords, available on any WordPress 5.6+ install with no extra plugin) for authentication, rather than a session cookie and nonce, specifically because it runs outside wp-admin, from a terminal or CI runner:

#!/usr/bin/env python3
"""
Push an approved settings payload to every site in sites.json via the
brndle/v1/settings REST route. Confirms each write with the settings
object the API hands back, and purges caches afterward.
"""
import json
import sys
import urllib.request
import base64

SITES_FILE = "sites.json"
PAYLOAD_FILE = "rollout-payload.json"

def rest_call(base_url, path, method, user, app_password, body=None):
    url = f"{base_url.rstrip('/')}/wp-json/brndle/v1/{path}"
    token = base64.b64encode(f"{user}:{app_password}".encode()).decode()
    headers = {
        "Authorization": f"Basic {token}",
        "Content-Type": "application/json",
    }
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())

def main():
    with open(SITES_FILE) as f:
        sites = json.load(f)          # [{"url": "...", "user": "...", "app_password": "..."}...]
    with open(PAYLOAD_FILE) as f:
        payload = json.load(f)        # {"footer_copyright": "...", "header_cta_text": "..."}

    for site in sites:
        label = site["url"]
        try:
            # 1. Read current state first -- never write blind.
            before = rest_call(site["url"], "settings", "GET", site["user"], site["app_password"])

            # 2. Write only the approved keys. save() merges, so nothing
            #    else on this site is touched.
            result = rest_call(site["url"], "settings", "POST", site["user"], site["app_password"], payload)

            if not result.get("success"):
                print(f"[FAIL] {label}: {result.get('message')}")
                continue

            # 3. Confirm each key actually landed as sent, not silently
            #    coerced to something else (e.g. a bad hex -> '').
            after = result["settings"]
            mismatches = [k for k, v in payload.items() if after.get(k) != v]
            if mismatches:
                print(f"[WARN] {label}: keys did not save as sent: {mismatches}")
            else:
                print(f"[OK]   {label}: {len(payload)} keys applied")

            # 4. Purge caches so the change is visible on the next request,
            #    not just in Settings::all()'s per-request memoised copy.
            rest_call(site["url"], "cache/purge", "POST", site["user"], site["app_password"], {})

        except Exception as exc:
            print(f"[ERROR] {label}: {exc}")

if __name__ == "__main__":
    main()
// sites.json
[
  { "url": "https://client-one.example.com", "user": "deploy", "app_password": "xxxx xxxx xxxx xxxx xxxx xxxx" },
  { "url": "https://client-two.example.com", "user": "deploy", "app_password": "xxxx xxxx xxxx xxxx xxxx xxxx" }
]

// rollout-payload.json -- the actual change being rolled out
{
  "footer_copyright": "© 2026 Acme Robotics. All rights reserved.",
  "perf_view_transitions": true,
  "single_show_updated_date": true
}

Four details in that script map directly back to the confirmed behavior documented earlier in this post, not to assumptions about how a generic REST settings API “should” work: it reads before writing (step 1), because POST /settings has no dry-run mode and the only way to compare before/after is to fetch both states yourself; it posts only the keys actually being rolled out (step 2), relying on save()‘s merge-not-replace semantics so a script targeting three keys cannot accidentally wipe the other forty-seven; it diffs the response’s settings object against what was sent (step 3), because POST /settings returns 200 and success: true even when a value got silently coerced (an invalid hex sanitizing down to '', for instance) or when a key in the payload was unknown and got dropped by the allowlist; and it calls cache/purge as a final step (step 4), because a settings write is visible to Settings::all() immediately but not necessarily to the next visitor if a page cache or CDN sits in front of the site, the same caveat the environment-token post raised for a different reason.

The same four-step shape works as a WP-CLI wp eval one-liner for a single site where you already have shell access and do not need HTTP at all, bypassing REST and its auth check entirely since wp eval runs inside WordPress as an authenticated bootstrap:

wp eval '
$before = \Brndle\Settings\Settings::get("footer_copyright");
\Brndle\Settings\Settings::save(["footer_copyright" => "© 2026 Acme Robotics. All rights reserved."]);
$after = \Brndle\Settings\Settings::get("footer_copyright");
echo "before: $before\nafter: $after\n";
\Brndle\Settings\Settings::clearCache();
'

Choose REST for anything crossing a network boundary (a CI job, a script on a different machine than the target site); choose wp eval or a custom WP-CLI command for anything already running on the server with shell access, since it skips the HTTP round trip, the nonce/Application Password dance, and the JSON allowlist filtering, calling Settings::save() directly with the exact same merge and sanitize behavior underneath either path.

Caching, migrations, and what actually gets cleared on save

Two static properties back the entire settings layer’s in-request caching: $cache (the merged Defaults::all() + saved-option array returned by all()) and $cssVarsCache (the minified CSS string returned by cssVariables()). Both are request-scoped, reset to null on every fresh PHP process; neither persists between requests through the theme’s own code, by design, since the real persistence layer is the brndle_settings WordPress option itself.

Action$cache cleared?$cssVarsCache cleared?brndle_settings option touched?
Settings::save()Repopulated with merged data, not just clearedYes, set to nullWritten via update_option()
Settings::reset()YesYesDeleted via delete_option()
Settings::clearCache()YesYesUntouched, this is a cache-only operation
POST /cache/purgeYes (calls clearCache())YesUntouched, plus Blade view cache + wp_cache_flush()

migrate(), called once inside all() whenever the saved option’s _version is behind Settings::VERSION (currently 1, meaning no migration has ever had to run against a real prior version in this theme’s life so far), iterates a Migrations::all() list in ascending target-version order and persists the result immediately if anything changed:

public static function migrate(array $saved): array
{
    $current = (int) ($saved['_version'] ?? 0);
    if ($current >= self::VERSION) {
        return $saved;
    }

    $migrations = Migrations::all();
    ksort($migrations);

    $changed = false;
    foreach ($migrations as $target => $migration) {
        if ($current >= $target) { continue; }
        $saved = $migration($saved);
        $saved['_version'] = $target;
        $current = $target;
        $changed = true;
    }

    if ($changed) {
        update_option(self::OPTION_KEY, $saved, false);
    }

    return $saved;
}

For the bulk-rollout use case, the practical consequence is that the very first REST call (or wp eval call) against a site that has never had its settings touched by this version of the theme will trigger a migration write as a side effect of simply reading, before your own payload is even applied. That write is idempotent and runs against a known schema, so it is not a risk to script against, but it means a strict before/after diff across a fleet of sites at different ages should expect the very first before read on an old site to already include a freshly-bumped _version, not the stale one that site had been sitting on.

FAQ

Does Settings::get() hit the database on every call?

No. get() calls all(), which checks a private static $cache property first and only calls get_option() once per request, the first time anything reads settings. Every subsequent Settings::get() or Settings::all() call in the same request reads the already-merged in-memory array. Calling Settings::get() fifty times in a template costs one database read, not fifty.

Is there a way to write a single setting without building an array?

No. Settings::save() is the only write method and it always takes an array, even for one key: Settings::save(['font_pair' => 'grotesk']). There is no Settings::set(string $key, mixed $value) convenience wrapper in the current source.

What capability does the brndle/v1/settings REST route require?

manage_options, checked via current_user_can('manage_options') in checkManageOptions(), the single permission callback shared by all six routes under this service provider: GET/POST/DELETE /settings, GET /settings/export, POST /settings/import, and POST /cache/purge. On a default WordPress install, that capability belongs only to the Administrator role.

If I POST an unknown key to /settings, does the request fail?

No, as long as at least one key in the request body is a real settings key. handleSaveSettings() filters the input down to array_intersect_key($input, array_flip(array_keys(Defaults::all()))) before sanitizing anything; unrecognized keys are silently dropped from that filtered array and never saved, but the request still returns 200 with success: true if anything else in the body was valid. Only a body where every key is unknown returns a 400.

Does POST /settings validate select-type values like color_scheme or header_style against the options shown in the admin UI?

No, for most of them. Any key not explicitly listed in Defaults::colorKeys(), urlKeys(), emailKeys(), htmlKeys(), boolKeys(), intKeys(), or floatKeys() falls through to plain sanitize_text_field() sanitization with no allowlist check, which covers every select-control key including color_scheme, font_pair, header_style, footer_style, and archive_layout. The one exception is homepage_sections, whose dedicated sanitizer does validate each row’s style value against Defaults::homepageSectionStyles() and falls back to 'grid-3col' for anything else.

Are secrets like the Mailchimp API key encrypted or masked anywhere in this API?

No. mailchimp_api_key is sanitized with plain sanitize_text_field(), stored in the brndle_settings option in plain text, and returned in plain text by both GET /settings and GET /settings/export to any caller that passes the manage_options check. Treat a settings export the same way you would treat any other file containing live credentials.

What is the actual difference between GET /settings and GET /settings/export?

None in the current source; both call new WP_REST_Response(Settings::all(), 200) and return an identical payload. They exist as separate routes to express separate intent (current state vs. a backup/clone artifact), not because the data differs today. Do not build logic that depends on one route having a field the other lacks.

Can I reset a single setting to its default without resetting everything?

Not through Settings::reset(), which deletes the entire brndle_settings option. The practical equivalent for one or a few keys is to POST (or call Settings::save() with) the exact default values for just those keys, since a save only merges the keys you send and leaves everything else untouched. Defaults::all() is the authoritative source for what each key’s default actually is.

Does a settings write take effect immediately, or does something need to be cleared first?

Settings::save() updates both the database option and the in-memory $cache directly, and nulls $cssVarsCache, so the very next Settings::all() or Settings::cssVariables() call in that same request, or any later request, already reflects the change. The only thing that can make a change appear not to have taken effect is a layer outside the theme entirely, a full-page cache, a CDN, or a persistent object cache serving a pre-change HTML response; POST /cache/purge clears the theme’s own Blade-view cache and calls wp_cache_flush(), but does not reach a separate CDN or reverse proxy.

Is the REST settings payload the same shape as what Settings::all() returns in PHP?

Yes, exactly. Every GET/POST handler in SettingsServiceProvider either returns Settings::all() directly or calls it to build the response, so a script reading the REST API and a template reading Settings::get() are looking at the same data, same key names, same types, down to the internal _version field appearing in every full-object response.

The takeaway

Settings::get(string $key, mixed $default = null) and Settings::save(array $settings) are the real, complete PHP read/write pair, confirmed directly from app/Settings/Settings.php; there is no set(), no per-key delete, and every write merges rather than replaces. The REST contract sitting on top of it is six routes under brndle/v1, all gated by the identical manage_options check, three of them (GET/POST/DELETE on /settings) doing the actual reading, writing, and resetting, and three more (export, import, cache/purge) built around that same core. Both paths funnel every write through the same Sanitizer, which types most keys correctly but leaves nearly every select-control string, including color_scheme and header_style, unvalidated against the options the admin UI actually offers. And the schema underneath all of it is fifty real keys across eleven sections, not the handful any single hook-specific post happened to touch. An agency scripting settings across more than one site now has the actual contract to script against, confirm-after-write diffing included, rather than three separate posts’ worth of partial, hook-shaped glimpses of it.

Sources, read on 12 November 2026