Caching Strategy
Settings are cached at three levels with different TTLs to balance freshness and performance. The backend uses Redis via Laravel's Cache facade, with automatic invalidation on every write. The frontend uses promise deduplication to prevent concurrent fetch races and watches auth state to keep settings in sync.
SettingsCacheManager
The SettingsCacheManager manages Redis caching for all three scopes. Each scope has its own cache key pattern and TTL:
final class SettingsCacheManager
{
private const TTL_APPLICATION = 3600; // 1 hour
private const TTL_SCOPED = 600; // 10 minutes
private const KEY_APPLICATION = 'settings:app';
private const KEY_TENANT_PREFIX = 'settings:tenant:';
private const KEY_USER_PREFIX = 'settings:user:';
public function rememberApplication(Closure $callback): mixed
{
return Cache::remember(self::KEY_APPLICATION, self::TTL_APPLICATION, $callback);
}
public function rememberTenant(string $tenantId, Closure $callback): mixed
{
return Cache::remember(
self::KEY_TENANT_PREFIX . $tenantId,
self::TTL_SCOPED,
$callback
);
}
public function rememberUser(int $userId, Closure $callback): mixed
{
return Cache::remember(
self::KEY_USER_PREFIX . $userId,
self::TTL_SCOPED,
$callback
);
}
public function invalidateApplication(): void
{
Cache::forget(self::KEY_APPLICATION);
}
public function invalidateTenant(string $tenantId): void
{
Cache::forget(self::KEY_TENANT_PREFIX . $tenantId);
}
public function invalidateUser(int $userId): void
{
Cache::forget(self::KEY_USER_PREFIX . $userId);
}
}
| Scope | TTL | Cache Key | Description |
|---|---|---|---|
| Application | 1 hour (3600s) | settings:app | Single shared key for all platform settings |
| Tenant | 10 minutes (600s) | settings:tenant:{tenantId} | One key per tenant |
| User | 10 minutes (600s) | settings:user:{userId} | One key per user |
Cache Invalidation
Invalidation is automatic: every repository's update() method calls the corresponding invalidate*() method on the cache manager after writing to the database. This ensures the next read always gets fresh data.
Here is the pattern from ApplicationSettingsRepository:
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 tenant and user repositories follow the same pattern:
public function update(string $tenantId, array $settings): array
{
// ... updateOrCreate for each key ...
$this->cache->invalidateTenant($tenantId);
return $updated;
}
Public Settings Cache
The public settings endpoint (GET /api/v1/docs/settings/public) adds an HTTP-level Cache-Control header so browsers and CDNs cache the response:
private const CACHE_TTL_SECONDS = 300;
// In __invoke():
return response()->json([
'data' => $settings,
'meta' => ['active_currencies' => $activeCurrencies],
])->header('Cache-Control', 'public, max-age=' . self::CACHE_TTL_SECONDS);
This means:
- Browsers cache the response for 5 minutes before making a new request
- CDN/reverse proxy layers (if configured) can also serve this from cache
- The response is intentionally
publicsince it contains no user-specific data
This is particularly useful for SSR, where the Nuxt server fetches public settings on every page render. The cache prevents this from becoming a bottleneck.
Frontend Caching
Promise Deduplication
The Pinia settings store uses promise deduplication to prevent race conditions when multiple consumers call load() concurrently (e.g., the settings plugin and a route middleware both trigger loading during app initialization):
let _loadPromise: Promise<void> | null = null
async function load(): Promise<void> {
if (isLoaded.value) return
if (_loadPromise) return _loadPromise
_loadPromise = (async () => {
isLoading.value = true
error.value = null
try {
const response = await apiClient.get<{ data: EffectiveSettings }>(
'/api/v1/docs/settings/effective',
{ skipCaseTransform: true }
)
const parsed = effectiveSettingsResponseSchema.parse(response)
settings.value = parsed.data as EffectiveSettings
isLoaded.value = true
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to load settings'
error.value = message
throw e
} finally {
isLoading.value = false
_loadPromise = null
}
})()
return _loadPromise
}
If load() is called while a request is already in flight, the second caller joins the existing promise rather than making a duplicate API request. The promise reference is cleared after completion (success or failure) so the next call makes a fresh request.
Auth State Watching
The client-side settings plugin watches the auth store and responds to login/logout events:
watch(
[() => authStore.isAuthenticated, () => authStore.isInitialized],
async ([isAuth, isInit]) => {
if (isAuth && isInit) {
// Login: load effective settings and apply locale
await nuxtApp.runWithContext(() => settingsStore.load())
await applyLocale()
} else if (!isAuth) {
// Logout: clear all user/tenant settings
settingsStore.clear()
}
}
)
This ensures:
- On login: Settings are loaded fresh for the newly authenticated user (including their personal preferences and tenant overrides)
- On logout: All user-specific settings are cleared, preventing data from leaking between sessions
- Guard condition: The
isInitializedcheck prevents the watcher from firing before the auth flow completes, avoiding 401 errors from premature API calls
Public Settings in SSR
The 00.settings-public.ts plugin loads public settings (app name, logo) during server-side rendering. The Pinia state is automatically serialized into the SSR payload, so the client hydrates from the payload without making a duplicate request:
export default defineNuxtPlugin(async () => {
if (process.env.VITEST) return
const settingsStore = useSettingsStore()
await settingsStore.loadPublic()
})
The loadPublic() method uses $fetch directly (bypassing the authenticated API client) and merges public settings into the store without overwriting any already-loaded authenticated settings.
Performance Tips
- Bulk reads with
all(): Repositories load all settings for a scope in a single query and cache the result. Individualget()calls delegate toall(), so there is no N+1 problem — one cache hit serves any number ofget()calls. - Logo URLs are computed on read: The
EffectiveSettingsControllercallsStorage::disk('public')->url()to generate public URLs for logo settings. This avoids storing full URLs in the database (which would break on domain changes). - Settings API uses
skipCaseTransform: Setting keys use dot notation (billing.company.name), which must not be transformed to camelCase. The frontend API client'sskipCaseTransformoption preserves the original key format. - After updates, the store reloads: Both
updateUser()andupdateTenant()in the Pinia store callreload()after a successful API update. This ensures the cascade is re-evaluated — for example, if a user deletes their locale override, the tenant's locale becomes effective.
What's Next
- Settings Overview — Full setting key reference and scope matrix
- Defining Settings — How to add new settings with types and validation
- Reading & Writing Settings — API endpoint details and frontend composable usage
Reading & Writing Settings
How to read and write settings: SettingsResolver cascade, scope-specific repositories, API endpoints with examples, custom validation, authorization, and frontend composables.
Notifications Overview
Real-time notification architecture with WebSocket broadcasting via Laravel Reverb and Web Push delivery, supporting tenant and admin notification channels.