yahyaerturan / settings
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.
Requires
- php: ^8.5
- psr/simple-cache: ^3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35.4
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.3
Suggests
- phpunit/phpunit: ^13.0 to extend YahyaErturan\\Settings\\Testing\\SettingStoreContractTestCase when writing your own storage adapter.
Provides
None
Conflicts
None
Replaces
None
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.
$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
- Install
- Five-minute tour
- The model
- The API
- Definitions and validation
- Defaults
- Caching
- Persistence
- Errors
- Security boundary
- What this package is not
- Guides
- Development
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.