Reading & Writing Settings
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:
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:
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"
}
POST /api/v1/admin/docs/settings/logo
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 (billing → company → name). 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:
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.
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
| Endpoint | Auth Required | Permission | Who Can Access |
|---|---|---|---|
GET /docs/settings/public | No | — | Anyone (public pages, SSR) |
GET /docs/settings/effective | Yes | — | Any authenticated user |
PATCH /user/docs/settings | Yes | — | Any authenticated user (own settings only) |
PATCH /tenant/docs/settings | Yes | tenant.update | Tenant owner or admin |
GET /admin/docs/settings | Yes | settings.view | Platform administrator |
PATCH /admin/docs/settings | Yes | settings.manage | Platform administrator |
POST /admin/docs/settings/logo | Yes | settings.manage | Platform administrator |
DELETE /admin/docs/settings/logo/{type} | Yes | settings.manage | Platform 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:
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:
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):
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:
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 viaGET /api/v1/admin/docs/settings. Returns typed getters for values, sources, and logo URLs.useAdminSettingsActions()— ProvidesupdateSettings(),uploadLogo(), anddeleteLogo()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:
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:
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()
}
}
)
})
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
Defining Settings
How to add new settings to the registry: SettingKey enum, SettingDefinition metadata, supported types, validation rules, database storage, and value casting.
Caching Strategy
Settings caching architecture: SettingsCacheManager with scope-specific TTLs, automatic invalidation on write, HTTP-level caching for public settings, and frontend promise deduplication.