Search by

yahyaerturan / settings

yahyaerturan

Framework-agnostic, typed settings engine for PHP 8.5+. Resolves persistent per-scope overrides over application defaults, with provenance, strict validation and an optional PSR-16 cache.

Package info

github.com/yahyaerturan/settings

pkg:composer/yahyaerturan/settings

Statistics

Installs: 8

Dependents: 2

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-09-08 15:03 UTC

This package is auto-updated.

Last update: 2026-09-08 15:29:31 UTC


README

A framework-agnostic, typed settings engine for PHP 8.5+.

Your application has defaults in code. Your administrators, organizations, workspaces and users need to override them at runtime, persistently, at whichever level makes sense — and your admin screens need to explain where each effective value came from. That is what this package does, and deliberately all it does.

PHP License

$settings->get('app.locale', $context);   // "tr"

$result = $settings->resolve('app.locale', $context);
$result->source();                        // SettingSource::SCOPED_OVERRIDE
$result->scope()?->canonical();            // "workspace:w_9"

Contents

Why this exists

Most settings libraries collapse two different things into one mutable bag: the defaults your code ships with, and the overrides your users save. They then store the merged result, and you discover the cost later — when changing a default in a deployment does nothing, because every row already has a copy of the old one.

This package keeps them apart.

  • Defaults come from your code or configuration. This package reads them; it never writes them.
  • Overrides are values someone deliberately saved, at one exact scope.

An effective value is resolved on every read, so changing a default and deploying takes effect immediately for everyone who has not overridden it.

Install

composer require yahyaerturan/settings

Requires PHP 8.5+. The only runtime dependency is psr/simple-cache (used solely by the optional cache decorator). No framework, no ORM, no database driver.

For database-backed persistence, add yahyaerturan/settings-doctrine, which supports SQLite and PostgreSQL.

Five-minute tour

use YahyaErturan\Settings\Settings;
use YahyaErturan\Settings\SettingMode;
use YahyaErturan\Settings\SettingSource;
use YahyaErturan\Settings\Definition\SettingDefinition;
use YahyaErturan\Settings\Definition\SettingRegistry;
use YahyaErturan\Settings\Defaults\ArrayDefaultProvider;
use YahyaErturan\Settings\Store\InMemorySettingStore;
use YahyaErturan\Settings\Value\SettingContext;
use YahyaErturan\Settings\Value\SettingScope;

// 1. Declare what settings exist.
$registry = new SettingRegistry([
    SettingDefinition::string('app.locale')->withDefault('en'),
    SettingDefinition::integer('pagination.per_page')->withDefault(25),
    SettingDefinition::boolean('mail.enabled')->withDefault(false),
]);

// 2. Wire the service. In production the store would be a database adapter.
$settings = new Settings(
    store: new InMemorySettingStore(),
    defaults: new ArrayDefaultProvider(['app.locale' => 'en-GB']),
    registry: $registry,
    mode: SettingMode::STRICT,
);

// 3. Read. Nothing is stored yet, so this falls through to the default provider.
$settings->get('app.locale');                    // "en-GB"

// 4. Write, at one exact scope.
$settings->set('app.locale', 'tr', SettingScope::of('workspace', 'w_9'));

// 5. Read through a context: the scopes to try, in order.
$context = SettingContext::fromScopes(
    SettingScope::of('user', 'u_42'),
    SettingScope::of('workspace', 'w_9'),
);

$settings->get('app.locale', $context);          // "tr"
$settings->get('app.locale');                    // "en-GB" — no context, no workspace

// 6. Ask where a value came from.
$result = $settings->resolve('app.locale', $context);
$result->value();                                 // "tr"
$result->source() === SettingSource::SCOPED_OVERRIDE;  // true
$result->scope()?->canonical();                   // "workspace:w_9"

// 7. Remove the override; the layer beneath reappears.
$settings->reset('app.locale', SettingScope::of('workspace', 'w_9'));
$settings->get('app.locale', $context);          // "en-GB"

The model

Resolution order

first context scope
  → second context scope
    → …
      → global scope
        → external default provider
          → definition default
            → SettingNotFoundException

The first present value wins, and null is present. A setting deliberately saved as null stops resolution exactly like "tr" would; it never falls through.

The global scope is appended by the resolver, never supplied by you, so it is always checked last among persisted values and can never be accidentally ordered ahead of an application scope.

Scopes are yours

tenant, organization, workspace, user, project, site — none of these mean anything to this package. They are opaque strings you choose:

SettingScope::of('organization', 'org_123');
SettingScope::of('workspace', 'workspace_456');
SettingScope::of('device', 'dev_9');

The library never discovers a relationship between two scopes, and precedence comes from the order you supply — never from a scope's name. One application can use user → workspace → organization; another device → location → customer. Both work, because neither means anything here.

The one reserved scope is global, whose canonical identity is global:*. Build it with SettingScope::global(); it is rejected inside a context because the resolver adds it.

Reads and writes are deliberately asymmetric

A read may walk many scopes. A write targets exactly one, and is never inferred from a context:

$settings->set('app.locale', 'tr', SettingScope::of('workspace', 'w_9'));  // explicit
$settings->set('app.locale', 'tr');                                        // global

"This value came from the organization" is not an instruction to write to the organization. If you want that, say so.

reset() removes the override at the scope you name and nothing else — no parent scopes, no child scopes, no defaults, no cascade.

null is a value; missing is not a value

This distinction runs through the whole package:

Question Method
What value applies here? get() / resolve()
Does any layer supply a value? has()
Is there a row at this exact scope? isOverridden()
What is the row at this exact scope? findOverride()

A user who clears their avatar is not the same as a user who never set one. The first is an override holding null; the second is no row at all. isOverridden() returns true for the first and false for the second.

The API

interface SettingsInterface
{
    public function get(string $key, ?SettingContext $context = null): mixed;
    public function resolve(string $key, ?SettingContext $context = null): ResolvedSetting;
    public function has(string $key, ?SettingContext $context = null): bool;
    public function getMany(array $keys, ?SettingContext $context = null): array;

    public function set(string $key, mixed $value, ?SettingScope $scope = null): StoredSetting;
    public function setMany(array $values, ?SettingScope $scope = null): array;
    public function reset(string $key, ?SettingScope $scope = null): bool;
    public function resetMany(array $keys, ?SettingScope $scope = null): int;

    public function findOverride(string $key, ?SettingScope $scope = null): ?StoredSetting;
    public function isOverridden(string $key, ?SettingScope $scope = null): bool;
}

A null context means "global, then defaults". A null scope on a write or an inspection means the global scope.

Bulk operations are first-class

getMany() walks one scope at a time, asking only for the keys still unanswered — for 20 keys across a three-deep context that is at most four store round trips, not sixty. It is all-or-nothing: if any requested key resolves to nothing it throws, rather than handing back a partial array every caller would have to re-check.

setMany() validates every key and value before the store is touched, then writes them atomically, so one bad entry means zero writes rather than a half-applied batch.

$settings->setMany([
    'mail.enabled'   => true,
    'mail.from.name' => 'Acme',
], SettingScope::of('organization', 'o_2'));

Provenance

resolve() returns where the value came from, which is what lets an admin screen show "inherited from organization" instead of an unattributed string:

source() scope() storedVersion() updatedAt()
SCOPED_OVERRIDE the exact scope version timestamp
GLOBAL_OVERRIDE global:* version timestamp
EXTERNAL_DEFAULT null null null
DEFINITION_DEFAULT null null null

Definitions and validation

SettingDefinition::string('app.locale')->withDefault('en');
SettingDefinition::integer('pagination.per_page')->withDefault(25);
SettingDefinition::float('billing.tax_rate')->withDefault(0.2);
SettingDefinition::boolean('mail.enabled')->withDefault(false);
SettingDefinition::array('app.feature_flags')->withDefault([]);
SettingDefinition::mixed('app.anything');

SettingDefinition::string('profile.avatar_url')->nullable();

Types are checked strictly and never coerced. "42" is not an integer, 1 is not a boolean, and 1 is not a float. A coerced value would not survive a storage round trip as the type you promised, so a mismatch is rejected instead of quietly converted.

Add constraints with validators:

use YahyaErturan\Settings\Validator\ChoiceValidator;
use YahyaErturan\Settings\Validator\NumericRangeValidator;
use YahyaErturan\Settings\Validator\StringLengthValidator;

SettingDefinition::string('app.locale')
    ->withDefault('en')
    ->withValidator(new ChoiceValidator(['en', 'tr', 'de']));

SettingDefinition::integer('pagination.per_page')
    ->withDefault(25)
    ->withValidator(new NumericRangeValidator(min: 1, max: 200));

SettingDefinition::string('app.tagline')
    ->withValidator(StringLengthValidator::characters(max: 80));

StringLengthValidator makes you choose characters() or bytes(), because "length" is ambiguous for UTF-8 and the two readings disagree by a factor of four: "🎯" is one character and four bytes.

Every declared default is validated when the definition is registered, so a broken default fails at boot rather than on the first read that happens to fall through to it.

See docs/definitions-and-validation.md.

Defaults

use YahyaErturan\Settings\Defaults\ArrayDefaultProvider;
use YahyaErturan\Settings\Defaults\ChainDefaultProvider;
use YahyaErturan\Settings\Defaults\NullDefaultProvider;

new ArrayDefaultProvider(['app.locale' => 'en-GB']);   // a flat map
new ChainDefaultProvider([$appDefaults, $moduleDefaults]);  // first present wins
new NullDefaultProvider();                             // definition defaults only

Keys are atomic even though they contain dots. mail.from.address is one key, not a path — ArrayDefaultProvider will never assemble it from nested arrays. Flatten your configuration explicitly, where you know whether you meant a shallow or a deep merge.

Defaults are consulted on every resolution and never cached, so a configuration change takes effect on deploy. See docs/defaults.md.

Caching

use YahyaErturan\Settings\Cache\CachedSettingStore;

$store = new CachedSettingStore(
    inner: $databaseStore,
    cache: $psr16Cache,
    ttlSeconds: 300,
    namespace: 'production-main',
);

The decorator caches the answer to "is there a row for this key at this exact scope" — including the answer no, which is the common case and would otherwise hit the database on every request. It never caches an effective value, so changing an application default still takes effect immediately.

Caching is best-effort: a cache that is down, misconfigured, or returning nonsense makes reads slower, never unavailable, and never turns a committed write into a reported failure. A finite, positive TTL is mandatory. See docs/caching.md.

Persistence

SettingStoreInterface is the seam. This package ships InMemorySettingStore; anything durable is an adapter:

Adapter Databases
yahyaerturan/settings-doctrine SQLite, PostgreSQL

Writing your own is a supported path, and the package ships the same behavioural test suite its own store must pass so yours can be held to it too. See docs/storage-adapters.md.

Errors

Every exception implements YahyaErturan\Settings\Exception\SettingsException, so one catch isolates this package. Each also extends the SPL class that describes the fault, so existing handlers keep working.

Exception When
InvalidSettingKeyException malformed key, or a key repeated in one bulk request
InvalidScopeException malformed scope, or global used inside a context
DuplicateScopeException the same scope twice in one context
UndefinedSettingException unregistered key while in STRICT mode
SettingNotFoundException no override and no default supplies a value
InvalidSettingValueException value contradicts its definition, or is not JSON-safe
InvalidDefinitionException duplicate definition, or an invalid declared default
StorageException a persistence fault; adapters normalize to this
CorruptStoredValueException a stored row exists but cannot be decoded
UnsupportedDatabasePlatformException an adapter was given an unverified platform
InvalidCacheConfigurationException e.g. a non-positive cache TTL

Messages never contain a setting value. They name the key and scope, so a rejected credential cannot reach a log through an exception.

Security boundary

This is not a secrets manager. Values are persisted as plaintext JSON. Do not store database passwords, encryption keys, private keys, OAuth client secrets, or long-lived API tokens in it unless you supply an encrypted storage adapter and key management of your own.

The package uses no PHP serialization, instantiates no class from stored data, evaluates nothing, and performs no authorization — deciding who may read or write a setting is your application's job, before it calls set().

See docs/security.md.

What this package is not

Deliberately out of scope: a secrets vault, feature flags with percentage rollouts, audit history, change subscriptions, tenancy discovery, authorization, form rendering, .env loading, config-file parsing, or schema auto-migration. Each is a real problem; none of them is this one.

Guides

Guide
Getting started wiring the service, first read and write
Scopes and contexts modelling SaaS hierarchies
Definitions and validation types, nullability, validators, strict vs open
Defaults providers, precedence, flattening configuration
Caching what is cached, invalidation, failure behaviour
Storage adapters writing one, and the contract suite
Security threat model, what not to store
Compatibility SemVer policy and upgrade notes

Development

The package targets PHP 8.5, so Composer must run on a PHP 8.5 binary. If your php on PATH is older:

/path/to/php8.5 "$(command -v composer)" install
composer lint      # PHP syntax lint
composer cs        # coding standard (PER-CS 2.0), report only
composer cs:fix    # coding standard, apply
composer analyse   # PHPStan, level max
composer test      # PHPUnit
composer qa        # all of the above
composer mutation  # Infection (needs pcov or xdebug)

composer qa excludes mutation testing, which needs a coverage driver and so is not environment-independent. It runs separately, and in CI.

Contributions: see CONTRIBUTING.md. Security reports: see SECURITY.md.

License

MIT. See LICENSE.