Laravel Packagev12.0.0MIT License

Settings

Settings stores typed application settings in your database, for the whole application, a named scope such as checkout, or any saved Eloquent model such as a company, branch, or user. Every row is a real Setting Eloquent model that keeps its type, so an integer stays an integer, a boolean never turns into "1", and a stored null is still a stored value. Reads fall back from one scope to another only when you ask them to.

Typed Values

Each setting keeps its type and nullability, and a write never changes them silently.

Global, Named and Model Scopes

One identifier column addresses the global scope, any name, or any saved model.

Explicit Fallback

Branch, then company, then global, in the order you choose, with no hidden parent lookups.

Plain Eloquent

Setting is a normal Eloquent model, so get, where and orderBy work as usual.

Definitions and Validation

Declare the type, default and Laravel validation rules of important keys once.

Transaction-Safe Cache

Each scope is cached on its own and forgotten only after your transaction commits.

Settings or Dynamic Config?

Settings is for application preferences that your code reads with getValue(). To change Laravel's own config() values at runtime, use Laravel Dynamic Config. The two packages can run side by side and never touch each other's table.

#Installation

Install the package with Composer and create its table.

#Requirements

  • PHP 8.2, 8.3, 8.4 or 8.5
  • Laravel 12.x

#Composer Installation

Terminal
composer require pharaonic/laravel-settings

The service provider is registered automatically. It also defines the settings() helper, unless your application or another package already defines a function with that name.

#Run Migrations

The package loads its migration automatically, so the settings table is created with your other migrations:

Terminal
php artisan migrate

To change the migration before it runs, publish it into database/migrations:

Terminal
php artisan vendor:publish --tag=settings-migrations
Upgrading from 1.x

If your application already has the settings table from version 1.x, migrate does not touch it: the migration keeps its 1.x file name, so it never runs again. Convert the table with php artisan settings:migrate-legacy, as described in Upgrading from 1.x.

#Publish Configuration

Publish the configuration when you need another connection or table, a cache setup, the safe casting policy, or definitions:

Terminal
php artisan vendor:publish --tag=settings-config

The file is published to config/pharaonic/settings.php.

Installation Complete

You're all set! Store your first value with settings()->setValue('site.name', 'My Site').

#Configuration

Every option lives in config/pharaonic/settings.php and is read as pharaonic.settings.*.

config/pharaonic/settings.php
1return [
2 'connection' => env('SETTINGS_DB_CONNECTION'),
3 'table' => env('SETTINGS_DB_TABLE', 'settings'),
4 
5 'types' => [
6 'casting' => 'strict', // strict or safe
7 ],
8 
9 'cache' => [
10 'enabled' => env('SETTINGS_CACHE', true),
11 'store' => env('SETTINGS_CACHE_STORE'),
12 'ttl' => null,
13 ],
14 
15 'models' => [
16 'purge_on_force_delete' => false,
17 ],
18 
19 'definitions' => [],
20];
OptionDefaultDescription
connectionnullThe database connection of the table. null uses the default connection.
tablesettingsThe table name. The migration and every query use it.
types.castingstrictstrict or safe (or a CastingPolicy case): how a value that is not already of a setting's type is handled. See Typed Values.
cache.enabledtrueCache each scope's rows.
cache.storenullThe cache store. null uses the default store.
cache.ttlnullSeconds an entry lives. null keeps it until the next write to the scope.
models.purge_on_force_deletefalseDelete a model's settings when HasSettings sees it deleted permanently.
definitions[]Optional definitions of important keys. See Definitions and Validation.

#The Global Scope

The global scope is stored as the identifier __global__, available as Setting::GLOBAL. It is not configurable: changing it would orphan every global row.

1use Pharaonic\Laravel\Settings\Models\Setting;
2 
3Setting::for(); // global
4Setting::for(null); // global
5Setting::for(Setting::GLOBAL); // global
6Setting::for('__global__'); // global, the same string
Reserved identifier

PHP cannot tell Setting::GLOBAL from the string '__global__', so that string always means the global scope and cannot name any other scope.

Config cache

Keep the file free of closures and objects. Definitions are plain arrays, so php artisan config:cache works.

#Basic Usage

settings($scope) returns a query on the Setting model scoped to one identifier, the same as Setting::for($scope). Its value methods read and write typed values.

1settings()->setValue('site.name', 'Pharaonic');
2settings()->getValue('site.name'); // "Pharaonic"
3settings()->getValue('site.tagline', 'Welcome'); // "Welcome": not stored
4settings()->hasValue('site.name'); // true
5settings()->forgetValue('site.name'); // true: a row was deleted

Writes go to the database right away and return the saved Setting row:

1$setting = settings('checkout')->setValue('minimum', 100);
2 
3$setting->type; // SettingType::INTEGER
4$setting->value; // 100
5$setting->nullable; // false

#Several Values at Once

setMany() stores every value in one transaction, or none of them:

1settings()->setMany([
2 'site.name' => 'Pharaonic',
3 'site.locales' => ['ar', 'en'],
4 'site.maintenance' => false,
5]);

It takes an array keyed by setting keys. A list such as ['a', 'b'], or any integer key, is refused with InvalidSettingKeyException; use setValue() for a numeric key.

#All Values

1settings()->allValues();
2// ['site.locales' => ['ar', 'en'], 'site.maintenance' => false, 'site.name' => 'Pharaonic']

The array is flat and sorted by key. Dotted keys are exact strings: mail.from and mail.from.address are two separate settings, never merged into nested arrays.

#Lazy Defaults

A closure default is only called when no scope stores the key. A stored null is a value, so the default is not used:

1settings()->getValue('report.limit', function () {
2 return expensiveDefault();
3});

#Keys

A key is 1 to 191 UTF-8 characters, and may use dots, but not empty segments: .x, x., and x..y throw InvalidSettingKeyException.

#Scopes

Every setting belongs to one scope, stored in the identifier column.

ScopeCallStored identifier
Globalsettings(), settings(null), Setting::for()__global__
Namedsettings('checkout')checkout
Modelsettings($company){morph class}:{key}, such as App\Models\Company:12

Scopes never share rows: the same key can hold a different value in each one, and a read only sees its own scope unless you add fallbacks.

#Model Scopes

A model scope uses the model's morph class and key, so it honors your morph map:

app/Providers/AppServiceProvider.php
1use Illuminate\Database\Eloquent\Relations\Relation;
2 
3Relation::morphMap(['company' => \App\Models\Company::class]);
1settings($company)->setValue('timezone', 'Africa/Cairo');
2 
3settings($company)->getIdentifier(); // "company:12"
4settings('company:12')->getValue('timezone'); // "Africa/Cairo": the same scope

Integer, string and UUID keys all work, including 0. An unsaved model, or one without a string or integer key, throws InvalidIdentifierException.

Changing the morph map

Adding or changing a morph map alias changes the identifier of that model's scope, so its stored settings are no longer found. Keep aliases stable, or rename the stored identifiers in the same deploy.

#Identifiers

An identifier is 1 to 191 UTF-8 characters. Identifiers are compared exactly, including case: Checkout and checkout are two scopes, on MySQL and MariaDB too (the columns use a binary collation).

An identifier is not a permission

settings('company:12') reads company 12's settings for anyone who knows the string. Never build an identifier from request input without checking that the user may access that scope.

#HasSettings

Add HasSettings to any Eloquent model whose records have their own settings.

app/Models/Company.php
1namespace App\Models;
2 
3use Illuminate\Database\Eloquent\Model;
4use Pharaonic\Laravel\Settings\Concerns\HasSettings;
5 
6class Company extends Model
7{
8 use HasSettings;
9}
1$company->setSetting('theme', 'dark'); // Setting row, saved right away
2$company->getSetting('theme', 'light'); // "dark"
3$company->hasSetting('theme'); // true
4$company->allSettings(); // ['theme' => 'dark']
5$company->forgetSetting('theme'); // true
6 
7$company->settings()->get(); // Eloquent collection of Setting models
8$company->settings()->getValue('theme'); // the same as getSetting()

settings() returns Setting::for($this), so every value method works on it, fallbackTo() included.

#What the Trait Never Does

  • It is not a relation. with('settings'), whereHas('settings'), and $company->settings (without parentheses) throw; use $company->settings().
  • It never queries on its own. Loading or saving the model runs no settings query.
  • It never waits for save(). setSetting() writes immediately, even when the model has unsaved changes.

#Deleting Models

DeleteSettings
Soft delete (delete() with SoftDeletes)Kept
RestoreStill there
Permanent delete (forceDelete(), or delete() without SoftDeletes)Kept, unless models.purge_on_force_delete is true

With purging on, the settings are deleted inside the transaction that deletes the model, so a rollback keeps both.

Mass deletes

Company::query()->where(...)->delete() fires no model events, so purging cannot see it. Delete the settings of those models before the mass delete, such as $company->settings()->get()->each->delete(), so the cache is forgotten as well.

#Typed Values

Every row stores a type and a nullable flag next to its value.

TypePHP valueEnum case
stringstringSettingType::STRING
integerintSettingType::INTEGER
floatfinite floatSettingType::FLOAT
booleanboolSettingType::BOOLEAN
arrayarray of the above, nested up to 63 levelsSettingType::ARRAY

Pharaonic\Laravel\Settings\Enums\SettingType is a string-backed enum. Every method that takes a type accepts the case or its value (SettingType::INTEGER or 'integer'), and $setting->type returns the case:

1$setting = settings()->setValue('count', 20);
2 
3$setting->type; // SettingType::INTEGER
4$setting->type === SettingType::INTEGER; // true
5$setting->getAttributes()['type']; // "integer", the stored value

An untyped first null has null as its type.

#How a New Setting Gets Its Type

In this order: the $type argument, then the key's definition, then the first value.

1use Pharaonic\Laravel\Settings\Enums\SettingType;
2 
3settings()->setValue('enabled', true); // boolean
4settings()->setValue('count', 20); // integer
5settings()->setValue('price', 19.99); // float
6settings()->setValue('title', 'Pharaonic'); // string
7settings()->setValue('locales', ['ar']); // array
8settings()->setValue('limit', null, SettingType::INTEGER); // integer, nullable

#Null

A first null without a type is stored untyped and nullable. The first non-null value then fixes the type, and the setting stays nullable:

1settings()->setValue('unknown', null); // type null, nullable
2settings()->setValue('unknown', 5); // type integer, still nullable
3settings()->setValue('unknown', 'x'); // SettingTypeMismatchException
4settings()->setValue('unknown', null); // allowed

A setting created with a non-null value is not nullable. Pass nullable: true (the fourth argument) to allow null later; nullable: false with a null value is refused.

#The Type Never Changes on a Write

1settings()->setValue('count', 20);
2settings()->setValue('count', 21); // fine
3settings()->setValue('count', '21'); // SettingTypeMismatchException (strict policy)
4settings()->setValue('count', 21.0); // SettingTypeMismatchException
5settings()->setValue('count', 21, SettingType::FLOAT); // SettingTypeMismatchException

Change the type deliberately with retypeValue(). It locks the row, converts the stored value with the safe rules below, and saves type, nullability and value together, or nothing:

1settings()->setValue('limit', '20'); // string
2settings()->retypeValue('limit', SettingType::INTEGER); // integer 20
3settings()->retypeValue('limit', SettingType::STRING, true); // "20", nullable
4settings()->retypeValue('label', SettingType::INTEGER); // "twenty": SettingTypeMismatchException

#Casting Policies

The strict policy, the default, accepts only values already of the setting's type. The safe policy also converts these cases, and refuses everything else:

TypeSafe conversionsStill refused
integercanonical decimal strings in range: "20", "-7", "0""20.5", "020", " 20", "+20", "-0", "1e3", overflow, 20.0, true
floatintegers up to 2^53; decimal strings of at most 15 significant digits: "19.99", "20"exponents, longer decimals, "abc"
boolean"true", "false", "1", "0", 1, 0"TRUE", "yes", "", 2
stringintegers (42 becomes "42"), finite floats (1.5 becomes "1.5", 2.0 becomes "2.0")booleans, arrays
arraynoneanything but an array

Enable it for every key, or per key in a definition:

config/pharaonic/settings.php
1'types' => [
2 'casting' => 'safe',
3],
PHP's loose casts are never used

(bool) "false" is true in PHP and (int) "20.5" is 20, so neither is a conversion rule here.

#Values That Cannot Be Stored

Objects, closures, resources, INF, NAN, invalid UTF-8 (in strings or array keys), and arrays nested deeper than 63 levels throw UnsupportedSettingValueException. Its message names the key, the scope and the type, never the value.

#Storage

Values are stored as JSON. Strings that look like JSON stay strings, and JSON objects come back as associative arrays, never stdClass:

1settings()->setValue('raw', '{"a":1}');
2settings()->getValue('raw'); // '{"a":1}', a string
3 
4settings()->setValue('map', ['a' => 1]);
5settings()->getValue('map'); // ['a' => 1]

#Definitions

Settings work without definitions. For important keys, a definition fixes the type, nullability, default, casting policy and validation rules in every scope: global, named and model.

config/pharaonic/settings.php
1'definitions' => [
2 'checkout.minimum_order' => [
3 'type' => 'float',
4 'nullable' => false,
5 'default' => 0.0,
6 'casting' => 'safe',
7 'rules' => ['min:0'],
8 ],
9 'notifications.email' => [
10 'type' => 'boolean',
11 'default' => true,
12 ],
13],
OptionRequiredDescription
typeyesA SettingType case, or its value: string, integer, float, boolean or array.
nullablenoDefault false.
defaultnoReturned when no scope stores the key. Must be of type (or null when nullable). Never written to the table.
castingnoA CastingPolicy case, or strict or safe; overrides types.casting for this key.
rulesnoLaravel validation rules, as an array or a `

Definition keys are the exact setting keys, so write them as literal array keys, not nested arrays.

#Defaults

1settings()->getValue('checkout.minimum_order'); // 0.0: the definition default
2settings()->getValue('checkout.minimum_order', 5.0); // 5.0: your default wins
3settings()->hasValue('checkout.minimum_order'); // false: defaults are not stored

A default is used only when no scope of the fallback chain stores the key. A stored null is a value, so it wins over every default.

#Validation

The value is type-checked (or safely cast) first, then validated with Laravel's validator. The type's own rule (integer, numeric, boolean, string or array) is added before yours, so size rules such as max:5 compare numbers as numbers:

1settings()->setValue('checkout.minimum_order', -1.0);
2// Illuminate\Validation\ValidationException: "The checkout.minimum_order must be at least 0."

Errors are reported under the value key, with the setting key as the attribute name. Rules are skipped for a stored null; nullability is already enforced by the type.

#When a Stored Row Contradicts Its Definition

A row stored before you added the definition may have another type or nullability. Reading or writing it throws SettingDefinitionException instead of returning a wrongly typed value. Fix the row with retypeValue():

1settings()->retypeValue('checkout.minimum_order', 'float', false);

#Refused Definitions

A definition without a valid type, with an unknown option, a default of another type, or encrypted throws SettingDefinitionException when the key is used. Encrypted settings are not supported yet, so they are refused rather than stored as plaintext.

#Fallback

A read only sees its own scope until you add fallback scopes with fallbackTo(). They are tried in the order you add them.

1$theme = settings($branch)
2 ->fallbackTo($company)
3 ->fallbackTo(null) // the global scope
4 ->getValue('theme', 'light');

The order is branch, then company, then global, then your default (or the definition's default). The package never finds a parent on its own: there is no relation lookup and no automatic global fallback.

fallbackToGlobal() is the same as fallbackTo(null) and fallbackTo(Setting::GLOBAL), and reads better at the end of a chain:

1$theme = settings($branch)
2 ->fallbackTo($company)
3 ->fallbackToGlobal()
4 ->getValue('theme', 'light');

#Missing Versus Null

A missing key moves on to the next scope. A stored null is present, so it stops the chain:

1settings()->setValue('theme', 'blue');
2settings($company)->setValue('theme', null);
3 
4settings($branch)->fallbackTo($company)->fallbackTo(null)->getValue('theme', 'light'); // null

#Own Values

The Own methods ignore the fallbacks and read the for() scope only:

1$chain = settings($branch)->fallbackTo($company);
2 
3$chain->getOwnValue('theme'); // the branch's value, or the default
4$chain->hasOwnValue('theme');
5$chain->allOwnValues();

#All Values

allValues() overlays the chain: for each exact key the earliest scope wins.

1settings()->setMany(['a' => 'global', 'b' => 'global']);
2settings($company)->setValue('b', 'company');
3 
4settings($company)->fallbackToGlobal()->allValues(); // ['a' => 'global', 'b' => 'company']

#Explaining a Value

explainValue() tells you where a value comes from:

1settings($branch)->fallbackTo($company)->fallbackTo(null)->explainValue('branding.primary_color');
2// [
3// 'key' => 'branding.primary_color',
4// 'found' => true,
5// 'value' => '#0EA5E9',
6// 'source' => 'company:12',
7// 'inherited' => true, // found in a fallback scope
8// 'is_default' => false,
9// 'type' => 'string',
10// 'nullable' => false,
11// ]

When no scope stores the key, found is false, source is null, and value, type and nullable come from the definition (is_default is true when it has a default), or are null.

#Rules of the Chain

  • A scope already in the chain is skipped, so the order is first-seen.
  • The chain belongs to its builder: settings($branch) called again starts without fallbacks.
  • Writes (setValue(), forgetValue(), …) only ever touch the for() scope.
  • getFallbackChain() returns the identifiers in order.

#Eloquent Model

Pharaonic\Laravel\Settings\Models\Setting is a regular Eloquent model, and Setting::query() returns its SettingBuilder. Nothing Eloquent offers is changed: get(), first() and the rest return Setting models.

1use Pharaonic\Laravel\Settings\Models\Setting;
2 
3Setting::for()->get(); // the global rows
4Setting::for('checkout')->where('type', 'integer')->get();
5Setting::for($company)->orderBy('key')->pluck('key');
6Setting::query()->where('identifier', Setting::GLOBAL)->count();
ColumnDescription
identifierThe scope, never NULL; __global__ for the global scope.
keyThe exact key. Unique with identifier.
typestring, integer, float, boolean, array, or NULL for an untyped first null.
nullableWhether the setting accepts null.
valueThe JSON of the value.

$setting->value returns the typed value; $setting->getAttributes()['value'] is the stored JSON.

#Value Methods Need a Plain Scope

The value methods only run on a plain for() scope, optionally with fallbackTo() and orderBy(). Anything else throws UnscopedSettingQueryException, because the value methods would otherwise ignore it:

1Setting::query()->getValue('x'); // no for(): refused
2Setting::for()->where('type', 'string')->getValue('x'); // extra where(): refused
3Setting::for()->where('type', 'string')->get(); // fine: plain Eloquent

The same goes for limit(), select(), joins, distinct(), eager loading, global scopes, and a query on another connection (Setting::on(...)).

#Saving the Model Directly

A native save follows the same rules as setValue(): the value is checked (or safely cast) against the type, definitions and rules apply, and the scope's cache is forgotten after the commit.

1$setting = Setting::for()->where('key', 'site.count')->firstOrFail();
2$setting->value = 4;
3$setting->save();
4 
5Setting::create(['identifier' => 'checkout', 'key' => 'minimum', 'value' => 100]);

#Creating Through a Scope

A model made by a scoped builder belongs to that scope. make(), create(), firstOrNew(), firstOrCreate(), updateOrCreate() and forceCreate() all store in the for() scope, and an identifier attribute that names another scope throws InvalidIdentifierException, even when it is set later on the model before save():

1$company->settings()->create(['key' => 'theme', 'value' => 'dark']); // the company scope
2settings('checkout')->firstOrCreate(['key' => 'minimum'], ['value' => 100]);
3 
4settings('tenant-a')->create(['identifier' => 'tenant-b', 'key' => 'x', 'value' => 1]); // throws

The type and nullability of a stored row can only change through $setting->retype($type, $nullable) (what retypeValue() calls), and its identifier and key cannot change at all.

Query-builder writes bypass the package

Setting::query()->update([...]), DB::table('settings'), and raw SQL skip the type checks, definitions and cache invalidation. Scopes cached before such a write keep their old rows until the next package write to the scope, or until cache.ttl expires.

Concurrency

Writes run in a transaction under a row lock. A new key is inserted inside a savepoint; when another request inserts the same key first, the unique (identifier, key) index refuses the second row, and the write validates against the row that won. So two requests can never leave two rows, or flip a type. A plain $setting->save() validates against the state it loaded, without a lock; use setValue() when requests may race.

#Caching

Each scope's rows are cached as one entry: the global scope, every named scope, and every model scope separately. A fallback chain reads each of its scopes from its own entry, so a change to the company scope is seen by every branch that falls back to it, without invalidating the branches.

config/pharaonic/settings.php
1'cache' => [
2 'enabled' => env('SETTINGS_CACHE', true),
3 'store' => env('SETTINGS_CACHE_STORE'), // null: the default store
4 'ttl' => null, // seconds; null: until the next write
5],

With the cache disabled, every value read queries the database.

#When the Cache Is Forgotten

setValue(), setMany(), forgetValue(), retypeValue(), a model save() or delete(), and a purging model delete forget the written scope only.

Every scope has a version counter in the cache, and its rows are cached under that version. A write moves the scope to a new version, so a request that loaded the old rows just before the write commits caches them under the old version, where no later read looks.

#Transactions

Inside a database transaction the cache is not used: reads go to the database, so you read your own writes, and nothing uncommitted is cached. The written scopes are forgotten only when the outermost transaction commits; a rollback forgets nothing, because nothing changed.

1DB::transaction(function () {
2 settings()->setValue('theme', 'dark');
3 settings()->getValue('theme'); // "dark", from the database
4});
5// Committed: other requests now read "dark".

setMany() runs in one transaction and forgets its scope once.

#Several Servers

Use a shared store (Redis, Memcached, database) when several servers or workers run the application, so a write on one server is seen by the others. A per-server store such as array or a local file store cannot invalidate the others' entries.

#Long-Running Processes

The package keeps no settings in memory between calls and no state in the container, so it is safe under Octane, queue workers and other long-running processes: every read comes from the cache store or the database. Invalidations waiting for a transaction belong to that connection object and are gone once it commits or rolls back.

Direct database writes

An UPDATE that does not go through the package is not seen by scopes already cached until the next package write to that scope, or until ttl expires. Set a ttl if other systems write the table.

#Upgrading

Version 1.x stored settings in settings(name, value, settingable_type, settingable_id), with no type information and no unique index. The new schema is different, so the 1.x table is converted with an Artisan command, never by migrate.

#1. Update the Code

1.xNow
use Pharaonic\Laravel\Settings\Traits\Settingable;use Pharaonic\Laravel\Settings\Concerns\HasSettings;
Pharaonic\Laravel\Settings\Models\SettingsPharaonic\Laravel\Settings\Models\Setting
settings()->name, settings()->get('name')settings()->getValue('name')
settings()->set('name', $value); settings()->save();settings()->setValue('name', $value);, saved right away
$model->settings->name$model->getSetting('name')
$model->settings->set(...) then $model->save()$model->setSetting(...), saved right away
$model->clearSettings()$model->settings()->get()->each->delete()

Settings are no longer deleted when a model is deleted, unless you set models.purge_on_force_delete, and never on a soft delete.

#2. Check the Conversion

Back up your database first. Then run the command without options: it only reads and reports.

Terminal
php artisan settings:migrate-legacy

It prints how many rows it read and would write, the rows per scope, and every row that blocks the conversion. It never prints values.

ProblemWhat to do
The same key twice in one scope--duplicates=latest keeps the most recently updated row (the later id on a tie). The default, fail, stops.
A value starting with { or [1.x read {...} back as JSON and [...] as text, so either may be JSON or text. --ambiguous=string keeps the text, --ambiguous=decode stores valid JSON as an array. The default, fail, stops.
A model type that is no class or morph alias any more--map="App\User=user" maps a stored type to an identifier prefix. The option repeats.
Half-set model columns, invalid keys or UTF-8Fix the row in the table.

Model rows become {morph class}:{id}, using your current morph map, so settings($user) finds them.

#3. Type the Values

1.x stored everything as text: true as "1", false as "", 20 as "20". Without a definition, a converted row keeps the text 1.x returned, as a nullable string. Add definitions for the keys you want typed before converting, and the command converts them with the safe casting rules:

config/pharaonic/settings.php
1'definitions' => [
2 'max_items' => ['type' => 'integer'], // "20" becomes 20
3 'enabled' => ['type' => 'boolean'], // "1" becomes true
4],

A value that cannot be converted safely blocks the conversion. You can also change types later with retypeValue().

#4. Convert

Terminal
php artisan settings:migrate-legacy --apply

The command renames the 1.x table to settings_legacy (choose another name with --backup=), creates the new table, copies the converted rows with their timestamps, and checks the result against a checksum. If anything fails, it drops the new table and renames the backup back.

Running it again on a converted table changes nothing.

#Rolling Back

Drop the new settings table, rename settings_legacy back to settings, and reinstall 1.x. On MySQL, the rename and create are not transactional, so restore from your backup if the process was killed midway.

#API Reference

#SettingBuilder

Pharaonic\Laravel\Settings\Builders\SettingBuilder extends Illuminate\Database\Eloquent\Builder, so every Eloquent builder method is available unchanged. These are its own methods. All but for() and getIdentifier() need a plain for() scope.

MethodReturnsDescription
for(Model|string|null $identifier = null)staticScope to the global scope (null or Setting::GLOBAL), a named scope, or a saved model. Once per builder.
fallbackTo(Model|string|null $identifier)staticAdd a scope that reads fall back to. Duplicates are skipped.
fallbackToGlobal()staticAdd the global scope as the next fallback; the same as fallbackTo(null).
getIdentifier()?stringThe for() identifier, or null before for().
getFallbackChain()list<string>The identifiers reads try, in order.
getValue(string $key, mixed $default = null)mixedThe value from the first scope storing the key; else $default (a closure is called), else the definition default, else null.
getOwnValue(string $key, mixed $default = null)mixedThe same, for the for() scope only.
hasValue(string $key)boolWhether a scope of the chain stores the key. Defaults do not count.
hasOwnValue(string $key)boolWhether the for() scope stores the key.
allValues()array<string, mixed>Every stored value of the chain, the earliest scope winning, sorted by key.
allOwnValues()array<string, mixed>Every value of the for() scope, sorted by key.
explainValue(string $key)arraykey, found, value, source, inherited, is_default, type, nullable.
setValue(string $key, mixed $value, SettingType|string|null $type = null, ?bool $nullable = null)SettingStore the value in the for() scope and return the saved row.
setMany(array $values)voidStore ['key' => value, …] in one transaction, all or nothing.
forgetValue(string $key)boolDelete the key from the for() scope; true when a row was deleted.
retypeValue(string $key, SettingType|string $type, ?bool $nullable = null)SettingChange the stored type and nullability, converting the value with the safe rules.

#Setting

Pharaonic\Laravel\Settings\Models\Setting

MemberDescription
Setting::GLOBAL'__global__', the identifier of the global scope.
Setting::for($identifier)A scoped SettingBuilder.
$setting->valueThe typed value.
$setting->identifier, ->key, ->nullableThe stored metadata.
$setting->typeThe SettingType case, or null for an untyped first null.
$setting->retype(SettingType|string $type, ?bool $nullable = null)Change type and nullability of the stored row and save it. Returns the model.

#HasSettings

Pharaonic\Laravel\Settings\Concerns\HasSettings

MethodReturnsDescription
settings()SettingBuilderSetting::for($this). Not a relation.
getSetting(string $key, mixed $default = null)mixedsettings()->getValue().
setSetting(string $key, mixed $value, SettingType|string|null $type = null, ?bool $nullable = null)Settingsettings()->setValue().
hasSetting(string $key)boolsettings()->hasValue().
forgetSetting(string $key)boolsettings()->forgetValue().
allSettings()array<string, mixed>settings()->allValues().

#Helper

FunctionReturnsDescription
settings(Model|string|null $identifier = null)SettingBuilderSetting::for($identifier). Defined only when no settings() function exists yet.

#Enums

Both are string-backed; every method that takes one also accepts its value.

EnumCases
Pharaonic\Laravel\Settings\Enums\SettingTypeSTRING, INTEGER, FLOAT, BOOLEAN, ARRAY
Pharaonic\Laravel\Settings\Enums\CastingPolicySTRICT, SAFE

SettingType::of($value) returns the type of a value (or null), SettingType::parse($type) takes a case or its value and throws InvalidArgumentException for anything else, and $type->rule() is its Laravel validation rule.

#Exceptions

All in Pharaonic\Laravel\Settings\Exceptions. Messages name keys, scopes and types, never values.

ExceptionExtendsThrown when
InvalidIdentifierExceptionInvalidArgumentExceptionThe scope is an empty or too long string, or an unsaved or keyless model; or a model made by a scoped builder names another scope.
InvalidSettingKeyExceptionInvalidArgumentExceptionThe key is empty, has an empty dot segment, is too long or invalid UTF-8; or setMany() gets an integer key.
SettingTypeMismatchExceptionInvalidArgumentExceptionA value does not match the type or nullability, or a write tries to change them.
UnsupportedSettingValueExceptionInvalidArgumentExceptionThe value cannot be stored at all.
SettingDefinitionExceptionLogicExceptionA definition is invalid, or a stored row contradicts it.
CorruptSettingValueExceptionUnexpectedValueExceptionA stored value is not JSON the package wrote.
UnscopedSettingQueryExceptionLogicExceptionA value method runs without for() or with other constraints.

Validation rules from definitions throw Illuminate\Validation\ValidationException.

#Artisan

CommandDescription
settings:migrate-legacyReport on a 1.x table. Options: --apply, --duplicates=fail|latest, --ambiguous=fail|string|decode, --map=Type=prefix (repeatable), --backup=name.

#Publish Tags

TagPublishes
settings-config, pharaonic-configconfig/pharaonic/settings.php
settings-migrations, pharaonic-migrationsthe migration into database/migrations
laravel-settings, pharaonicboth

#Examples

#1. Branch, Company and Global Branding

Each branch may override its company, and each company the application:

1$color = settings($branch)
2 ->fallbackTo($branch->company)
3 ->fallbackToGlobal()
4 ->getValue('branding.primary_color', '#0EA5E9');

#2. User Preferences with Defaults

config/pharaonic/settings.php
1'definitions' => [
2 'ui.language' => ['type' => 'string', 'default' => 'en', 'rules' => ['in:ar,en']],
3 'notifications.email' => ['type' => 'boolean', 'default' => true],
4],
app/Models/User.php
1use Pharaonic\Laravel\Settings\Concerns\HasSettings;
2 
3class User extends Authenticatable
4{
5 use HasSettings;
6}
app/Http/Controllers/PreferencesController.php
1public function update(Request $request)
2{
3 $request->user()->settings()->setMany([
4 'ui.language' => $request->input('language'),
5 'notifications.email' => $request->boolean('email'),
6 ]);
7 
8 return back();
9}

$user->getSetting('ui.language') is "en" until the user picks a language, and setMany() refuses "fr" with a validation error, storing neither value.

#3. Checkout Rules from a Form

A form sends strings. The safe policy turns "100" into 100.0 for a float setting, and still refuses "abc":

config/pharaonic/settings.php
1'definitions' => [
2 'checkout.minimum_order' => ['type' => 'float', 'casting' => 'safe', 'rules' => ['min:0']],
3],
1settings('checkout')->setValue('checkout.minimum_order', $request->input('minimum')); // "100" becomes 100.0

#4. Settings with Other Changes in One Transaction

1DB::transaction(function () use ($company, $plan) {
2 $company->update(['plan' => $plan->name]);
3 $company->setSetting('limits.users', $plan->users);
4});

If the update fails, the setting is rolled back with it, and other requests never see the new limit before the commit.

#5. Debugging Where a Value Comes From

1dump(settings($branch)->fallbackTo($company)->fallbackToGlobal()->explainValue('theme'));
2// ['found' => true, 'source' => 'App\Models\Company:12', 'inherited' => true, ...]

#Troubleshooting

#SettingTypeMismatchException on a value that looks right

The setting already has a type, and the strict policy only accepts that exact PHP type: "20" is not an integer. Pass the right type, enable safe casting for the key, or change the type with retypeValue().

#UnscopedSettingQueryException

A value method ran on a builder without for(), or with where(), limit(), select() or another constraint. Use settings($scope)->getValue() for values, and get() for filtered rows.

#The [settings] table already exists during migrate

The table comes from 1.x or from another package. Convert a 1.x table with php artisan settings:migrate-legacy; otherwise set pharaonic.settings.table to another name.

#SettingDefinitionException: ... is stored with the type

A definition was added for a key that is stored with another type or nullability. Run retypeValue($key, $type, $nullable) in each scope that stores it.

#Model settings disappeared after adding a morph map

The model scope is now alias:id instead of App\Models\Company:id. Remove the alias, or rename the stored identifiers: Setting::query()->where('identifier', 'like', 'App\Models\Company:%') and update them to the alias (then clear the cache).

#database is locked on SQLite

SQLite allows one writer at a time. The package's own writes take the write lock before reading, so concurrent writes wait their turn. Your own DB::transaction() that reads first and then calls setValue() can still collide with another writer: write before you read in that transaction, retry it with DB::transaction($callback, 3), or use MySQL or PostgreSQL for concurrent writers.

#A change made in the database directly is not visible

Scopes are cached until the next package write to them. Clear the cache, set cache.ttl, or write through the package.

#$company->settings throws LogicException

settings() is a method that returns a builder, not a relation. Call $company->settings() or $company->getSetting().

#Validation errors are under value

Rules run on the value alone, so errors use the value key; the message names the setting key, such as "The checkout.minimum_order must be at least 0."

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Settings!

Moamen Eltouny

@MoamenEltouny

46 contributions

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.