rasuvaeff / yii3-settings-db
Database-backed writable settings provider for Yii3 applications
Requires
- php: 8.3 - 8.5
- ext-sodium: *
- rasuvaeff/yii3-settings: ^1.0
- symfony/console: ^7.0
- yiisoft/db: ^2.0
- yiisoft/db-migration: ^2.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.33
- maglnet/composer-require-checker: ^4.17
- psr/simple-cache: ^3.0
- rasuvaeff/property-testing: ^2.6
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
- yiisoft/cache: ^3.2
- yiisoft/db-sqlite: ^2.0
- yiisoft/injector: ^1.2
- yiisoft/test-support: ^3.0
Suggests
- psr/simple-cache: For wrapping reads in the core CachedSettingsProvider (PSR-16)
This package is auto-updated.
Last update: 2026-08-01 23:53:06 UTC
README
Database-backed writable settings provider for Yii3 applications. Implements WritableSettingsProvider and SettingsInspector from rasuvaeff/yii3-settings, persists runtime overrides in a DB table, and supports at-rest encryption of secret settings via libsodium (XChaCha20-Poly1305).
Using an AI coding assistant? llms.txt has a compact API reference you can feed into the model.
Requirements
- PHP 8.3+
ext-sodium(bundled with PHP 7.2+)rasuvaeff/yii3-settings^1.0yiisoft/db^2.0yiisoft/db-migration^2.0- a PSR-16 cache implementation — required transitively by
yiisoft/db2.0 (e.g.yiisoft/cache)
Installation
composer require rasuvaeff/yii3-settings-db
Requires rasuvaeff/yii3-settings ^1.0. With Yii3 config-plugin this package binds
SettingsProvider, WritableSettingsProvider and SettingsInspector
automatically (all resolve to the same DbSettingsProvider instance); the core
binds the Settings facade. Do not also bind these in your application or
another backend, or yiisoft/config reports a Duplicate key error.
Usage
Basic provider
use Rasuvaeff\Yii3Settings\SettingDefinition; use Rasuvaeff\Yii3Settings\SettingType; use Rasuvaeff\Yii3Settings\Settings; use Rasuvaeff\Yii3SettingsDb\DbSettingsProvider; $definitions = [ 'mail.from' => new SettingDefinition(key: 'mail.from', type: SettingType::String, default: 'noreply@example.com'), 'orders.max_items' => new SettingDefinition(key: 'orders.max_items', type: SettingType::Int, default: 100), ]; $provider = new DbSettingsProvider( db: $connection, definitions: $definitions, table: 'settings', ); $provider->set('mail.from', 'admin@example.com'); $provider->set('orders.max_items', '250'); $settings = new Settings(provider: $provider, definitions: $definitions); $settings->string('mail.from'); $settings->int('orders.max_items');
The constructor accepts an optional fallback (SettingsProvider). When a key
has no stored DB row, get() delegates to the fallback (which must recognize the
same keys) instead of returning the definition default — this is how config
values are preserved in the DI wiring below. Without a fallback, a missing row
resolves to SettingDefinition::default.
Yii3 config-plugin wiring
The package ships config/params.php and config/di.php via config-plugin.
It binds WritableSettingsProvider, SettingsProvider and SettingsInspector;
the Settings facade itself is bound by the core (rasuvaeff/yii3-settings ^1.0)
from the injected SettingsProvider. The default wiring keeps explicit config
values working: DbSettingsProvider is built with a ConfigSettingsProvider
fallback, so a key without a stored DB row resolves to its config value (and
only then to the definition default).
Encryption is turnkey: set rasuvaeff/yii3-settings-db.cipher.key (a 32-byte key,
base64) and the wiring builds the KeyRing + SodiumCipher for you — no manual
crypto wiring in the host app. When any secret definition exists, the key is
required (the provider throws at construction otherwise).
return [ 'rasuvaeff/yii3-settings' => [ 'definitions' => [ 'mail.from' => ['type' => 'string', 'default' => 'noreply@example.com'], 'orders.max_items' => ['type' => 'int', 'default' => 100], ], 'values' => [ 'mail.from' => 'config@example.com', ], ], 'rasuvaeff/yii3-settings-db' => [ 'table' => 'settings', 'cipher' => [ 'key_id' => 'main', 'key' => $env['SETTINGS_CIPHER_KEY'] ?? null, // 32-byte key, base64 ], ], ];
Runtime precedence after wiring:
| Source | Priority |
|---|---|
| DB row | 1 |
Explicit config values |
2 |
SettingDefinition::default |
3 |
Migration
Register the bundled migration by namespace — no vendor paths:
// config/common/di/migration.php use Yiisoft\Db\Migration\Service\MigrationService; return [ MigrationService::class => [ 'setSourceNamespaces()' => [[ 'App\\Migration', 'Rasuvaeff\\Yii3SettingsDb\\Migration', ]], ], ];
./yii migrate:up ./yii migrate:down --limit=1
Heads up: the snippet above does not find the migration yet. It is the correct configuration and will start working with no change on your side once the upstream bug below is fixed — but today
./yii migrate:upreports "Your system is up-to-date", exits 0 and creates no tables.
yiisoft/db-migration(2.0.x) resolves a namespace to a directory by taking the first entry incomposer/autoload_psr4.phpthat the namespace starts with, comparing against the key with its trailing separator trimmed but cutting the remainder with the untrimmed length. Trimming the separator destroys the segment boundary, soRasuvaeff\Yii3Settings\matchesRasuvaeff\Yii3SettingsDb\Migrationas if it were its parent — and this package depends on that one, so the collision is always present. The resolved directory does not exist, discovery skips missing directories silently, and nothing is applied.
Until that is fixed upstream, apply the bundled migration yourself:
// src/Console/MigrateCommand.php (excerpt) use Rasuvaeff\Yii3SettingsDb\Migration\M260605120000CreateSettingsTable; use Yiisoft\Db\Migration\Informer\ConsoleMigrationInformer; use Yiisoft\Db\Migration\MigrationBuilder; use Yiisoft\Injector\Injector; $builder = new MigrationBuilder($db, new ConsoleMigrationInformer()); $injector = new Injector($container); foreach ([ M260605120000CreateSettingsTable::class, ] as $class) { $injector->make($class)->up($builder); }
Injector::make() is required rather than new: it resolves the table-name
value object from your configuration. Keep the loop idempotent (skip when the
table already exists) — it has no migration history of its own.
Set the table name in params — the same value reaches the migration and
DbSettingsProvider:
// config/common/params.php 'rasuvaeff/yii3-settings-db' => [ 'table' => 'my_settings', 'table_prefix' => '', // prepended to `table`; e.g. 'rsv_' → rsv_my_settings ],
Do not configure the migration through the DI container.
M...::class => ['__construct()' => ['table' => ...]]does not work: the migration is built byInjector::make(), which resolves arguments by type and never reads a container definition keyed by the migration's own class. Worse, adding that definition makes the container fatal at build time in every request, because the class is not autoloadable until the migration runner requires it. That recipe was documented in 1.x; it never worked.
Core cache decorator
use Rasuvaeff\Yii3Settings\CachedSettingsProvider; $cached = new CachedSettingsProvider( inner: $provider, cache: $psr16Cache, definitions: $definitions, ttl: 60, ); $cached->get('mail.from'); $cached->clear('mail.from');
Secret settings
use Rasuvaeff\Yii3Settings\SettingDefinition; use Rasuvaeff\Yii3Settings\SettingType; use Rasuvaeff\Yii3SettingsDb\Crypto\KeyRing; use Rasuvaeff\Yii3SettingsDb\Crypto\SodiumCipher; $keyRing = new KeyRing( keys: ['key-2025' => sodium_crypto_aead_xchacha20poly1305_ietf_keygen()], activeKeyId: 'key-2025', ); $cipher = new SodiumCipher(keyRing: $keyRing); $definitions = [ 'billing.stripe_key' => new SettingDefinition(key: 'billing.stripe_key', type: SettingType::String, secret: true), 'mail.from' => new SettingDefinition(key: 'mail.from', type: SettingType::String, default: 'noreply@example.com'), ]; $provider = new DbSettingsProvider(db: $db, definitions: $definitions, cipher: $cipher); $provider->set('billing.stripe_key', 'sk_live_xxx'); // Stored as enc:vkey-2025:... in DB — plaintext never at rest. $provider->get('billing.stripe_key'); // 'sk_live_xxx' (transparent decrypt)
Settings inspector
$state = $provider->describe(key: 'billing.stripe_key'); $state->key; // 'billing.stripe_key' $state->hasStoredOverride; // true $state->source; // 'db', 'config', or 'default' $state->isSecret; // true — value is masked (null) $state->isWritable; // true
Key rotation
$count = $provider->reencryptSecrets(); // Decrypts all stored secret values with their current key, // re-encrypts with the active key. Plaintext values (without enc: prefix) // are encrypted in-place. Returns count of re-encrypted keys.
Bulk operations
// Resolve effective values for all keys starting with "mail." $values = $provider->getByPrefix('mail.'); // ['mail.from' => 'admin@example.com', 'mail.enabled' => true] // Set multiple values in a single transaction $provider->setMany([ 'mail.from' => 'admin@example.com', 'orders.max_items' => '250', 'mail.enabled' => false, ]);
getByPrefix() runs a single LIKE query, then resolves each key through
the normal precedence (DB row > fallback > default). setMany() validates all
keys upfront (unknown → UnknownSettingException, readonly →
ReadonlySettingException), then upserts in a transaction — if any write fails
the entire batch rolls back.
Console commands
# Re-encrypt all secret values with the current active key
./yii settings:reencrypt
The settings:reencrypt command runs reencryptSecrets() on the active
DbSettingsProvider instance. Works in any Symfony Console /
yiisoft/yii-console application. The ReencryptSettingsCommand class is
autowired via DI.
Public API
| Class | Description |
|---|---|
DbSettingsProvider |
DB-backed WritableSettingsProvider + SettingsInspector |
KeyRing |
Key management with versioning and active key |
SodiumCipher |
XChaCha20-Poly1305 encryption via libsodium |
ReencryptSettingsCommand |
Console command (settings:reencrypt) |
InvalidSettingRowException |
Stored row type mismatch |
Security
- Unknown keys are rejected:
get(),set(), andremove()throwUnknownSettingException. - DB values are normalized through
SettingDefinition; malformed ints/floats/arrays throwInvalidSettingRowException. - User values go through bound parameters in write/delete commands.
- Table names must be trusted application configuration.
- At-rest encryption: XChaCha20-Poly1305 AEAD via libsodium. Each value uses a random 24-byte nonce.
- AAD binding: ciphertext is bound to the setting key — a value cannot be moved to a different row.
- Fail loud: tampered data throws
DecryptionException, never silent fallback. - Key rotation: keyId in envelope allows reading with old key, writing with active key.
- Secret values do not appear in
SettingState::effectiveValue(masked as null). - Cipher is required when
secret: truedefinitions exist — fail-fast at construction.
Examples
See examples/ for runnable scripts.
Development
make install && make build
License
BSD-3-Clause. See LICENSE.md.