modera/dynamically-configurable-app-bundle
A Symfony bundle from Modera that lets application configuration be defined and changed at runtime instead of being hard-coded in files. It provides the infrastructure to store, load, and apply dynamic settings, making apps easier to adapt per environment or tenant without redeploying.
First, the reality check: modera/dynamically-configurable-app-bundle is a Symfony Bundle — it hooks Kernel::registerBundles() / Kernel::boot(), which Laravel doesn't have. Last release was 2016-06-13, it's READ-ONLY, has 1 star and 0 dependents. Do not composer require it — on PHP 8 it will fail platform constraints and pull nothing you need. You are here to steal the pattern, not the dependency.
Where to look first (skim, don't read deeply):
DependencyInjection/ — the extension class + Configuration tree. This is where the bundle declares which values can be overridden at boot and what their defaults/validation look like. This is the part that maps 1:1 to a Laravel settings service.*Kernel / boot hook classes — how it swaps config before the container is compiled. This is the equivalent of code that runs in bootstrap/app.php before $app->boot().Your first concrete use case (the Laravel version of what this bundle does):
You want per-tenant or per-environment behaviour decided before the service container resolves anything — e.g. different storage driver, queue connection, or feature-flagged providers per customer.
Minimal Laravel-first step, in bootstrap/app.php:
use Illuminate\Foundation\Application;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withProviders() // your baseline providers
->withMiddleware()
->withExceptions()
->create();
Then override config before boot in a very early bootstrap provider (see next section). If you were hoping the bundle's "runtime configuration" feature replaces .env + config/*.php — it doesn't need to. config() overrides before $app->boot(), plus conditional service providers, cover ~95% of what it offers, with a maintainer, documentation, and security patches.
Do not use this package to solve: plain settings/feature flags (use laravel/pennant or a settings table read through a cached service), per-environment config (.env.{environment} files), or swapping implementations per tenant (conditional bind()/singleton() in a provider).
Assume you are re-implementing the intent. Here are the day-to-day patterns, ordered from cheapest to most invasive.
Declare typed, validated, defaulted settings — mirrored from the bundle's Configuration tree — and resolve them through one service:
// config/settings.php — the authoritative schema (defaults + types)
return [
'mail.from' => ['env' => 'MAIL_FROM_ADDRESS', 'default' => '[email protected]', 'type' => 'string'],
'queue.driver' => ['env' => 'QUEUE_CONNECTION', 'default' => 'redis', 'type' => 'string'],
'features.new_billing' => ['env' => 'FEATURE_NEW_BILLING', 'default' => false, 'type' => 'bool'],
];
// app/Settings/SettingsRepository.php
class SettingsRepository
{
public function __construct(private DatabaseStore $db, private array $schema) {}
public function get(string $key): mixed
{
$definition = $this->schema[$key] ?? throw new RuntimeException("Unknown setting: {$key}");
$value = $this->db->get($key)
?? env($definition['env'])
?? $definition['default'];
return match ($definition['type']) {
'bool' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
'int' => (int) $value,
'json' => json_decode($value, true, flags: JSON_THROW_ON_ERROR),
default => (string) $value,
};
}
}
Cache it (Cache::rememberForever('settings:all', ...)) and bust the cache in the write path. This is the "admin-friendly" part: expose SettingsRepository::get/set in your Filament/Nova/admin controller; validation comes free from the $schema map.
The bundle's real trick is deciding how the app is assembled — the Laravel equivalent is conditional provider registration:
// app/Providers/AppServiceProvider.php
public function register(): void
{
$tenant = $this->app->make(TenantResolver::class)->current();
if ($tenant?->usesS3()) {
$this->app->singleton(FilesystemDriver::class, S3Driver::class);
} else {
$this->app->singleton(FilesystemDriver::class, LocalDriver::class);
}
}
Do this in a provider inside the fixed bootstrap/providers.php list (L11+) — not in a bootstrap file with side effects, and not by mutating config/app.php at runtime.
$app->bootstrapWith([...]))This is the truest analog to the Symfony bundle — pick the bootstrapper stack at runtime. It is powerful and dangerous; only reach for it when the previous two patterns fail.
// bootstrap/app.php
$app = Application::configure(basePath: dirname(__DIR__)) /* ... */ ->create();
if (env('APP_BOOTSTRAP_PROFILE') === 'worker') {
$app->bootstrapWith([
\Illuminate\Foundation\Bootstrap\LoadEnvironmentVariables::class,
\Illuminate\Foundation\Bootstrap\LoadConfiguration::class,
\Illuminate\Foundation\Bootstrap\HandleExceptions::class,
\Illuminate\Foundation\Bootstrap\RegisterFacades::class,
\Illuminate\Foundation\Bootstrap\RegisterProviders::class,
\Illuminate\Foundation\Bootstrap\BootProviders::class,
// note: no RegisterFacades/Route registration if you want CLI-only
]);
}
return $app;
Use case: single codebase, different boot profile for web / queue worker / CLI / tenant-isolated job. Always gate this on an env var with a safe default; never derive it from user input.
Overlap between "dynamic config" and "feature flags" is where this bundle was heading. Modern answer:
// app/Providers/FeatureServiceProvider.php
public function register(): void
{
$flags = $this->app->make(FeatureFlagReader::class)->all(); // reads cached settings
if ($flags['new_billing']) {
$this->app->bind(BillingEngine::class, NewBillingEngine::class);
}
}
The rule of thumb: toggle bindings at registration time, toggle behaviour at runtime. Binding a class based on a flag means the flag is effectively read-only per request — a mid-request change will not swap an already-resolved singleton.
Whatever you do, ensure your runtime settings layer survives php artisan config:cache. That means: the settings repository must be a service, not a config file you edit in place; and any boot-time branch must be derived from env vars that are baked into the image, not from DB reads that require a live connection before the DB provider registers.
Ordering matters:
LoadEnvironmentVariables → LoadConfiguration → your settings provider
→ DB-backed settings (OK here, connection is available)
→ config overrides via config(['x' => ...])
→ RegisterProviders/BootProviders
→ conditional bindings
Blast radius. Bootstrapping code sits beneath every request, queue job, scheduled command, and test. A bug here is not a 500 on one route — it's "the app starts but wiring is wrong" across all of them. Treat every change to bootstrap/app.php and early providers as a production-adjacent change: PR review, CI matrix coverage of all profiles, and a rollback plan that doesn't require a rebuild.
Config caching defeats runtime config — by design. If settings must be changed without a deploy, they cannot live in config/*.php. If they can tolerate a deploy, they don't need a runtime store. Pick one. Mixing the two is the #1 source of "it works in staging, fails in prod" reports with this pattern.
Silent misconfiguration. An env var differing by one character (QUEUE_CONNECTION=redis with a trailing space) causes the boot branch to take the wrong path and the app still starts. Defend in depth: (a) validate the boot profile against a whitelist at the top of bootstrap/app.php, (b) log the resolved profile once per process at info level including a hash of the resolved settings, (c) add an artisan command php artisan app:boot-profile that prints the active profile and each resolved setting.
Non-reproducible environments. If local, CI, staging, and prod take different code paths, works on my machine becomes structural. Add a CI job that boots the app under each profile (web, worker, cli) and runs a smoke test — php artisan about with each APP_BOOTSTRAP_PROFILE is a cheap start.
Cache poisoning. If a boot-time decision is cached (settings, feature flags, resolved providers), a bad boot can persist the bad decision past the process that made it. Never write to Cache from a bootstrap path; only read. Provide an explicit php artisan settings:refresh to clear.
Ordering pitfalls. TenantResolver often needs the DB; the DB needs config; config needs env. If you register a tenant-aware provider that reads settings from the DB, you must ensure RegisterProviders runs after LoadConfiguration, or you get Target [db] is not instantiable during console commands. Test your providers with php artisan tinker and php artisan config:cache — both exercise different orders than web requests.
Tests will hide the bug. phpunit boots a fresh app per test with APP_ENV=testing and typically no DB write, so tenant-aware bindings, cache poisoning, and profile branching are all unexercised. Write one feature test that explicitly sets APP_BOOTSTRAP_PROFILE and exercises each branch — or use Pest arch tests to assert the right class is bound:
it('binds S3 driver for s3 tenants', function () {
$this->app->instance(TenantResolver::class, new FakeTenantResolver(usesS3: true));
$this->app->register(AppServiceProvider::class, force: true);
expect($this->app->make(FilesystemDriver::class))->toBeInstanceOf(S3Driver::class);
});
Extension points you do want to copy from the bundle. Its schema-driven Configuration tree is genuinely nice: keys map to types, defaults, and validation, and the same schema drives both the admin form and the runtime reader. Do the same in config/settings.php + SettingsRepository — one source of truth for what can be changed, what its type is, and what default applies. Then the admin UI is a generic form over array_keys(config('settings')).
Debugging tips.
php artisan about shows resolved config, drivers, and env — your first stop when a boot branch is suspected.php artisan config:show (L11+) narrows it to a single file.get_class() of the suspicious binding in a route/tinker rather than reading config: config lies, the container doesn't.php artisan event:list and php artisan route:list --verbose both fail loudly with a stack trace that shows provider ordering — use them as canaries.\Log::info('boot', [...]) at the top of bootstrap/app.php if you need to prove the file is even being executed (opcache / path issues).Do not let anyone extend this bundle by forking it into app/. If the requirement is real, the ~2–3 engineer-days estimate for a native implementation is cheaper than owning 2016-era boot code forever. Every one of the patterns above is idiomatic Laravel and can be covered by your existing test suite — the Symfony bundle cannot.
How can I help you explore Laravel packages today?