Search by

hbp / settings

saas786

Layered settings resolution for Hybrid Core themes and plugins.

1.0.0-alpha.1 2026-10-23 07:02 UTC

This package is not auto-updated.

Last update: 2026-10-24 05:52:54 UTC


README

Layered settings resolution for Hybrid Core 7 themes and plugins.

It reads Hybrid Core's existing configuration repository rather than introducing a second one. This package ships no config/ directory of its own: every namespace it reads is owned by the theme or plugin consuming it.

Requires PHP 8.2+, themehybrid/hybrid-core ^7.0 and themehybrid/hybrid-tools ^2.0. The Loader shown below ships with hybrid-tools 2.0.6.

The consumer loads the config

This package reads config. It never loads it. Nothing here populates {namespace}.*, so a consumer that does not load its own config gets [] from every read, with no error.

Load a namespaced directory once, from the theme or plugin bootstrap:

use Hybrid\Tools\Config\Loader;

Loader::load( MYPLUGIN_DIR . '/config', 'myplugin' );

Everything under that directory then answers to the namespace:

config/
├── site/
│   └── identity.php     → myplugin.site.identity.*
├── presets.php          → myplugin.presets.*
├── features.php         → myplugin.features.*
└── controls.php         → myplugin.controls.*   (see hbp/settings-ui)

A root-level config/*.php file loaded without a namespace is framework config and collides with the framework's own keys by design. Namespace your package's config.

Resolution

For a namespace such as child or myplugin, a read falls through:

preset lock        ← fixed by the active preset, beats everything below
    ↓
object meta        ← only when scoped via for()
    ↓
namespace option
    ↓
active preset
    ↓
Hybrid Core config
    ↓
caller default

A stored false, 0, '' or null is a stored value and stops the fall-through. Only an absent key falls to the next tier.

The order is five plain statements in Settings::resolve(), so changing the precedence stays a local edit rather than a resolver graph.

API

Helpers are namespaced and take the namespace explicitly. There is no ambient "current" namespace to get wrong:

use function HBP\Settings\settings;
use function HBP\Settings\setting;
use function HBP\Settings\features;
use function HBP\Settings\feature;

setting( 'child', 'site.title' );
setting( 'child', 'site.title', 'Fallback' );

$settings = settings( 'child' );
$settings->get( 'site.title' );
$settings->set( 'site.title', 'New' );
$settings->has( 'site.title' );
$settings->forget( 'site.title' );
$settings->stored();            // the option only, no config or preset tier

settings( $namespace, $option ) takes an optional option name, defaulting to "{$namespace}_settings".

No global helpers

This package exports no global functions.

Declare a short alias in your own theme or plugin, where owning the name is your call to make:

function child_setting( string $key, mixed $default = null ): mixed {
    return \HBP\Settings\settings( 'child' )->get( $key, $default );
}

Per-object settings

The object-meta tier is opt-in. An unscoped instance never touches meta:

settings( 'child' )->for( $post )->get( 'layout' );
settings( 'child' )->for( Target::post( 12 ) )->set( 'layout', 'wide' );
settings( 'child' )->forQueried()->get( 'layout' );  // $this outside the loop

Target::from() accepts a WP_Post, a WP_Term, a Target, or [ 'type' => ..., 'id' => ... ]. Bare integers are rejected: they made an integer default indistinguishable from a post ID and produced silent wrong reads. Use Target::post() or Target::term().

A scoped instance writes to that object's meta; an unscoped one writes to the option. Writes never guess a target.

Meta keys default to _hbp_settings_{namespace}_{key} and can be remapped per key at {namespace}.meta.{key}, which is what makes adopting settings that already live under a legacy key possible.

Presets

A preset is a diff against the config, not a second copy of it:

// config/presets.php
return [
    'active'  => 'minimal',
    'minimal' => [
        'site.title' => 'Minimal',
        'features'   => [ 'gallery.slider' => false ],
        'hidden'     => [ 'site.tagline' ],   // read by hbp/settings-ui
    ],
];

The active preset is named at {namespace}.presets.active, and must be a non-empty string. Anything else (a missing key, a typo'd key, null) means no preset is active, and the whole preset tier is skipped with no error. Every read simply falls through to the config tier as though the preset did not exist, so a mis-named key looks exactly like a preset whose values happen to match the defaults.

Preset bodies may be written with flat dotted keys or as nested arrays; both resolve.

Locks

A preset can fix a value for its build. A lock beats every other tier, stored values and object meta included:

'minimal' => [
    'locks' => [
        'site.layout' => [ 'value' => 'boxed', 'reason' => 'This build is boxed.' ],
    ],
],

A lock needs a value key; null is a valid locked value. locked( $key ) and lockReason( $key ) let a settings screen render the control disabled with its reason instead of editable.

Config paths

Presets, features, tabs and sections live directly under the namespace by default. A consumer that files them elsewhere says so once, before the first read:

app( SettingsFactory::class )->paths( 'child', [
    'presets'  => 'site.presets',
    'features' => 'site.features',
] );

Paths are relative to the namespace. Instances already built for it are dropped, so none keeps reading the old paths.

Features

A capability is a build-time fact, not something a user stores. Features never reads or writes the option.

feature( 'child', 'gallery.slider' );   // bool
features( 'child' )->all();             // every declared capability

Capabilities and their defaults are declared at {namespace}.features.*; the active preset states only where it differs. An undeclared capability is off, so a typo fails loudly rather than silently enabling something.

Off must mean all three: the behaviour does not run, the controls do not render, and the assets are not enqueued. Guard all three.

Reserved roots

These subkeys are reserved under a namespace and cannot be used as setting keys: presets, meta, features, controls, tabs, sections. Using one throws.

Closures in config

A config value may be a closure, so an expensive or translated default is only paid for when it is reached:

'site.title' => static fn() => esc_html__( 'Untitled', 'child' ),

Settings::get() and Features::enabled() resolve these through Hybrid\Tools\value(). Reading the repository directly does not: a raw config()->get() hands back the closure unresolved.

Closures resolve at the leaf only. A closure returning a whole subtree is not expanded.

Filters

Every read passes through:

hbp-settings/{namespace}/setting/{key}

with ( $value, $key, $target ).

Extending

Store and ObjectMeta are the two seams. OptionStore keeps a namespace in a single autoloaded option addressed by dotted key; WordPressMeta is the metadata boundary. Swapping either is what lets Settings be tested without WordPress.

Admin UI

Rendering controls, tabs and settings pages lives in hbp/settings-ui.