hbp / settings
Layered settings resolution for Hybrid Core themes and plugins.
Requires
- php: >=8.2
- themehybrid/hybrid-contracts: ^2.0
- themehybrid/hybrid-core: ^7.0
- themehybrid/hybrid-tools: ^2.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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.