rasuvaeff/yii3-settings-db

Database-backed writable settings provider for Yii3 applications

Maintainers

Package info

github.com/rasuvaeff/yii3-settings-db

pkg:composer/rasuvaeff/yii3-settings-db

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v2.0.1 2026-07-31 22:52 UTC

This package is auto-updated.

Last update: 2026-08-01 23:53:06 UTC


README

Stable Version Total Downloads Build Static Analysis PHP License Русская версия

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.0
  • yiisoft/db ^2.0
  • yiisoft/db-migration ^2.0
  • a PSR-16 cache implementation — required transitively by yiisoft/db 2.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:up reports "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 in composer/autoload_psr4.php that 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, so Rasuvaeff\Yii3Settings\ matches Rasuvaeff\Yii3SettingsDb\Migration as 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 by Injector::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(), and remove() throw UnknownSettingException.
  • DB values are normalized through SettingDefinition; malformed ints/floats/arrays throw InvalidSettingRowException.
  • 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: true definitions exist — fail-fast at construction.

Examples

See examples/ for runnable scripts.

Development

make install && make build

License

BSD-3-Clause. See LICENSE.md.