Skip to content
SaaS4Builders
Settings

Reading & Writing Settings

How to read and write settings: SettingsResolver cascade, scope-specific repositories, API endpoints with examples, custom validation, authorization, and frontend composables.

Settings can be read through the cascaded SettingsResolver (which applies scope precedence automatically) or directly from scope-specific repositories. Writes go through scope-specific API endpoints, each with appropriate authorization. The frontend provides a set of composables that mirror these capabilities with TypeScript type safety.


Backend: The SettingsResolver

The SettingsResolver is the primary way to read settings. It applies the three-level cascade and returns both the effective value and its source:

backend/app/Services/SettingsResolver.php
final class SettingsResolver
{
    public function __construct(
        private readonly ApplicationSettingsRepository $appSettings,
        private readonly TenantSettingsRepository $tenantSettings,
        private readonly UserSettingsRepository $userSettings,
    ) {}

    public function get(SettingKey $key, ?Tenant $tenant = null, ?User $user = null): mixed
    {
        return $this->resolve($key, $tenant, $user)['value'];
    }

    public function resolve(SettingKey $key, ?Tenant $tenant = null, ?User $user = null): array
    {
        // Checks User → Tenant → Application → default
        // Returns ['value' => ..., 'source' => 'user'|'tenant'|'application'|'default']
    }

    public function all(?Tenant $tenant = null, ?User $user = null): array
    {
        // Returns all non-sensitive settings with their resolved values and sources
    }
}

Usage in application code:

$locale = $resolver->get(SettingKey::I18N_LOCALE, $tenant, $user);

$result = $resolver->resolve(SettingKey::I18N_LOCALE, $tenant, $user);
// $result = ['value' => 'fr', 'source' => 'tenant']

Scope-Specific Repositories

Each scope has a dedicated repository with get(), all(), and update() methods. Repositories cache reads and invalidate on writes automatically through the SettingsCacheManager:

backend/app/Repositories/ApplicationSettingsRepository.php
final class ApplicationSettingsRepository
{
    public function get(SettingKey $key): mixed
    {
        $all = $this->all();
        return $all[$key->value] ?? null;
    }

    public function all(): array
    {
        return $this->cache->rememberApplication(function (): array {
            return ApplicationSetting::query()
                ->pluck('value', 'key')
                ->toArray();
        });
    }

    public function update(array $settings): array
    {
        $updated = [];
        foreach ($settings as $key => $value) {
            ApplicationSetting::updateOrCreate(
                ['key' => $key],
                ['value' => $value]
            );
            $updated[] = $key;
        }
        $this->cache->invalidateApplication();
        return $updated;
    }
}

The TenantSettingsRepository and UserSettingsRepository follow the same pattern, scoped by tenant_id and user_id respectively. Each invalidates its own cache after writing. See Caching Strategy for details.


API Endpoints

GET /api/v1/docs/settings/public

Returns a small set of non-sensitive settings without authentication. Used by the frontend during SSR and on public pages (login, register).

Auth: None Cache: Cache-Control: public, max-age=300 (5 minutes)

Response:

{
  "data": {
    "app.name": { "value": "My SaaS", "source": "application" },
    "app.logo": {
      "value": "settings/logos/logo.png",
      "source": "application",
      "url": "https://storage.example.com/public/docs/settings/logos/logo.png"
    },
    "money.currency": { "value": "EUR", "source": "application" },
    "money.fallback_currency": { "value": "EUR", "source": "default" }
  },
  "meta": {
    "active_currencies": [
      { "code": "EUR", "name": "Euro", "minor_units": 2 },
      { "code": "USD", "name": "US Dollar", "minor_units": 2 }
    ]
  }
}

Only four keys are exposed: app.name, app.logo, money.currency, and money.fallback_currency. The meta.active_currencies array lists all currencies the platform supports.

GET /api/v1/docs/settings/effective

Returns all non-sensitive settings resolved for the current user's context (user + tenant + application + defaults).

Auth: Authenticated (Sanctum)

Response:

{
  "data": {
    "app.name": { "value": "My SaaS", "source": "application" },
    "i18n.locale": { "value": "fr", "source": "tenant" },
    "time.timezone": { "value": "Europe/Paris", "source": "user" },
    "money.currency": { "value": "USD", "source": "tenant" },
    "notifications.email_enabled": { "value": true, "source": "default" },
    "app.logo": {
      "value": "settings/logos/logo.png",
      "source": "application",
      "url": "https://storage.example.com/public/docs/settings/logos/logo.png"
    }
  }
}

Each setting includes both its resolved value and the source scope it came from. Logo settings include a url field with the public storage URL.

PATCH /api/v1/user/docs/settings

Updates settings at the user scope. Users can only update settings allowed at the user scope (i18n.locale, time.timezone, notifications.email_enabled, notifications.desktop_enabled).

Auth: Authenticated (Sanctum)

Request:

{
  "i18n.locale": "it",
  "time.timezone": "Europe/Rome"
}

Response:

{
  "data": {
    "updated": ["i18n.locale", "time.timezone"]
  }
}

PATCH /api/v1/tenant/docs/settings

Updates settings at the tenant scope. Requires the tenant.update permission (owner or admin role).

Auth: Authenticated + tenant member + tenant.update permission

Request:

{
  "i18n.locale": "fr",
  "time.timezone": "Europe/Paris",
  "money.currency": "EUR"
}

Response:

{
  "data": {
    "updated": ["i18n.locale", "time.timezone", "money.currency"]
  }
}

GET /api/v1/admin/docs/settings

Returns all application-scope settings with type metadata for the admin UI. Each setting includes a type field (e.g., "string", "enum") used by the frontend to render appropriate form controls.

Auth: Platform admin with settings.view permission

PATCH /api/v1/admin/docs/settings

Updates application-scope settings. Same request/response format as the other PATCH endpoints.

Auth: Platform admin with settings.manage permission

Request:

{
  "app.name": "My Awesome SaaS",
  "billing.company.name": "Acme Corp",
  "billing.company.country": "US"
}

Uploads a logo image. Accepts multipart/form-data with a file field and a type field (app_logo or email_logo).

Auth: Platform admin with settings.manage permission

Constraints:

  • Max file size: 2 MB
  • Accepted formats: JPEG, PNG, SVG, WebP
  • SVG files are sanitized (remote references removed to prevent XSS)
  • Files stored in storage/app/public/docs/settings/logos/

Returns { data: { path, url }, message }.

DELETE /api/v1/admin/docs/settings/logo/{type}

Deletes a logo (app_logo or email_logo). Removes the file from disk and sets the corresponding setting to null.

Auth: Platform admin with settings.manage permission


Custom Validation

Setting keys use dot notation (e.g., billing.company.name), which Laravel's standard validator interprets as nested array access (billingcompanyname). This would break validation of flat key-value pairs.

To work around this, all three update requests (UpdateUserSettingsRequest, UpdateTenantSettingsRequest, UpdateApplicationSettingsRequest) use an empty rules() method and perform manual validation in the after() callback:

backend/app/Http/Requests/Settings/UpdateUserSettingsRequest.php
public function rules(): array
{
    // Empty — dot-notation keys break Laravel's array-based validation
    return [];
}

public function after(): array
{
    return [
        function (Validator $validator): void {
            $this->validateSettings($validator, SettingScope::User);
        },
    ];
}

private function validateSettings(Validator $validator, SettingScope $scope): void
{
    foreach ($this->all() as $key => $value) {
        $settingKey = SettingKey::tryFromString($key);

        // Check if key is registered
        if ($settingKey === null) {
            $validator->errors()->add($key, __('settings.unknown_key', ['key' => $key]));
            continue;
        }

        // Check if key is allowed at this scope
        if (! $settingKey->allowedAtScope($scope)) {
            $validator->errors()->add($key, __('settings.scope_violation', [
                'key' => $key, 'scope' => $scope->value
            ]));
            continue;
        }

        // Validate value against the setting's type-specific rules
        $rules = $settingKey->definition()->validationRules();
        if (! empty($rules)) {
            $valueValidator = ValidatorFacade::make(
                ['value' => $value],
                ['value' => $rules]
            );

            if ($valueValidator->fails()) {
                foreach ($valueValidator->errors()->get('value') as $error) {
                    $validator->errors()->add($key, $error);
                }
            }
        }
    }
}

This approach validates each key independently: first confirming it's a known setting, then checking it's allowed at the target scope, and finally running the setting's type-specific validation rules against the provided value.

If you send an unknown key or a key that isn't allowed at the target scope, the API returns a 422 validation error with a descriptive message per key. For example, sending billing.company.name to the user settings endpoint returns: "The setting 'billing.company.name' cannot be set at user scope."

Authorization

EndpointAuth RequiredPermissionWho Can Access
GET /docs/settings/publicNoAnyone (public pages, SSR)
GET /docs/settings/effectiveYesAny authenticated user
PATCH /user/docs/settingsYesAny authenticated user (own settings only)
PATCH /tenant/docs/settingsYestenant.updateTenant owner or admin
GET /admin/docs/settingsYessettings.viewPlatform administrator
PATCH /admin/docs/settingsYessettings.managePlatform administrator
POST /admin/docs/settings/logoYessettings.managePlatform administrator
DELETE /admin/docs/settings/logo/{type}Yessettings.managePlatform administrator

Frontend

The frontend settings layer is organized into composables that provide type-safe read and write access.

useSettings() — Read Facade

The primary composable for reading settings. Returns computed refs for common settings and typed accessor methods:

frontend/features/foundation/docs/settings/composables/useSettings.ts
export function useSettings() {
  const store = useSettingsStore()

  return {
    // State
    settings: computed(() => store.settings),
    loaded: computed(() => store.isLoaded),
    loading: computed(() => store.isLoading),
    error: computed(() => store.error),

    // Shortcuts for common settings
    locale: computed(() => store.locale),
    timezone: computed(() => store.timezone),
    currency: computed(() => store.currency),
    fallbackCurrency: computed(() => store.fallbackCurrency),
    appName: computed(() => store.appName),
    publicCurrencies: computed(() => store.publicCurrencies),

    // Methods
    load: () => store.load(),
    reload: () => store.reload(),
    clear: () => store.clear(),
    get: store.get,            // get<K>(key, fallback) → typed value
    getWithSource: store.getWithSource,  // getWithSource<K>(key) → { value, source }
    has: store.has,
  }
}

Usage:

const { locale, timezone, get, getWithSource } = useSettings()

// Typed access with fallback
const appName = get('app.name', 'SaaS4Builders')

// Check where a setting comes from
const result = getWithSource('i18n.locale')
// result = { value: 'fr', source: 'tenant' }

useUserSettings() — User Mutations

Updates settings at the user scope:

frontend/features/foundation/docs/settings/composables/useUserSettings.ts
export function useUserSettings() {
  const store = useSettingsStore()

  return {
    updating: computed(() => store.isUpdatingUser),
    error: computed(() => store.userError),

    update: (payload: UserSettingsPayload) => store.updateUser(payload),
    updateLocale: (locale: LocaleCode) => store.updateUser({ 'i18n.locale': locale }),
    updateTimezone: (timezone: string) => store.updateUser({ 'time.timezone': timezone }),
  }
}

After every update, the store automatically reloads effective settings to reflect changes from the cascade.

useTenantSettings() — Tenant Mutations

Updates settings at the tenant scope (requires tenant.update permission):

frontend/features/foundation/docs/settings/composables/useTenantSettings.ts
export function useTenantSettings() {
  const store = useSettingsStore()

  return {
    updating: computed(() => store.isUpdatingTenant),
    error: computed(() => store.tenantError),

    update: (payload: TenantSettingsPayload) => store.updateTenant(payload),
  }
}

Usage:

const { update, updating } = useTenantSettings()

await update({
  'i18n.locale': 'fr',
  'time.timezone': 'Europe/Paris',
  'money.currency': 'EUR',
})

useSettingsLocale() — i18n Integration

Handles locale switching with both frontend navigation and backend persistence:

frontend/features/foundation/docs/settings/composables/useSettingsLocale.ts
export function useSettingsLocale() {
  const settings = useSettings()
  const userSettings = useUserSettings()
  const switchLocalePath = useSwitchLocalePath()
  const auth = useAuth()

  const currentLocale = computed<LocaleCode>(() => settings.get('i18n.locale', 'en'))

  const availableLocales = computed(() =>
    configuredLocales.map((l) => ({
      code: l.code as LocaleCode,
      name: l.name,
    }))
  )

  async function changeLocale(newLocale: LocaleCode): Promise<void> {
    // Navigate to locale-prefixed route for immediate UI switch
    await navigateTo(switchLocalePath(newLocale))

    // Persist to backend if authenticated
    if (auth.isAuthenticated.value) {
      await userSettings.updateLocale(newLocale)
    }
  }

  return { currentLocale, availableLocales, changeLocale, isUpdating: userSettings.updating }
}

The key design: changeLocale() first navigates to update the UI instantly via Nuxt's locale-prefixed routing, then persists the preference to the backend. This gives users immediate feedback without waiting for the API round-trip.

useSettingsTimezone() — Date Formatting

Provides date and time formatting using the user's timezone and locale via Intl.DateTimeFormat:

const { currentTimezone, formatDateTime, formatRelative, changeTimezone } = useSettingsTimezone()

// Format a date in the user's timezone and locale
formatDateTime(new Date())           // "Mar 26, 2026, 02:30 PM"
formatRelative('2026-03-26T12:00Z')  // "2 hours ago"

// Change timezone (persists to backend)
await changeTimezone('Europe/Paris')

The composable exposes formatDate (custom options), formatDateTime, formatDateOnly, formatTimeOnly, and formatRelative. All use Intl.DateTimeFormat / Intl.RelativeTimeFormat with the user's locale and timezone from settings.

useSettingsCurrency() — Money Formatting

Formats monetary amounts using the tenant's currency and user's locale via Intl.NumberFormat:

const { currentCurrency, formatMoney, formatMoneyCents } = useSettingsCurrency()

formatMoney(99.99)        // "99,99 €" (FR locale with EUR)
formatMoneyCents(9999)    // "99,99 €" (converts from cents)
formatMoneyCompact(1000)  // "1 000 €" (no decimals)

The composable also provides formatMoneyWithCurrency() to override the tenant currency for a specific amount — useful when displaying invoices in their original currency.

Admin Composables

Platform administrators use two composables from the product/platform layer:

  • useAdminSettings() — Fetches all application settings with type metadata via GET /api/v1/admin/docs/settings. Returns typed getters for values, sources, and logo URLs.
  • useAdminSettingsActions() — Provides updateSettings(), uploadLogo(), and deleteLogo() methods with loading state and error handling.

The admin settings page at /manager/docs/settings/general uses four form components — CompanySettingsForm, EmailSettingsForm, RegionalSettingsForm, and BrandingSettingsForm — each handling a group of related settings.


Plugins

Two Nuxt plugins manage the settings lifecycle:

Public Settings (SSR)

The 00.settings-public.ts plugin runs during SSR (universal, no .client suffix). The 00. prefix ensures it runs before auth plugins. It loads the app name and logo without authentication so they are available on the initial page render:

frontend/app/plugins/00.settings-public.ts
export default defineNuxtPlugin(async () => {
  if (process.env.VITEST) return

  const settingsStore = useSettingsStore()
  await settingsStore.loadPublic()
})

The Pinia state is serialized into the SSR payload (__NUXT__.pinia), so the client does not re-fetch public settings.

Authenticated Settings (Client)

The settings.client.ts plugin runs client-side only. It loads effective settings when the user is authenticated and watches for auth state changes:

frontend/app/plugins/docs/settings.client.ts
export default defineNuxtPlugin(async (nuxtApp) => {
  const authStore = useAuthStore()
  const settingsStore = useSettingsStore()

  async function applyLocale(): Promise<void> {
    const userLocale = settingsStore.get('i18n.locale', 'en')
    const i18n = nuxtApp.$i18n
    if (userLocale && i18n?.setLocale) {
      await i18n.setLocale(userLocale)
    }
  }

  // Initial load if already authenticated
  if (authStore.isAuthenticated) {
    await nuxtApp.runWithContext(() => settingsStore.load())
    await applyLocale()
  }

  // Watch auth changes — load on login, clear on logout
  watch(
    [() => authStore.isAuthenticated, () => authStore.isInitialized],
    async ([isAuth, isInit]) => {
      if (isAuth && isInit) {
        await nuxtApp.runWithContext(() => settingsStore.load())
        await applyLocale()
      } else if (!isAuth) {
        settingsStore.clear()
      }
    }
  )
})
The watcher guards with isInitialized to prevent firing before the login flow completes. This avoids a race condition where the settings API would return 401 because the auth token is not yet available.

What's Next

  • Caching Strategy — Redis caching with scope-specific TTLs, automatic invalidation, and frontend deduplication