Search by

yahyaerturan / settings-doctrine

yahyaerturan

Doctrine DBAL persistence for yahyaerturan/settings. Stores per-scope setting overrides in SQLite or PostgreSQL, with atomic upserts and no runtime schema changes.

Package info

github.com/yahyaerturan/settings-doctrine

pkg:composer/yahyaerturan/settings-doctrine

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-09-08 16:28 UTC

This package is auto-updated.

Last update: 2026-09-08 16:34:26 UTC


README

Doctrine DBAL persistence for yahyaerturan/settings.

PHP DBAL License

Database Status
SQLite verified against the core contract suite
PostgreSQL verified against the core contract suite
anything else refused explicitly

Contents

What this is

The core package defines SettingStoreInterface and knows nothing about SQL. This package implements that interface on Doctrine DBAL, so an application can persist settings in a real database without the core depending on Doctrine at all.

The dependency direction is one-way and stays that way: this package requires the core; the core must never require this one.

Design commitments

  • DBAL, never the ORM. No entities, no EntityManager, no attribute mapping. A settings row is a row.
  • The connection is yours. The store receives an already configured Doctrine\DBAL\Connection. It never reads DATABASE_URL, a .env file, or framework configuration, and never opens a connection of its own.
  • No schema changes at runtime. Creating the table is an explicit deployment action.
  • A platform is supported only once it has been verified against the core's storage contract suite. Being able to connect is not evidence.

Install

composer require yahyaerturan/settings-doctrine

Requires PHP 8.5+, doctrine/dbal ^4.4, and the driver extension for your database (pdo_sqlite or pdo_pgsql).

Usage

use Doctrine\DBAL\DriverManager;
use YahyaErturan\Settings\Doctrine\DoctrineSettingStore;

$connection = DriverManager::getConnection($yourExistingParameters);

$store = new DoctrineSettingStore($connection);

Hand it to the core service like any other store:

use YahyaErturan\Settings\Settings;

$settings = new Settings(
    store: $store,
    defaults: $yourDefaultProvider,
    registry: $yourRegistry,
);

$settings->set('app.locale', 'tr', SettingScope::of('workspace', 'w_9'));
$settings->get('app.locale', $context);

With caching in front — recommended for anything that reads settings per request:

use YahyaErturan\Settings\Cache\CachedSettingStore;

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

The decorator caches storage lookups, not effective values, so a changed application default still takes effect on deploy. See the core's caching guide.

Schema

This package never creates or alters your schema. A library issuing DDL at boot would have every application process racing to define production, with whichever won deciding the shape of your table. Installing it is a deployment action with its own review and rollback.

Generate the DDL for your platform:

use YahyaErturan\Settings\Doctrine\Schema\DoctrineSettingsSchema;

foreach (new DoctrineSettingsSchema()->createSql($connection->getDatabasePlatform()) as $sql) {
    echo $sql, ";\n";
}

Or write the migration by hand.

SQLite:

CREATE TABLE settings (
    scope_type  VARCHAR(64)  NOT NULL,
    scope_id    VARCHAR(255) NOT NULL,
    setting_key VARCHAR(255) NOT NULL,
    value_json  TEXT         NOT NULL,
    version     BIGINT       NOT NULL CHECK (version > 0),
    updated_at  VARCHAR(32)  NOT NULL,
    PRIMARY KEY (scope_type, scope_id, setting_key)
);

CREATE INDEX settings_setting_key_idx ON settings (setting_key);

PostgreSQL:

CREATE TABLE settings (
    scope_type  VARCHAR(64)  NOT NULL,
    scope_id    VARCHAR(255) NOT NULL,
    setting_key VARCHAR(255) NOT NULL,
    value_json  TEXT         NOT NULL,
    version     BIGINT       NOT NULL CHECK (version > 0),
    updated_at  VARCHAR(32)  NOT NULL,
    PRIMARY KEY (scope_type, scope_id, setting_key)
);

CREATE INDEX settings_setting_key_idx ON settings (setting_key);

The two are deliberately identical. Portability across the supported platforms is worth more here than using each engine's most idiomatic types.

Why these column types

value_json is TEXT, not PostgreSQL's JSONB. Values are addressed by key and never queried by their internal structure, so JSONB would buy nothing and would make the two schemas diverge.

updated_at is a fixed-width RFC 3339 UTC string, not a native timestamp. DBAL maps its datetime types onto SQLite's DATETIME, which stores whole seconds and would silently discard the microseconds a stored record carries — the core's contract suite detects exactly that. A canonical fixed-width UTC string keeps microsecond precision on both platforms and still sorts correctly as text.

The primary key is (scope_type, scope_id, setting_key), which is what lets the database decide atomically whether a write is an insert or a replacement.

The version > 0 check appears in the hand-written SQL but not in the generated DDL. DBAL's portable schema API cannot express a check constraint, and SQLite cannot add one afterwards, so emitting it for PostgreSQL alone would make the two schemas differ in what they actually enforce. The invariant is enforced in the domain instead — a StoredSetting refuses to exist with a version below 1, on every read as well as every write.

The global scope is an ordinary row

scope_type = 'global'
scope_id   = '*'

Never a NULL scope column. That keeps the primary key simple and every query uniform.

How writes work

Replacement and version increment happen in a single statement:

INSERT INTO settings (...) VALUES (?, ?, ?, ?, 1, ?)
ON CONFLICT (scope_type, scope_id, setting_key) DO UPDATE SET
    value_json = excluded.value_json,
    version    = settings.version + 1,
    updated_at = excluded.updated_at
RETURNING version, updated_at

Reading the current version into PHP, adding one, and writing it back would lose an update whenever two writers interleave — both would read 1 and both would write 2. The database is the only thing positioned to make that decision atomically, and RETURNING reports what it decided in the same round trip.

putMany() and deleteMany() run in a transaction. Duplicate batch targets and unsafe values are rejected before it opens, so a batch that cannot succeed never holds locks while discovering that.

Writes are unconditional: the last committed write wins. The version is real metadata, computed atomically, but there is no compare-and-swap API and no lost-update prevention is claimed.

Errors

Every failure is normalized into the core's exception family, with the driver's exception preserved as getPrevious():

Exception When
StorageException any database failure — a missing table, a lost connection, a constraint violation
CorruptStoredValueException the row exists but its payload does not decode, or its metadata is unusable
UnsupportedDatabasePlatformException the connection's platform has not been verified
InvalidTableNameException a configured table name is not a plain identifier

A row that cannot be decoded is reported, never treated as missing — and never repaired or deleted. Falling through to a default would quietly serve a different value than the administrator saved, with nothing to report it.

Supported platforms

SQLite and PostgreSQL. Each has been verified by running the core package's storage contract suite against a real server of that kind — separately, because one passing is not evidence about the other. Engines differ in upsert semantics, how a driver types a BIGINT on the way back, and what a transaction holds; none of that is visible from a connection succeeding.

Anything else raises UnsupportedDatabasePlatformException on first use:

Database platform "Doctrine\DBAL\Platforms\MySQLPlatform" is not supported.
Verified platforms: SQLite, PostgreSQL.

MySQL is a good illustration of why this is a whitelist: DBAL talks to it perfectly well, but its upsert syntax is ON DUPLICATE KEY UPDATE, so this package's SQL would simply fail — and failing at the platform gate says why, where failing at the SQL would not.

Configuration

use YahyaErturan\Settings\Doctrine\DoctrineStoreOptions;

new DoctrineSettingStore(
    connection: $connection,
    codec: $yourCodec,                                  // optional; JsonValueCodec by default
    options: new DoctrineStoreOptions('app_settings'),  // optional; "settings" by default
);

Table name

A table name reaches SQL as an identifier, which no database lets you bind as a parameter. Validation is therefore a whitelist rather than an escape:

[a-z_][a-z0-9_]{0,62}

Lowercase, starting with a letter or underscore, at most 63 characters, no dots. Deliberately narrower than what any database would accept: a name outside this set is far more likely to be a configuration mistake than a deliberate choice, and the cost of being wrong is SQL injection.

Schema qualification is not supported in V1 — select the schema on the connection instead.

Codec

The default JsonValueCodec from the core package. Supply your own to change how values are represented on disk; the store uses whichever it is given for both encoding and decoding.

Testing

composer test           # unit tests, no database needed
composer test:sqlite    # integration tests against real SQLite
composer test:postgres  # integration tests against real PostgreSQL

PostgreSQL needs a disposable test database:

createdb settings_test
export SETTINGS_TEST_POSTGRES_DSN='pgsql://user:password@127.0.0.1:5432/settings_test'
composer test:postgres

Without that variable the PostgreSQL tests skip with an explanation rather than failing, so a contributor without PostgreSQL can still run everything else.

The suite creates and drops tables. It refuses to run unless the database name contains test, and each test uses a uniquely named table so a crashed run leaves nothing a later run could adopt. Point the variable at a disposable database — never at production.

The test suites

Suite What it proves
Core storage contract the adapter satisfies SettingStoreInterface, run separately on each platform
Platform parity both engines store the same things the same way
Dialect upserts, transactions, corruption, and driver typing per engine
Unit table-name validation, schema description, wiring

The parity suite is written once and run against both databases, so "it works on SQLite" can never stand in for evidence about PostgreSQL.

Development

Clone both repositories as siblings:

~/Code/yahyaerturan/settings
~/Code/yahyaerturan/settings-doctrine

composer.json declares a path repository pointing at ../settings so the two develop together. Composer ignores repositories when a package is installed as somebody's dependency, so this affects local development only — the published requirement is exactly yahyaerturan/settings: ^1.0.

composer qa             # validate + lint + coding standard + static analysis + unit tests
composer test:sqlite
composer test:postgres
composer mutation       # Infection (needs pcov or xdebug; no database required)

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

License

MIT. See LICENSE.