cartalyst/composite-config
Composite Config is a Cartalyst package for merging and layering configuration from multiple sources into a single composite config repository, letting PHP applications organize, override, and resolve settings cleanly. A small utility aimed at developers who need flexible config composition.
5.1.0 (2020-12-23, "Add PHP 8 support"), and Cartalyst version lines historically track the Laravel major they target. Check the illuminate/* and php constraints inside the package's own composer.json before assuming it installs on Laravel 10/11/12. If composer require refuses, run composer why-not cartalyst/composite-config 5.1.* — a stale config.platform.php in your root composer.json is the usual culprit.NOASSERTION. Cartalyst ships a BSD-3 grant with a supplemental Cartalyst clause. Get that read and approved before it lands in a production composer.json, because it is materially different from MIT and most automated license scanners will flag it.register() (that is where config gets rebound in the container — it is the documentation), the repository class that decorates Illuminate\Config\Repository, the publishable migration/table schema, then the published config file. There is effectively no README.config/app.php (or bootstrap/providers.php) explicitly, and place it before any provider that reads config() inside its own register().app.name, a pagination size, a feature default — publish the migration, run it, insert one override row, and read it back. Do not touch auth, database, mail, or queue config until a single read confirms the binding actually swapped.composer require cartalyst/composite-config:5.1.*
php artisan vendor:publish --provider="Cartalyst\CompositeConfig\CompositeConfigServiceProvider"
php artisan migrate
php artisan tinker
>>> get_class(app('config')) # must NOT be Illuminate\Config\Repository
>>> config('app.name') # must reflect your row
That get_class() check is the one-line smoke test you will run for the rest of the project's life. If it still says Illuminate\Config\Repository, the provider did not register or registered too late and nothing else matters yet.
config('x') keeps working and callers never know whether the value came from a file or a row. Keep config() / config([...]) in application code and never call CompositeConfig::get() in a controller — that couples every call site to a zero-star dependency. If you ever need to swap the implementation, the goal is to change one container binding, not grep the codebase.config([...]) holds request-scoped overrides (tests, jobs, one-off scripts). Keep the DB layer sparse — only keys that genuinely differ. A sparse table means a missing table, an empty table, or a broken query is a survivable state that silently falls back to file values.Schema::hasTable() check plus a try/catch that falls back to file values:try {
$overrides = Cache::rememberForever("config.overrides.{$scope}", function () {
if (! Schema::hasTable('config_overrides')) {
return [];
}
return DB::table('config_overrides')->pluck('value', 'key')->all();
});
} catch (\Throwable $e) {
report($e);
$overrides = [];
}
Without this, a fresh clone, a CI job with no database service, or php artisan migrate on an empty schema hard-fails before your app can render an error page — and the stack trace will point at the config layer, not at the actual cause.
config([...]). In most implementations set() mutates the in-memory array only. The classic bug is an admin settings screen that "saves," returns success, and reverts on the next request. Route all writes through your own small service or repository class that hits the table directly and then Cache::forget()s the override key.rememberForever(), and invalidate on write. One query per process boot is the budget; anything more is a regression you will see in APM as a startup spike.config/*.php into the override table, so the file values become defaults rather than the live source. Diff the resulting rows against the files before shipping — that diff is your rollout checklist and your rollback plan.text column plus json_decode on read is the low-effort version; a json column plus $casts is the cleaner one.new \Illuminate\Config\Repository([...]) in setUp() and never let the composite layer touch a database — the tests should assert your merge logic, not Laravel's. In feature tests, use RefreshDatabase plus the Schema::hasTable() guard, because the boot-time read fires before migrations run and will otherwise throw against an empty SQLite file.5.1.0, not ^5.1) and disable automated dependency updates for it. Better still, vendor a copy of the source into your repo (path repository or a copied namespace) so a future Composer resolve cannot silently take it away or patch it under you.Illuminate\Contracts\Config\Repository: read the DB layer, fall through to the file layer, honor dot-notation and defaults. That is a few hundred lines for a competent Laravel engineer, fully testable, with no license question. Given the support profile here, that is often the correct decision — and reading this package's source is a fine way to spec it.config:cache is the biggest behavioural trap. A cached config file is a flat PHP array baked from the file repository. Depending on how the provider hooks in, your DB overrides either get frozen into that file at cache time (stale after every settings change until you re-run config:cache) or get bypassed entirely (the cache gives you nothing and you pay the cost for free). Test both states explicitly — with and without bootstrap/cache/config.php present — before you ship, and write down which one is true in your environment.config() inside its own register() sees the raw file repository if it registers before this provider. Symptom to recognize: the value is correct in tinker but wrong in the booted app. Confirm by adding a temporary dd(config('your.key')) inside the competing provider's register()."1", "0", and "null" as strings. config('feature.enabled') returning "0" is truthy in PHP. This is the single most common post-launch bug in DB-backed config; cast at the repository boundary and add one test per boolean/array key.null means, and test it. Whether a row with a null value shadows the file value or falls through to it depends on the implementation. Laravel's get($key, $default) only applies the default when the key is genuinely absent, so a shadowing null will surprise you. One explicit test settles it forever.queue:work, horizon, and swoole read config once at boot. Editing a row has no effect on a running worker. Plan a reload path (php artisan octane:reload, queue restart signal) or accept and document "restart on settings change" — otherwise the on-call debug session is brutal and the bug report will be "settings don't save."APP_KEY, DB_*, MAIL_*, cache/queue connection strings, and anything read before the database connection exists must stay in .env or files. A config override that depends on the database cannot configure the database — that is a boot loop, not an override.encrypted cast, an authorized admin surface, and rows you never hand-edit. Database-backed config is not a secrets vault; treat rotation and audit as out of scope here and use a real vault when they matter.get_class(app('config')) to confirm the swap → Schema::hasTable('config_overrides') to confirm the table exists → DB::table('config_overrides')->get() to confirm the data → toggle config:cache on and off to isolate caching → check storage/logs/laravel.log for the real exception underneath the one you swallowed in the boot guard.Repository contract with your own class, keep the schema in your own migrations rather than the package's, and route all writes through your own service. When this package inevitably needs replacing, you swap one binding and one class rather than hunting call sites across the application.How can I help you explore Laravel packages today?