Laravel Packagev10.0.0MIT License

Laravel Dynamic Config

Laravel Dynamic Config stores configuration values in a database table and merges them into Laravel's own config repository when you ask it to. Values live in a global scope or in named scopes such as a company or a branch. Config::dynamic()->load() applies one scope, and you keep reading the result with config() and Config::get().

The whole mental model fits in three lines:

1Config::dynamic('branch-12')->set('mail.from.address', '[email protected]'); // persist
2Config::dynamic('branch-12')->load(); // apply
3config('mail.from.address'); // read

Native Config API

A dynamic macro on Laravel's Config facade, with no extra facade and no custom getter for the final value.

Global and Named Scopes

Store values globally, per identifier or per Eloquent model, such as a company, a branch or a tenant.

Explicit Layering

Each load applies one scope, and the scope you load last wins.

Safe Merging

A nested key or an associative array overrides its target and keeps its siblings.

Typed Values

Null, booleans, integers, floats, strings and arrays come back with their original types.

Cached Scopes

One cache entry per scope, forgotten automatically whenever that scope is written.

Quick Tip

Nothing is loaded automatically. Load the scopes you need where your request or job starts, in the order you want them to win.

#Installation

Install the package with Composer and create its table.

#Requirements

  • PHP 8.1, 8.2 or 8.3
  • Laravel 10.x

#Composer Installation

Terminal
composer require pharaonic/laravel-dynamic-config

The service provider is registered automatically and adds the dynamic() macro to the config repository.

#Run Migrations

The package loads its migration automatically, so the dynamic_configs 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=dynamic-config-migrations

#Publish Configuration

Publish the configuration only when you need another connection, table name or cache setup:

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

The file is published to config/pharaonic/dynamic-config.php.

Installation Complete

You're all set! Store your first value with Config::dynamic()->set().

#Configuration

The configuration lives under the pharaonic.dynamic-config key. Every option has a sensible default, so most applications never publish it.

config/pharaonic/dynamic-config.php
1return [
2 'connection' => env('DYNAMIC_CONFIG_CONNECTION'),
3 
4 'table' => env('DYNAMIC_CONFIG_TABLE', 'dynamic_configs'),
5 
6 'cache' => [
7 'enabled' => env('DYNAMIC_CONFIG_CACHE', true),
8 'store' => env('DYNAMIC_CONFIG_CACHE_STORE'),
9 'ttl' => null,
10 ],
11];

#Options

KeyEnvironment variableDefaultDescription
connectionDYNAMIC_CONFIG_CONNECTIONnullThe database connection that stores the values. null uses the default connection.
tableDYNAMIC_CONFIG_TABLEdynamic_configsThe table that stores the values. The migration creates it under this name.
cache.enabledDYNAMIC_CONFIG_CACHEtrueCache each scope's values. Set it to false to read the database on every call.
cache.storeDYNAMIC_CONFIG_CACHE_STOREnullThe cache store to use. null uses the default store.
cache.ttlnonenullSeconds a cached scope lives. null keeps it until the scope is written.
Central database

In a multi-database application, point connection at the central connection so every tenant's scope lives in one table.

#The table

The migration creates one row per key and scope:

ColumnTypeDescription
idbig integerPrimary key.
identifiernullable stringnull for the global scope, otherwise the scope's identifier.
keystringThe dotted config key, such as mail.from.address.
valuelong textThe value, encoded as JSON.
created_at, updated_attimestampsWhen the key was first stored and last changed.

A unique index covers identifier and key. The package also keeps global keys unique on databases that let several NULL identifiers share a key.

#Basic Usage

Every call starts with Config::dynamic(), which returns one scope: the global scope when you pass nothing (or null), a named scope when you pass an identifier, or the scope of a model when you pass a saved Eloquent model.

1use Illuminate\Support\Facades\Config;
2 
3Config::dynamic(); // the global scope
4Config::dynamic(null); // the same global scope
5Config::dynamic('branch-12'); // the "branch-12" scope

config()->dynamic() works too, because the macro lives on the config repository itself.

#Persist a value

set() stores a value in the scope's table rows:

1Config::dynamic()->set('mail.from.address', '[email protected]');
2 
3Config::dynamic('branch-12')->set('mail.from.address', '[email protected]');

Setting the same key again replaces its value, so a scope never holds a key twice.

#Apply a scope

load() merges the scope's stored values into Laravel's config repository:

1config('mail.from.address'); // "[email protected]" from config/mail.php
2 
3Config::dynamic()->load();
4 
5config('mail.from.address'); // "[email protected]"

From then on, everything that reads configuration sees the new value: config(), Config::get(), and the mail, cache or service classes Laravel resolves afterwards.

#Read the final value

Read resolved configuration with Laravel itself, never with the scope:

1config('mail.from.address');
2Config::get('mail.from.address');
Stored values and resolved values

Config::dynamic('branch-12')->get('mail.from.address') returns what is stored in that scope, or the default. config('mail.from.address') returns the resolved value after the config files and every scope you have loaded so far.

#Scopes and Layering

A scope is the set of values stored under one identifier. The global scope uses the identifier null. A named scope uses any non-empty string, such as company-1, branch-12 or a tenant key, or a saved Eloquent model.

#One scope per load

Each load() applies exactly one scope:

1Config::dynamic()->load(); // only rows whose identifier is NULL
2Config::dynamic('branch-12')->load(); // only rows whose identifier is "branch-12"

Loading a named scope never loads the global scope or any other identifier for you. The package has no idea of parent scopes.

#Layer scopes explicitly

To stack layers, load them in order. Each load overrides the previous ones where they conflict:

app/Http/Middleware/LoadBranchConfig.php
1Config::dynamic()->load(); // config files < global
2Config::dynamic('company-1')->load(); // < company-1
3Config::dynamic('branch-12')->load(); // < branch-12

The order is yours. Loading branch-12 first and the global scope second lets the global values win:

1Config::dynamic()->set('mail.from.address', '[email protected]');
2Config::dynamic('branch-12')->set('mail.from.address', '[email protected]');
3 
4Config::dynamic('branch-12')->load();
5Config::dynamic()->load();
6 
7config('mail.from.address'); // "[email protected]"

Keys that a later scope does not mention keep the value an earlier scope gave them:

1Config::dynamic()->set('mail.from.name', 'Global');
2Config::dynamic('branch-12')->set('mail.from.address', '[email protected]');
3 
4Config::dynamic()->load();
5Config::dynamic('branch-12')->load();
6 
7config('mail.from'); // ['address' => '[email protected]', 'name' => 'Global']

#Identifiers

An identifier is a plain string. Config::dynamic('') throws an InvalidArgumentException, and passing an array of identifiers is a TypeError: load several scopes with several calls.

Name identifiers by type

Prefix identifiers with their type, such as company-1 and branch-12, so two kinds of scope with the same number never share values.

#Model scopes

Pass a saved Eloquent model instead of a string, and the scope belongs to that model:

1$branch = Branch::find(12);
2 
3Config::dynamic($branch)->set('mail.from.address', '[email protected]');
4 
5Config::dynamic()->load();
6Config::dynamic($branch->company)->load();
7Config::dynamic($branch)->load();

The model is stored as the identifier {morph class}:{key}, such as App\Models\Branch:12, so two models of different types never share a scope. The string form reaches the same scope: Config::dynamic('App\Models\Branch:12') reads and writes the same values as Config::dynamic($branch).

A model that is not saved yet, or has no string or integer key, throws an InvalidArgumentException.

Use a morph map

Without a morph map, the identifier holds the model's class name, and renaming or moving the class leaves its stored values behind.

Register short aliases with Relation::enforceMorphMap(), and the identifier becomes branch:12:

1use Illuminate\Database\Eloquent\Relations\Relation;
2 
3Relation::enforceMorphMap([
4 'branch' => \App\Models\Branch::class,
5 'company' => \App\Models\Company::class,
6]);

#Merge Rules

load() writes each stored key into the config repository without deleting unrelated configuration.

Stored valueCurrent config valueResult
Scalar or nullanythingReplaced.
List (['a', 'b'])anythingReplaced, never appended.
Empty array []anythingReplaced with [].
Associative arrayarrayMerged recursively, key by key.
Associative arraymissing or scalarReplaced.

#Dotted keys keep their siblings

A dotted key changes only its own leaf:

1// config/mail.php: 'from' => ['address' => '[email protected]', 'name' => 'My App']
2 
3Config::dynamic('branch-12')->set('mail.from.address', '[email protected]');
4Config::dynamic('branch-12')->load();
5 
6config('mail.from.address'); // "[email protected]"
7config('mail.from.name'); // "My App"

Keys the config files don't have are created:

1Config::dynamic()->set('services.mailgun.domain', 'example.com');
2Config::dynamic()->load();
3 
4config('services.mailgun.domain'); // "example.com"

#Associative arrays merge recursively

1// config: 'services.mailgun' => ['domain' => 'base.com', 'secret' => 'base-secret', 'options' => ['timeout' => 5, 'retries' => 1]]
2 
3Config::dynamic()->set('services.mailgun', ['domain' => 'dynamic.com', 'options' => ['timeout' => 10]]);
4Config::dynamic()->load();
5 
6config('services.mailgun');
7// ['domain' => 'dynamic.com', 'secret' => 'base-secret', 'options' => ['timeout' => 10, 'retries' => 1]]

#Lists are replaced

1// config: 'app.hosts' => ['a.test', 'b.test', 'c.test']
2 
3Config::dynamic()->set('app.hosts', ['z.test']);
4Config::dynamic()->load();
5 
6config('app.hosts'); // ['z.test']

A list nested inside an associative array is replaced too, while the array's other keys merge.

#Parent keys come first

Within one scope, keys are applied in string order, so a parent key is always applied before its children:

1Config::dynamic()->set('mail.from.address', '[email protected]');
2Config::dynamic()->set('mail', ['from' => ['address' => '[email protected]']]);
3 
4Config::dynamic()->load();
5 
6config('mail.from.address'); // "[email protected]"
Config cache

load() only changes the config repository in memory. It never writes bootstrap/cache/config.php, so php artisan config:cache keeps caching your config files, and dynamic values are applied on top of them at runtime.

#Stored Values

A scope's get(), has(), all(), set() and forget() work on that scope's stored rows only. They never look at the config files or at other scopes.

#Reading stored values

1$scope = Config::dynamic('branch-12');
2 
3$scope->get('mail.from.address'); // "[email protected]", or null
4$scope->get('mail.from.address', '[email protected]'); // the default when the key is not stored
5$scope->get('mail.from.address', fn () => '[email protected]');
6 
7$scope->has('mail.from.address'); // true
8 
9$scope->all();
10// [
11// 'mail.from.address' => '[email protected]',
12// 'services.mailgun.domain' => 'branch.example.com',
13// ]

all() is flat: each stored key with its value, sorted by key. Keys match exactly, so after set('mail.from', [...]), has('mail.from') is true while has('mail.from.address') and has('mail') are false.

A stored null counts as present: has() returns true and get() returns null, not the default.

#Writing and deleting

1Config::dynamic('branch-12')->set('mail.from.address', '[email protected]');
2 
3Config::dynamic('branch-12')->forget('mail.from.address');

forget() deletes the key from that scope only. Other scopes and the config files keep their values, and forgetting a key that isn't stored does nothing.

Writes don't change the loaded config

set() and forget() change the database and that scope's cache entry, not the config repository. A value you set becomes visible to config() on the next load(). A value you forget stays in the loaded config until the next request or job starts without it.

#Keys

A key is a dotted config key with no empty segments. '', ., mail., .mail and mail..from throw an InvalidArgumentException on set().

#Supported values

Values are stored as JSON and come back with their type:

TypeExample
nullnull
booltrue, false
int42, -7
float1.5, 2.0 (stays a float)
string'[email protected]', '42' (stays a string)
arraylists, associative arrays and nested arrays of the types above

Anything else throws Pharaonic\Laravel\DynamicConfig\Exceptions\UnsupportedValueException, before anything is written: closures, objects, resources, NAN, INF and strings that are not valid UTF-8.

1use Pharaonic\Laravel\DynamicConfig\Exceptions\UnsupportedValueException;
2 
3try {
4 Config::dynamic()->set('mail', ['from' => ['address' => new stdClass]]);
5} catch (UnsupportedValueException $exception) {
6 $exception->getMessage();
7 // The dynamic config [mail.from.address] of the global scope cannot store a value of type [stdClass]; ...
8}

The message names the key, the scope and the type, never the value, so secrets don't end up in logs. The exception extends InvalidArgumentException.

#Caching

Each scope is read from the database once and kept in the cache as a single entry. load(), get(), has() and all() all read that entry, so a request that loads two scopes queries the database at most twice, and not at all once both scopes are cached.

After load(), config() reads Laravel's config repository in memory and never queries the database.

#Invalidation

set() and forget() forget the cache entry of their own scope. Other scopes stay cached, and nothing else in your cache is touched.

1Config::dynamic('branch-12')->set('mail.from.address', '[email protected]');
2// the "branch-12" entry is forgotten; the global entry is kept
Direct database changes

A row you change with a query, a seeder or a database client is not seen while its scope is cached. Write through set() and forget(), or clear the cache after changing rows directly.

#Options

OptionEffect
cache.enabledfalse reads the database on every load(), get(), has() and all().
cache.storeThe store that holds the entries, such as redis. null uses the default store.
cache.ttlSeconds before an entry expires on its own. null keeps it until the scope is written.

#Cache keys

An entry's key is pharaonic:dynamic-config: followed by a SHA-1 hash of the connection, the table and the identifier. The global scope and an identifier named global never share an entry, and a changed connection or table never reads an old entry.

Shared cache, several servers

Use a shared store such as Redis or Memcached when several servers serve the same application, so a write on one server invalidates the entry for all of them. With the file or array store, set a ttl.

#Long-Running Processes

load() changes the config repository of the running application. In a normal PHP-FPM request that application ends with the request. Processes that serve several requests or jobs with one application need more care.

#Queue workers

php artisan queue:work boots the application once and keeps its config repository between jobs. A scope one job loads is still loaded for the next job, which may belong to another branch or tenant.

Load scopes in a job middleware that puts the configuration back when the job ends:

app/Jobs/Middleware/LoadBranchConfig.php
1namespace App\Jobs\Middleware;
2 
3use Illuminate\Support\Facades\Config;
4 
5class LoadBranchConfig
6{
7 public function handle(object $job, callable $next): void
8 {
9 $config = config();
10 $original = $config->all();
11 
12 try {
13 Config::dynamic()->load();
14 Config::dynamic($job->branch)->load();
15 
16 $next($job);
17 } finally {
18 foreach (array_diff_key($config->all(), $original) as $key => $value) {
19 unset($config[$key]);
20 }
21 
22 $config->set($original);
23 }
24 }
25}

The finally block restores every top-level key to its value before the job and sets new top-level keys to null.

Services resolved while a scope is loaded

Restoring the configuration does not rebuild services that already read it, such as a mailer or a cache store resolved during the job. Load scopes before resolving the services that depend on them.

#Laravel Octane

Octane gives every request its own copy of the config repository, and the package always uses the copy of the current request. Scopes loaded during one request don't leak into the next one, so load them in a middleware, as you would without Octane.

#Config cache

php artisan config:cache caches your config files only. Dynamic values are never written into bootstrap/cache/config.php; they are applied on top of the cached configuration each time you call load(). Clearing or rebuilding the config cache doesn't touch the stored values.

#Nothing loads at boot

The service provider never queries the database. Artisan commands, migrations, tests and queue workers start with your config files only, until your own code calls load().

#API Reference

#Config macro

MethodDescriptionReturns
Config::dynamic(string|Model|null $identifier = null)The global scope (null), the named scope, or the scope of a saved Eloquent model ({morph class}:{key}). Also available as config()->dynamic(). Throws InvalidArgumentException for an empty identifier or a model that is not saved.DynamicConfig

#Scope methods

Pharaonic\Laravel\DynamicConfig\DynamicConfig, returned by Config::dynamic().

MethodDescriptionReturns
load()Merges this scope's stored values into the config repository.void
get(string $key, mixed $default = null)The value stored for the exact key in this scope, or $default (a closure default is called).mixed
has(string $key)Whether the exact key is stored in this scope, including a stored null.bool
set(string $key, mixed $value)Stores the value and forgets this scope's cache entry. Throws InvalidArgumentException for an invalid key and UnsupportedValueException for an unsupported value.void
forget(string $key)Deletes the key from this scope and forgets this scope's cache entry.void
all()Every stored key and value of this scope, flat and sorted by key.array

#Exceptions

ExceptionThrown when
Pharaonic\Laravel\DynamicConfig\Exceptions\UnsupportedValueExceptionset() receives a closure, object, resource, non-finite float or non-UTF-8 string, even nested in an array. Extends InvalidArgumentException.
InvalidArgumentExceptionConfig::dynamic(''), or set() with an empty key or a key with an empty segment.

#Publish tags

TagPublishes
dynamic-config-configconfig/pharaonic/dynamic-config.php
dynamic-config-migrationsThe migration into database/migrations
pharaonic-config, pharaonic-migrationsThe same files, together with other Pharaonic packages'
laravel-dynamic-config, pharaonicBoth files

#Examples

#1. Per-branch configuration in a middleware

Load the global scope and the signed-in user's branch on every request, so mail, services and app settings follow the branch.

app/Http/Middleware/LoadBranchConfig.php
1namespace App\Http\Middleware;
2 
3use Closure;
4use Illuminate\Http\Request;
5use Illuminate\Support\Facades\Config;
6 
7class LoadBranchConfig
8{
9 public function handle(Request $request, Closure $next)
10 {
11 Config::dynamic()->load();
12 
13 if ($branch = $request->user()?->branch) {
14 Config::dynamic($branch)->load();
15 }
16 
17 return $next($request);
18 }
19}
app/Http/Kernel.php
1protected $middlewareGroups = [
2 'web' => [
3 // ...
4 \App\Http\Middleware\LoadBranchConfig::class,
5 ],
6];
app/Http/Controllers/InvoiceController.php
1Mail::to($customer)->send(new InvoiceMail($invoice));
2// sent from config('mail.from.address') of the branch

#2. A settings form

Save validated settings into a branch scope. The new values apply from the next request, when the middleware loads the scope again.

app/Http/Controllers/BranchSettingsController.php
1namespace App\Http\Controllers;
2 
3use App\Models\Branch;
4use Illuminate\Http\Request;
5use Illuminate\Support\Facades\Config;
6 
7class BranchSettingsController extends Controller
8{
9 public function edit(Branch $branch)
10 {
11 $scope = Config::dynamic($branch);
12 
13 return view('branches.settings', [
14 'branch' => $branch,
15 'fromAddress' => $scope->get('mail.from.address', config('mail.from.address')),
16 'fromName' => $scope->get('mail.from.name', config('mail.from.name')),
17 ]);
18 }
19 
20 public function update(Request $request, Branch $branch)
21 {
22 $data = $request->validate([
23 'from_address' => ['nullable', 'email'],
24 'from_name' => ['nullable', 'string', 'max:100'],
25 ]);
26 
27 $scope = Config::dynamic($branch);
28 
29 foreach (['mail.from.address' => 'from_address', 'mail.from.name' => 'from_name'] as $key => $field) {
30 if (filled($data[$field] ?? null)) {
31 $scope->set($key, $data[$field]);
32 } else {
33 $scope->forget($key);
34 }
35 }
36 
37 return back()->with('status', 'Settings saved.');
38 }
39}

Forgetting an empty field brings the branch back to the global or config-file value instead of storing an empty string.

#3. Global feature flags

Keep application-wide switches in the global scope and load it in a service provider for web requests.

app/Providers/AppServiceProvider.php
1use Illuminate\Support\Facades\Config;
2 
3public function boot(): void
4{
5 if (! $this->app->runningInConsole()) {
6 Config::dynamic()->load();
7 }
8}
Anywhere
1Config::dynamic()->set('features.new_checkout', true);
2 
3if (config('features.new_checkout')) {
4 // ...
5}

The console check keeps php artisan migrate working before the table exists. Load the scope in your commands and jobs where they need it.

#4. Tenant settings on a central connection

Store every tenant's scope in the central database, whichever connection the tenant itself uses.

.env
DYNAMIC_CONFIG_CONNECTION=central
DYNAMIC_CONFIG_CACHE_STORE=redis
app/Tenancy/BootstrapTenant.php
1Config::dynamic()->load();
2Config::dynamic("tenant-{$tenant->getKey()}")->load();

#5. Copying a scope

all() returns the stored keys of a scope, so copying one branch's settings to a new branch takes one loop:

1foreach (Config::dynamic('branch-12')->all() as $key => $value) {
2 Config::dynamic('branch-13')->set($key, $value);
3}

#Troubleshooting

#config() still returns the old value after set()

set() only stores the value. Call load() on the scope again, or wait for the next request where your middleware loads it:

1Config::dynamic('branch-12')->set('mail.from.address', '[email protected]');
2Config::dynamic('branch-12')->load();

#get() returns null although config() has a value

get() reads only what is stored in that one scope, by exact key. It never falls back to the config files or another scope. Read resolved values with config(), and pass a default to get() when you need one.

#A value changed in the database is not picked up

The scope is cached. Change rows through set() and forget(), which forget the cache entry, or run php artisan cache:clear after changing rows directly. Set cache.enabled to false while debugging.

#"Table not found" during php artisan migrate

Some code calls load() while the application boots, before the dynamic_configs table exists. Skip loading in the console, or load scopes in a middleware instead of a service provider.

#Call to undefined method Illuminate\Config\Repository::dynamic()

The macro is registered when the package's service provider boots. Don't call Config::dynamic() from a service provider's register() method; use boot(), a middleware or later code. If package discovery is disabled in your application, register Pharaonic\Laravel\DynamicConfig\DynamicConfigServiceProvider yourself.

#A model's values disappeared after renaming its class

Without a morph map, a model scope is stored under the model's class name. Register a morph map with the old class name as the alias, or update the identifier column of the old rows, as shown in Model scopes.

#One job sees another job's values

Queue workers keep the config repository between jobs. Restore it when each job ends, as shown in Long-Running Processes.

#UnsupportedValueException when saving a value

Only null, booleans, integers, finite floats, UTF-8 strings and arrays of them can be stored. Convert objects to arrays or strings before calling set(), such as $date->toIso8601String() for a date.

#Changes on one server are not seen on another

Every server has its own cache when the store is file or array. Use a shared store such as Redis, or set cache.ttl so entries expire.

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 Dynamic Config!

Moamen Eltouny

@MoamenEltouny

23 contributions

Want to Contribute?

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