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:
2Config::dynamic('branch-12')->load(); // apply3config('mail.from.address'); // readNative 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.
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.2, 8.3, 8.4 or 8.5
- Laravel 12.x
#Composer Installation
composer require pharaonic/laravel-dynamic-configThe 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:
php artisan migrateTo change the migration before it runs, publish it into database/migrations:
php artisan vendor:publish --tag=dynamic-config-migrations#Publish Configuration
Publish the configuration only when you need another connection, table name or cache setup:
php artisan vendor:publish --tag=dynamic-config-configThe file is published to config/pharaonic/dynamic-config.php.
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.
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
| Key | Environment variable | Default | Description |
|---|---|---|---|
connection | DYNAMIC_CONFIG_CONNECTION | null | The database connection that stores the values. null uses the default connection. |
table | DYNAMIC_CONFIG_TABLE | dynamic_configs | The table that stores the values. The migration creates it under this name. |
cache.enabled | DYNAMIC_CONFIG_CACHE | true | Cache each scope's values. Set it to false to read the database on every call. |
cache.store | DYNAMIC_CONFIG_CACHE_STORE | null | The cache store to use. null uses the default store. |
cache.ttl | none | null | Seconds a cached scope lives. null keeps it until the scope is written. |
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:
| Column | Type | Description |
|---|---|---|
id | big integer | Primary key. |
identifier | nullable string | null for the global scope, otherwise the scope's identifier. |
key | string | The dotted config key, such as mail.from.address. |
value | long text | The value, encoded as JSON. |
created_at, updated_at | timestamps | When 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 scope4Config::dynamic(null); // the same global scope5Config::dynamic('branch-12'); // the "branch-12" scopeconfig()->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:
2 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:
2 3Config::dynamic()->load();4 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');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 NULL2Config::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:
1Config::dynamic()->load(); // config files < global2Config::dynamic('company-1')->load(); // < company-13Config::dynamic('branch-12')->load(); // < branch-12The order is yours. Loading branch-12 first and the global scope second lets the global values win:
3 4Config::dynamic('branch-12')->load();5Config::dynamic()->load();6 Keys that a later scope does not mention keep the value an earlier scope gave them:
1Config::dynamic()->set('mail.from.name', 'Global');3 4Config::dynamic()->load();5Config::dynamic('branch-12')->load();6 #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.
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 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.
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 value | Current config value | Result |
|---|---|---|
Scalar or null | anything | Replaced. |
List (['a', 'b']) | anything | Replaced, never appended. |
Empty array [] | anything | Replaced with []. |
| Associative array | array | Merged recursively, key by key. |
| Associative array | missing or scalar | Replaced. |
#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 4Config::dynamic('branch-12')->load();5 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:
3 4Config::dynamic()->load();5 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 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
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.
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:
| Type | Example |
|---|---|
null | null |
bool | true, false |
int | 42, -7 |
float | 1.5, 2.0 (stays a float) |
string | '[email protected]', '42' (stays a string) |
array | lists, 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.
2// the "branch-12" entry is forgotten; the global entry is keptA 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
| Option | Effect |
|---|---|
cache.enabled | false reads the database on every load(), get(), has() and all(). |
cache.store | The store that holds the entries, such as redis. null uses the default store. |
cache.ttl | Seconds 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.
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:
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.
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
| Method | Description | Returns |
|---|---|---|
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().
| Method | Description | Returns |
|---|---|---|
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
| Exception | Thrown when |
|---|---|
Pharaonic\Laravel\DynamicConfig\Exceptions\UnsupportedValueException | set() receives a closure, object, resource, non-finite float or non-UTF-8 string, even nested in an array. Extends InvalidArgumentException. |
InvalidArgumentException | Config::dynamic(''), or set() with an empty key or a key with an empty segment. |
#Publish tags
| Tag | Publishes |
|---|---|
dynamic-config-config | config/pharaonic/dynamic-config.php |
dynamic-config-migrations | The migration into database/migrations |
pharaonic-config, pharaonic-migrations | The same files, together with other Pharaonic packages' |
laravel-dynamic-config, pharaonic | Both 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.
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}1->withMiddleware(function (Middleware $middleware) {2 $middleware->web(append: [3 \App\Http\Middleware\LoadBranchConfig::class,4 ]);5})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.
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.
1use Illuminate\Support\Facades\Config;2 3public function boot(): void4{5 if (! $this->app->runningInConsole()) {6 Config::dynamic()->load();7 }8}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.
DYNAMIC_CONFIG_CONNECTION=centralDYNAMIC_CONFIG_CACHE_STORE=redis1Config::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:
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!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.