DEV Community

Cover image for Admin-Editable Settings Without Giving Up config()
Nasrul Hazim Bin Mohamad
Nasrul Hazim Bin Mohamad

Posted on

Admin-Editable Settings Without Giving Up config()

There's a tension every app hits eventually: some configuration really should live in .env and config/, and some of it the customer wants to change themselves from an admin screen without a redeploy. Timezone. Whether public registration is open. The site name. Today I wired exactly that into kickoff — DB-backed, admin-editable settings — without forcing the rest of the codebase to learn a new way to read config. Everything downstream still calls plain config('app.timezone'). The settings just become the config at boot.

The design goal: one read path

The trap with "settings in the database" is that you end up with two ways to read the same value — config('app.timezone') in some places, app(GeneralSettings::class)->timezone in others — and now every developer has to remember which is authoritative. That's how you get a timezone that's correct in one half of the app and wrong in the other.

So the rule I set: the database is the source of truth, but config() stays the single read path. Settings get loaded once per request and laid over config. Controllers, Blade views, Fortify, third-party packages — none of them know the value came from the database. They read config() like always.

The storage layer is spatie/laravel-settings. Each settings group is a typed class:

class AuthenticationSettings extends Settings
{
    public bool $public_registration_enabled;

    public static function group(): string
    {
        return 'authentication';
    }
}
Enter fullscreen mode Exit fullscreen mode

Typed properties, not an array of loose keys — so $settings->public_registration_enabled is a real bool with editor autocomplete, and a typo is a fatal error instead of a silently-null array access.

The overlay: lay settings over config in boot()

The whole mechanism is one private method called from AppServiceProvider::boot(). Read each settings group, push its values into config():

private function applyDatabaseSettings(): void
{
    try {
        $general = app(GeneralSettings::class);
        config(['app.name' => $general->site_name]);

        // Admin-editable timezone. config() alone doesn't re-apply once the
        // framework has set the default during boot, so set it explicitly too.
        if ($general->timezone !== '' && in_array($general->timezone, timezone_identifiers_list(), true)) {
            config(['app.timezone' => $general->timezone]);
            date_default_timezone_set($general->timezone);
        }

        $auth = app(AuthenticationSettings::class);
        config(['admin.public_registration' => $auth->public_registration_enabled]);

        // ... mail, notifications, etc.
    } catch (\Throwable) {
        // Settings table may not exist yet (fresh install, migrations pending).
        // Silently fall back to .env / config defaults.
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details in there earn their keep.

The timezone needs date_default_timezone_set(), not just config(). This one bit me. Laravel reads config('app.timezone') and calls date_default_timezone_set() once, early in the bootstrap, before your provider's boot() runs. By the time you overwrite config(['app.timezone' => ...]), the framework has already applied the old value to PHP's global timezone. Updating config after the fact changes the config array but not PHP's actual default — so now() keeps using the boot-time zone. You have to set both: the config value (so anything reading config later is correct) and PHP's global default (so date functions are correct).

The whole thing is wrapped in a try/catch that swallows everything. On a fresh install the settings table doesn't exist yet — migrations haven't run. If applyDatabaseSettings() threw, the app couldn't boot far enough to run the migration that creates the table. Classic chicken-and-egg. Catching \Throwable and falling back to .env defaults breaks the cycle: a fresh app boots on env config, you migrate, and from then on the DB settings overlay kicks in.

The registration toggle: unregister, don't just hide

The public-registration setting does something slightly more aggressive than overlaying a value. When registration is off, it removes Fortify's registration feature entirely:

if (! $auth->public_registration_enabled) {
    config([
        'fortify.features' => array_values(array_filter(
            (array) config('fortify.features', []),
            fn ($feature) => $feature !== Features::registration(),
        )),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

This works because of boot order — AppServiceProvider boots before the Fortify package provider, so when Fortify later reads fortify.features to decide which routes to register, the registration feature is already gone. The register and register.store routes are never defined. A direct POST /register gets a genuine 404, not a "hidden" form that still accepts submissions. The login view hides the "Sign up" link with Route::has('register'), so the UI and the routing tell the same story.

The principle: when you disable a capability, prefer removing the surface over hiding it. A hidden form that still has a live endpoint is a soft target. A route that doesn't exist can't be posted to.

The Livewire screen is the easy part

The admin UI is a plain Livewire component — load the value in mount(), save it back, re-sync config for the rest of the current request so the change is visible immediately without a reload:

public function save(): void
{
    $this->authorize('manage.settings');

    $settings = app(AuthenticationSettings::class);
    $settings->public_registration_enabled = $this->publicRegistrationEnabled;
    $settings->save();

    // Keep the live config in sync for the rest of this request.
    config(['admin.public_registration' => $this->publicRegistrationEnabled]);

    $this->dispatch('toast', type: 'success', message: __('Authentication settings saved successfully!'));
}
Enter fullscreen mode Exit fullscreen mode

Note the $this->authorize('manage.settings') in both mount() and save() — gating the read and the write, because hiding a form is not authorization. And note that after saving, it re-applies the value to config() for the current request. The next request will pick it up via the boot-time overlay anyway, but this makes the change feel instant instead of taking effect "on the next page load."

The takeaway

Database-backed settings don't have to mean a second config system bolted onto the side of your app. Keep one read path — config() — and treat the database as a layer that gets painted over config at boot. Watch the two gotchas: values the framework consumes once during bootstrap (timezone, locale) need to be re-applied to their real target, not just to the config array; and the overlay itself has to survive the fresh-install moment when its own table doesn't exist yet. Get those right and the rest of your codebase never has to know the setting came from a database at all.

Top comments (0)