Search by

nvl / settings

nicolas_vls

Standalone settings package

v2.2.1 2026-09-25 21:26 UTC

This package is auto-updated.

Last update: 2026-09-26 07:32:20 UTC


README

← NVL Laravel Suite

For support, open an issue. For vulnerabilities, use private reporting. See Contributing.

See the installation and publishing guide for Composer setup, configuration, migration ownership, and agent skills.

Quick reference

Item Value
Installed through composer require nvl/settings:^2.0
Module identifier nvl/settings
PHP namespace Nvl\Settings
Service provider Nvl\Settings\Providers\SettingsServiceProvider
Configuration config/settings.php

A source-defined, typed runtime settings engine for Laravel applications.

Purpose

nvl/settings keeps definitions in source control and stores runtime overrides plus synchronization metadata in the database. It provides deterministic discovery, validation, effective-value resolution, caching, optimistic concurrency, after-commit events, safe Laravel config overrides, and an optional authorized management API.

It does not provide per-user preferences, secrets management, arbitrary key/value storage, localized content, tenant ownership, or application UI.

Requirements and dependency

  • PHP 8.4 or newer
  • Laravel 13
  • nvl/core for public DTO and generated TypeScript contracts

Installation

composer require nvl/settings:^2.0
php artisan migrate
php artisan vendor:publish --tag=settings-config

Package discovery registers SettingsServiceProvider. Migrations load automatically unless settings.migrations.enabled is false.

php artisan vendor:publish --tag=settings-skills

Choose exactly one migration owner. For automatic vendor loading, leave settings.migrations.enabled=true and do not publish settings-migrations. For host-owned migrations, run php artisan vendor:publish --tag=settings-migrations, set settings.migrations.enabled=false before the first migration, and maintain the copied files as application migrations. Never run both sources; Laravel retimestamps published migrations.

Define settings

Put *.settings.php or *.settings.json files in configured discovery paths. Both formats compile into the same validated definition model.

PHP sources are trusted executable configuration and may use enum and deterministically stringable or serializable Laravel rule objects. Closures are rejected because their semantics cannot participate in stable definition hashes:

use Nvl\Settings\Enums\SettingType;

return [
    'namespace' => 'interface',
    'settings' => [
        'theme' => [
            'type' => SettingType::Enum,
            'default' => 'light',
            'rules' => ['in:light,dark'],
            'description' => 'Default interface theme.',
            'metadata' => ['group' => 'appearance'],
        ],
        'page_size' => [
            'type' => SettingType::Integer,
            'default' => 25,
            'rules' => ['min:1', 'max:100'],
        ],
    ],
];

JSON sources use portable SettingType values and string validation rules:

{
    "namespace": "catalog",
    "scopes": {
        "listing": {
            "page_size": {
                "type": "int",
                "default": 24,
                "rules": ["min:1", "max:100"],
                "description": "Default catalog page size.",
                "metadata": {
                    "group": "pagination"
                }
            }
        }
    }
}

Definition rules always validate the root setting value. Laravel paths such as value.* are therefore not definition rules. For typed JSON collections, use the first-party root rules in JSON sources:

{
    "namespace": "notifications",
    "settings": {
        "reminder_minutes": {
            "type": "json",
            "default": [5, 15],
            "rules": ["settings_integer_list_between:1,60"]
        },
        "channel_limits": {
            "type": "json",
            "default": {"email": 10, "sms": 5},
            "rules": ["settings_integer_map_between:1,100"]
        }
    }
}

Trusted PHP definitions may use the equivalent deterministic rule objects:

use Nvl\Settings\Support\SettingsRules;

'rules' => [SettingsRules::integerListBetween(1, 60)],

Definitions may group keys under one level of scopes:

return [
    'namespace' => 'content',
    'scopes' => [
        'listing' => [
            'page_size' => [
                'type' => SettingType::Integer,
                'default' => 25,
            ],
        ],
    ],
];

Canonical keys are namespace.key or namespace.scope.key. A declared namespace must match the name.settings.php|json filename namespace. Discovery is sorted and duplicate namespaces or keys, malformed files, invalid types, unsafe segments, invalid rules, and invalid override targets fail explicitly. Arbitrarily nested canonical keys are intentionally unsupported. During adoption, flatten segments after the optional scope into one descriptive snake-case key and record every legacy-to-canonical replacement in the adoption manifest; for example, core.currency.dual_pricing.enabled may map to core.currency.dual_pricing_enabled.

Configure any number of explicit directories or directory globs:

'discovery' => [
    'paths' => [
        base_path('settings'),
        base_path('domains/*/settings'),
    ],
    'patterns' => ['*.settings.php', '*.settings.json'],
    'recursive' => true,
    'follow_links' => false,
    'maximum_files' => 1000,
    'maximum_file_bytes' => 262144,
    'maximum_json_depth' => 64,
    'cache' => true,
    // null uses bootstrap/cache/nvl-settings.php
    'cache_path' => null,
],

Files are sorted deterministically. Real paths must remain below their configured root; optional link following is limited to targets that remain inside that root. The scanner limits file count, source bytes, and JSON nesting; rejects duplicate filename namespaces across all roots; and reports invalid JSON without entering the synchronization transaction. A source checksum covers every discovered file by stable namespace and content digest, so moving an unchanged source tree does not create a false change. A custom cache path must remain below bootstrap/cache.

Supported types are string, integer, decimal, boolean, enum, date, date-time, and JSON. Dates use Y-m-d; date-times require timezone-aware ISO 8601 input and are stored in UTC without discarding provided microseconds. Scheduled validity windows use whole-second precision. Relative dates and permissive scalar coercion are rejected. Text and enum values must be valid UTF-8. JSON values must be arrays. Add nullable to a definition's rules when a runtime null is valid.

Typed Actions

The public action boundary returns Core Data DTOs:

use Nvl\Settings\Actions\GetSettingAction;
use Nvl\Settings\Actions\SetSettingAction;
use Nvl\Settings\Data\SettingMutationData;

$current = app(GetSettingAction::class)->execute('interface.theme');

$updated = app(SetSettingAction::class)->execute(
    SettingMutationData::validateAndCreate([
        'key' => 'interface.theme',
        'value' => 'dark',
        'expectedRevision' => $current->revision,
    ]),
);

The initial value has revision 0, so a race-safe first write sends expectedRevision: 0. Existing rows require their exact positive revision. Omitting the token is supported only by the lower-level programmatic Action; the management API always requires it.

Scheduled overrides are optional:

SettingMutationData::validateAndCreate([
    'key' => 'campaign.banner',
    'value' => true,
    'expectedRevision' => 0,
    'validFrom' => '2026-11-01T00:00:00+00:00',
    'validUntil' => '2026-12-01T00:00:00+00:00',
]);

Before validFrom and after validUntil, the definition fallback is the effective value and the DTO source is definition. Partial window updates are validated against the dates already stored on the row.

Available Actions are:

  • AdoptSettingsAction
  • GetSettingAction
  • GetManySettingsAction
  • ListSettingsAction
  • SetSettingAction
  • ResetSettingAction
  • ValidateSettingsSourcesAction

SettingDefinitionData, SettingMutationData, and SettingValueData describe definitions, writes, and effective values. Effective values include their source (definition or database), type, revision, definition hash, and hasOverride state, and orphan state. hasOverride is independent from the payload, so an explicitly stored nullable override remains distinguishable from reset state.

Set and reset acquire a row lock and reject stale revisions, including concurrent first writes. SettingChanged contains only identifiers and mutation metadata, never the setting value, and dispatches after commit. Its context snapshot carries optional actor type/id, request id, IP address, and user agent. The default SettingsAuditContextProvider reads bounded values from Laravel's current request before commit; applications may replace that contract for their own actor and correlation model. Canonically equivalent repeat writes are no-ops: they do not advance the revision, refresh synchronization timestamps, flush the value cache, or emit SettingChanged.

Setting change subject reference

SettingChanged exposes a value-free subject alongside its existing ID, key, revision, operation, and audit context. The stable subject shape is type: 'nvl_setting' plus the setting UUID. It is constructed by the event and is not an additional dispatch argument.

An application using nvl/activity can therefore record the setting mutation without loading the package model:

use Nvl\Activity\Facades\ActivityLog;
use Nvl\Activity\Support\ActivitySubjectReference;
use Nvl\Settings\Events\SettingChanged;

function recordSettingActivity(SettingChanged $event): void
{
    ActivityLog::recordForSubjectReference(
        subject: new ActivitySubjectReference(
            $event->subject->type,
            $event->subject->id,
        ),
        event: $event->operation,
        description: 'settings.changed',
        context: [
            'key' => $event->key,
            'revision' => $event->revision,
        ],
    );
}

The event and subject never serialize the setting value. Keep listeners idempotent because after-commit events may be handled asynchronously.

Repository convenience API

Applications that do not need mutation result DTOs may depend on SettingRepository:

$settings = app(SettingRepository::class);

$theme = $settings->get('interface.theme');
$settings->set('interface.theme', 'dark');
$settings->setMany([
    'interface.theme' => 'dark',
    'interface.page_size' => 50,
]);
$settings->forget('interface.theme');

The Setting facade mirrors this contract. Unknown keys, validation failures, cast errors, and database failures are not swallowed. Definitions own their fallbacks; get() does not accept a caller fallback.

Synchronize definitions

php artisan nvl:settings:validate
php artisan nvl:settings:validate --format=json
php artisan nvl:settings:sync --dry-run
php artisan nvl:settings:sync
php artisan nvl:settings:sync --provider=interface
php artisan nvl:settings:sync --prune
php artisan nvl:settings:list --namespace=interface --changed
php artisan nvl:settings:reset interface.theme --dry-run
php artisan nvl:settings:reset interface --force
php artisan nvl:settings:cache
php artisan nvl:settings:clear
php artisan nvl:settings:doctor --strict --format=json
php artisan nvl:settings:adopt storage/adoption/settings.json --format=json
php artisan nvl:settings:adopt storage/adoption/settings.json --apply --format=json

nvl:settings:validate performs discovery, format parsing, namespace/scope/key validation, type resolution, default-value validation, rule validation, duplicate detection, and checksum generation without reading or writing the settings table.

Validation and synchronization always rescan configured roots instead of trusting a possibly stale discovery cache. Synchronization updates type, fallback, metadata, definition hash, and sync timestamps while preserving runtime overrides when configured. Missing source definitions follow the configured orphan, delete, or ignore policy. --prune explicitly selects deletion. nvl:settings:sync is isolatable; use --isolated on multi-server deployments backed by a shared cache. Synchronization locks live rows and uses conflict-safe inserts before updating definition metadata, so a concurrent write cannot be replaced by a stale pre-transaction snapshot or move a revision backwards. The dry run exits unsuccessfully when an existing override is incompatible with its current source definition, making it safe to use as a deployment gate.

nvl:settings:cache validates and atomically replaces the source map used by runtime reads. Re-run it after files are added, moved, or removed. nvl:settings:reset treats a full key as an exact match; namespace or namespace/scope prefixes require --force when they match more than one override. Always review dry runs before reset or prune.

Optional config overrides

Definitions may explicitly target a Laravel config key:

'display_name' => [
    'type' => SettingType::Text,
    'default' => 'Example',
    'overrides' => 'app.name',
],

Overrides are disabled by default. When enabled, they apply only after a safe application boot and schema check. Denied patterns protect environment, debugging, database, cache, and settings configuration. Workers must restart after override changes because application configuration is process state. Mapped definition defaults apply even before synchronization. Scheduled validity windows are rejected for config-mapped settings because a boot-time configuration snapshot cannot activate them safely in a long-running process.

Optional management API

The API is disabled by default:

'management' => [
    'enabled' => true,
    'path' => 'api/v1/settings',
    'name' => 'nvl.settings.management.',
    'middleware' => ['api', 'auth', 'throttle:60,1'],
    'authorization_ability' => 'manage-settings',
],

The path accepts safe slash-separated URI segments. The name is a configurable route-name prefix and receives a trailing dot automatically.

Method Path Default name Purpose
GET /status nvl.settings.management.status Validate source discovery and return sanitized counts/checksum
GET / nvl.settings.management.index List definitions and effective values
GET /{key} nvl.settings.management.show Inspect one effective value
PUT /{key} nvl.settings.management.update Set a validated optimistic override
DELETE /{key} nvl.settings.management.reset Reset an override using its expected revision

GET / accepts namespace, scope, search, page, and perPage (maximum 100). It returns data.items plus data.meta; only the requested page is read from storage. PUT requires value and expectedRevision and accepts validFrom/validUntil. A first write uses revision 0; reset requires the current positive revision.

Unknown keys return 404 with error.code=unknown_setting; missing persisted overrides return 404 with error.code=setting_override_not_found; stale revisions return 409 with error.code=stale_setting_revision. All use the stable {"error":{"code":"...","message":"..."}} envelope.

Every request passes SettingsAuthorization; the default implementation fails closed until a Gate ability is configured. Applications may bind the contract for scope/key-specific policy logic. The abilities are status, list, view, set, and reset.

No management UI is included.

Database, caching, and adoption

The configured table uses UUID primary keys and unique namespace/scope/key identifiers. It stores typed value and fallback JSON, an explicit has_override flag, metadata, definition hash, revision, validity dates, synchronization and orphan timestamps, plus query indexes for scope, validity, and sync status.

The cache is optional and stores primitive attribute arrays rather than PHP objects, making it compatible with Laravel 13's hardened cache deserialization. Its default key is nvl:settings:v2. Invalidation from model saves, deletes, canonical Actions, and synchronization runs only after the outer database transaction commits. Reads inside a transaction on the settings connection bypass the shared cache, so they see that transaction’s writes without publishing uncommitted values. Cache failures and database outages are not converted into defaults.

For an existing table, disable automatic migrations during assessment and run:

php artisan nvl:settings:doctor --strict --format=json

The doctor checks the configured connection/table, required v1 columns, identifier type, indexes, duplicate identities, uncached definition discovery, cache freshness, canonical stored value encodings, and management route security without mutating state. Its schema.compatibility check explicitly distinguishes the canonical package schema from a same-name legacy table. Package migrations create the complete clean-install schema and do not mutate an unrelated existing table.

Adopt an established typed key/value store through a staging table and a versioned manifest. Dry-run is the default and performs no writes. It requires one explicit replacement for every source row, resolves every target against a source definition, decodes and validates values through the target type, checks the declared count, and rejects unknown, duplicated, missing, or colliding keys. --apply writes the complete validated set atomically and reconciles the target count. It is safe to repeat; canonical no-op writes keep their revision.

{
    "version": 1,
    "source_connection": "sqlite",
    "source_table": "legacy_settings",
    "key_column": "key",
    "value_column": "value",
    "expected_count": 2,
    "key_replacements": {
        "core.currency.dual_pricing.enabled": "core.currency.dual_pricing_enabled",
        "notifications.reminders.minutes": "notifications.reminder_minutes"
    }
}

When the legacy table is itself named settings, keep migrations disabled, run Doctor, rename the legacy table to an explicit staging name, create the canonical package schema, then run the plan and apply phases. The adoption command refuses to read from the configured canonical target table. Manifest size and record limits are controlled by settings.adoption.*.

TypeScript

Tenant ownership

Definitions remain immutable platform source. A definition must opt in with tenant_override=true before a tenant may store a value. Adopt existing rows through the Settings tenancy adapter; mixed platform/tenant identities use the persisted ownership discriminator, and cache invalidation uses the captured row identity rather than ambient worker context.

php artisan nvl:data:types:generate
php artisan nvl:data:types:check

Declarations use Nvl.Settings.*.

Development

composer install
composer quality

The suite covers discovery, duplicate detection, strict codecs, nullable overrides, effective sources, idempotent writes, synchronization/orphans, malformed and serialized caches, after-commit invalidation, rollback-safe events, bounded bulk reads, stale writes, config overrides, authorization, API errors, routes, and adoption checks.

See UPGRADING.md, SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md.

License

Released under the MIT License.