Search by

yahyaerturan / settings-codeigniter4

yahyaerturan

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

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:39:28 UTC


README

PHP CodeIgniter License

CodeIgniter 4 integration for yahyaerturan/settings — scoped, versioned, runtime-changeable application settings, stored on the connection your application already has.

Contents

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.