yahyaerturan / settings-doctrine
Doctrine DBAL persistence for yahyaerturan/settings. Stores per-scope setting overrides in SQLite or PostgreSQL, with atomic upserts and no runtime schema changes.
Requires
- php: ^8.5
- doctrine/dbal: ^4.4
- yahyaerturan/settings: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35.4
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^13.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Doctrine DBAL persistence for yahyaerturan/settings.
| Database | Status |
|---|---|
| SQLite | verified against the core contract suite |
| PostgreSQL | verified against the core contract suite |
| anything else | refused explicitly |
Contents
- What this is
- Install
- Usage
- Schema
- How writes work
- Errors
- Supported platforms
- Configuration
- Testing
- Development
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 readsDATABASE_URL, a.envfile, 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.