yahyaerturan / settings-codeigniter4
CodeIgniter 4 adapter for the yahyaerturan/settings engine: a CI-native store, service wiring, config-backed defaults, a request-aware context factory and a PSR-16 cache bridge.
Package info
github.com/yahyaerturan/settings-codeigniter4
pkg:composer/yahyaerturan/settings-codeigniter4
Requires
- php: ^8.5
- codeigniter4/framework: ^4.7
- 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
CodeIgniter 4 integration for yahyaerturan/settings
— scoped, versioned, runtime-changeable application settings, stored on the connection your
application already has.
Contents
- What this is
- Install
- Create the table
- Wire it up
- Using it
- Scopes and the context factory
- Definitions
- Defaults from a config class
- Caching
- Transactions
- Spark commands
- Configuration reference
- Supported platforms
- Errors
- Testing
- Development
- License
What this is
The core package is framework-agnostic: it knows nothing about requests, sessions, users or databases. This package supplies the three things a CodeIgniter application needs to use it — a store on CI4's database layer, a cache bridge to CI4's cache, and the service wiring — plus the one seam only your application can fill: turning the current request into a scope chain.
Configuration is not settings. Config\* classes hold what the deployment decides at
boot: credentials, paths, feature flags baked into a release. Settings are what changes at
runtime, per scope, without a deploy. This package keeps the two distinct and lets a config
class act as the defaults beneath the settings, which is the only place they should meet.
Why not just use the Doctrine adapter?
A CodeIgniter application already has a configured database layer. Adding Doctrine DBAL beside it means a second connection, a second set of credentials, and — the part that matters — a settings write that cannot join a transaction your controller has already opened. This store uses the connection you hand it, so a settings change commits or rolls back with everything else in the request.
Install
composer require yahyaerturan/settings-codeigniter4
Requires PHP 8.5+, CodeIgniter 4.7+, and one of SQLite or PostgreSQL.
Installing this package raises your application's PHP floor to 8.5. CodeIgniter 4.7 itself runs on PHP 8.2+, but Composer will refuse to install this package below 8.5. The floor is a hard requirement, not caution: the codebase leans on
#[\NoDiscard], and PHP 8.4 does not reject an unknown attribute — it ignores it. A lower floor would not fail, it would silently switch the safety net off.
Create the table
Nothing here creates schema on its own. A library issuing DDL at boot means every application process racing to define production.
php spark settings:schema:create
Or lift the SQL into the migration workflow you already have:
php spark settings:schema:create --dry-run
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 DDL is identical on both supported platforms by design. updated_at is a fixed-width
RFC 3339 UTC string rather than a native timestamp, so microsecond precision survives on both.
The table name is not subject to DBPrefix — this package never uses the query builder,
so the table is called exactly what Config\Settings::$table says.
Wire it up
The package ships services named yahyaSettings*, because CodeIgniter discovers service
methods globally and two packages defining settings() would collide.
Out of the box you get a working engine with an empty registry and no defaults. That
is deliberate: a settings engine that invented your definitions would be guessing. Override
the two that matter in your application's own app/Config/Services.php:
<?php namespace Config; use CodeIgniter\Config\BaseService; use YahyaErturan\Settings\CodeIgniter4\Defaults\ConfigDefaultProvider; use YahyaErturan\Settings\Definition\SettingDefinition; use YahyaErturan\Settings\Definition\SettingRegistry; use YahyaErturan\Settings\Contract\DefaultProviderInterface; class Services extends BaseService { public static function yahyaSettingsRegistry(bool $getShared = true): SettingRegistry { if ($getShared) { return static::getSharedInstance('yahyaSettingsRegistry'); } return new SettingRegistry([ SettingDefinition::string('app.locale')->withDefault('en'), SettingDefinition::boolean('mail.enabled')->withDefault(true), SettingDefinition::integer('billing.retry.limit')->withDefault(3), SettingDefinition::string('mail.reply.to')->nullable(), ]); } public static function yahyaSettingsDefaults(bool $getShared = true): DefaultProviderInterface { if ($getShared) { return static::getSharedInstance('yahyaSettingsDefaults'); } return new ConfigDefaultProvider(config(AppSettings::class), [ 'app.locale' => 'locale', 'mail.enabled' => 'mailEnabled', ]); } }
Using it
use YahyaErturan\Settings\Value\SettingScope; $settings = service('yahyaSettings'); $context = service('yahyaSettingsContextFactory')->forCurrentRequest(); // Read: walks the context, then global, then defaults. $locale = $settings->get('app.locale', $context); // Write: targets exactly one scope. Nothing is inferred. $settings->set('app.locale', 'tr', SettingScope::of('workspace', 'w_42')); // Remove the override at that one scope. Lower scopes are untouched. $settings->reset('app.locale', SettingScope::of('workspace', 'w_42'));
Reads and writes are deliberately asymmetric. A read walks a chain because that is what "effective value" means; a write must name one scope, because "save this setting" without saying where is the ambiguity that makes settings systems go wrong.
Bulk operations are first-class and validate the whole batch before mutating anything:
$settings->setMany([ 'app.locale' => 'tr', 'mail.enabled' => false, ], SettingScope::of('workspace', 'w_42')); $values = $settings->getMany(['app.locale', 'mail.enabled'], $context);
null is a legitimate value and never means "missing":
$settings->set('mail.reply.to', null, SettingScope::global()); $settings->has('mail.reply.to', $context); // true — the setting resolves $settings->isOverridden('mail.reply.to'); // true — a row exists $settings->get('mail.reply.to', $context); // null — the stored value
Scopes and the context factory
Scopes are yours. The package gives no special meaning to tenant, organization,
workspace or user — a scope is a (type, id) pair, and the order you supply is the
precedence.
The one built-in is the canonical global scope, ('global', '*'), and it is stored as an
ordinary row like any other.
GlobalSettingContextFactory ships as the default, resolving global then defaults. Replace it
as soon as you have scopes worth walking:
<?php namespace App\Settings; use YahyaErturan\Settings\CodeIgniter4\Context\SettingContextFactory; use YahyaErturan\Settings\Value\SettingContext; use YahyaErturan\Settings\Value\SettingScope; final class RequestSettingContextFactory implements SettingContextFactory { #[\NoDiscard] public function forCurrentRequest(): SettingContext { $user = auth()->user(); if ($user === null) { return SettingContext::empty(); } // Most specific first. Nothing infers this order. return SettingContext::fromScopes( SettingScope::of('user', (string) $user->id), SettingScope::of('workspace', (string) $user->workspace_id), SettingScope::of('organization', (string) $user->organization_id), ); } }
Then register it:
public static function yahyaSettingsContextFactory(bool $getShared = true): SettingContextFactory { return $getShared ? static::getSharedInstance('yahyaSettingsContextFactory') : new RequestSettingContextFactory(); }
This is the only place in the stack where knowing about "the current user" is appropriate. The core engine is never handed a request — it is handed an explicit context.
Definitions
In strict mode (the default) every key must be registered, so a typo fails immediately instead of silently resolving to nothing:
$settings->get('app.locael', $context); // UndefinedSettingException
Definitions carry a type, nullability, an optional default and optional validators:
SettingDefinition::integer('billing.retry.limit')->withDefault(3); SettingDefinition::string('mail.reply.to')->nullable();
The builder methods are immutable and carry
#[\NoDiscard], so$definition->nullable();on its own raises a warning rather than doing nothing quietly.
Set Config\Settings::$strict = false only for a genuinely open-ended key space, such as a
plugin system.
Defaults from a config class
A CI4 config class is a bag of public properties — the natural place for shipped defaults, and
it already picks up .env overrides and Registrar contributions on the way in.
final class AppSettings extends BaseConfig { public string $locale = 'en'; public bool $mailEnabled = true; } new ConfigDefaultProvider(config(AppSettings::class), [ 'app.locale' => 'locale', 'mail.enabled' => 'mailEnabled', ]);
The mapping is explicit on purpose. Deriving mail.from.address from a property name means
inventing a convention — mailFromAddress? mail_from_address? a nested array? — and every
such convention is ambiguous in one direction. One line per setting removes the guesswork.
A setting key is atomic even though it contains dots: mail.from.address is one name, never a
path into a nested array. Only public properties are visible; a mapping to a property that does
not exist contributes no default, which is different from a property holding null.
Caching
Off by default. Turn it on in Config\Settings:
public bool $cache = true; public int $cacheTtlSeconds = 300; public string $cacheNamespace = 'production';
Reads then go through the core's cache decorator, backed by CodeIgniter's own cache via
Psr16CacheBridge.
What is cached is the storage lookup, not the effective value — whether a row exists for a
(scope, key) pair, including when it does not. Negative results are the common case and
would otherwise hit the database on every request. Because resolved values are never cached,
changing a default and deploying takes effect immediately.
Cache failures never fail an authoritative write: the database is the source of truth, and a cache that cannot be reached is a slow request, not a lost setting.
There is deliberately no "cache forever". PSR-16 offers no cross-process coordination, so an invalidation that fails leaves an entry stale until it expires — the TTL is what bounds that window.
cacheNamespace separates applications sharing one cache backend. It is not tenancy;
scope carries that, and every scope already participates in the cache key.
Transactions
The store uses CodeIgniter's depth-aware transStart()/transComplete(), so a settings write
joins a transaction the application has already opened:
$db = db_connect(); $db->transBegin(); $order->place(); service('yahyaSettings')->set('billing.retry.limit', 5, SettingScope::of('workspace', 'w_42')); $db->transRollback(); // the settings write is rolled back too
This is verified on both platforms in the parity suite.
Spark commands
php spark settings:schema:create # create the table php spark settings:schema:create --dry-run # print the SQL, touch nothing php spark settings:schema:create --group=reporting php spark settings:schema:status # exit 0 if present, 1 if not
settings:schema:create is idempotent — running it again reports that the table exists rather
than failing, which is what a deployment script needs. settings:schema:status names the
platform it is looking at, which is the fastest way to find out why a store refused a
connection.
Both commands read --group only from the parameters they were dispatched with, never from the
host process's command line, so calling them programmatically cannot be steered onto another
database by an unrelated argument.
Configuration reference
Publish Config\Settings into your application (or override values via .env):
| Property | Default | What it does |
|---|---|---|
$databaseGroup |
'default' |
Which CI4 database group the store uses. Leave it on the application's own group to share its transactions. |
$table |
'settings' |
Table holding overrides. Must match [a-z_][a-z0-9_]{0,62}. Not affected by DBPrefix. |
$strict |
true |
Whether every key must be registered. |
$cache |
false |
Whether reads go through the PSR-16 cache decorator. |
$cacheTtlSeconds |
300 |
How long a cached storage lookup stays valid. Must be positive. |
$cacheNamespace |
'default' |
Separates applications sharing a cache backend. |
Anything with structure — which settings exist, where defaults come from, how a context is built — is customised by overriding a service, not by growing this config into a miniature DI container.
Supported platforms
| Platform | Status |
|---|---|
| SQLite3 | verified against the core storage contract suite |
| Postgre | verified against the core storage contract suite |
| MySQLi / others | refused at construction |
MySQL spells its upsert differently (ON DUPLICATE KEY UPDATE) and would fail against this
SQL. Rather than attempt it, the store refuses at the gate where the reason can be stated:
UnsupportedDatabasePlatformException: Database platform "MySQLi" is not supported. Verified platforms: SQLite3, Postgre.
Adding a platform means running the contract suite against a real server of that kind — not reading the docs and hoping.
Errors
| Exception | When |
|---|---|
UndefinedSettingException |
strict mode, key not registered |
InvalidSettingKeyException |
key does not match the grammar |
InvalidSettingValueException |
value fails its definition, or is not JSON-safe |
SettingNotFoundException |
nothing resolved and no default exists |
StorageException |
the database rejected the operation, or a batch repeats a target |
CorruptStoredValueException |
a row exists but does not decode |
UnsupportedDatabasePlatformException |
the connection is not SQLite3 or Postgre |
InvalidTableNameException |
configured table name fails the whitelist |
InvalidCacheKeyException |
a cache key is malformed (implements PSR-16's marker) |
All extend the core's SettingsException marker. Exception messages name the key and scope,
never the value.
Corruption is never treated as absence: a row that fails to decode raises rather than falling through to a default, because silently substituting a default for data you cannot read is how a settings bug becomes a billing incident.
Testing
The adapter is measured against the core package's own storage contract suite, which ships in
yahyaerturan/settings — so this store is held to exactly the same behaviour as any other,
rather than to a re-typed approximation.
composer test # unit, no database composer test:sqlite # real SQLite through CI4's driver composer test:postgres # real PostgreSQL through CI4's driver
In your own application, use whatever store you like in tests — the engine depends on
SettingStoreInterface, not on this class.
Development
See CONTRIBUTING.md for the quality gates, the rules specific to a framework integration, and the PHP 8.5 conventions this codebase enforces.
License
MIT. See LICENSE.