Skip to content
SaaS4Builders
Settings

Caching Strategy

Settings caching architecture: SettingsCacheManager with scope-specific TTLs, automatic invalidation on write, HTTP-level caching for public settings, and frontend promise deduplication.

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:

backend/app/Services/SettingsCacheManager.php
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);
    }
}
ScopeTTLCache KeyDescription
Application1 hour (3600s)settings:appSingle shared key for all platform settings
Tenant10 minutes (600s)settings:tenant:{tenantId}One key per tenant
User10 minutes (600s)settings:user:{userId}One key per user
Application settings use a longer TTL because they change rarely (only through admin actions). Tenant and user settings use a shorter TTL because they may change during an active session — for example, when a user switches their locale or a tenant admin updates the organization currency.

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:

backend/app/Repositories/ApplicationSettingsRepository.php
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:

backend/app/Repositories/TenantSettingsRepository.php
public function update(string $tenantId, array $settings): array
{
    // ... updateOrCreate for each key ...

    $this->cache->invalidateTenant($tenantId);

    return $updated;
}
Cache invalidation is immediate on the server that performed the write. In a multi-server deployment behind a load balancer, other servers will serve the stale cached version until its TTL expires. For most SaaS applications, the 10-minute TTL for tenant/user settings is acceptable. If you need stronger consistency, you can reduce the TTLs or use a shared Redis instance (the default configuration).

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:

backend/app/Http/Controllers/Api/V1/Public/Settings/PublicSettingsController.php
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 public since 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):

frontend/features/foundation/docs/settings/stores/useSettingsStore.ts
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:

frontend/app/plugins/docs/settings.client.ts
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 isInitialized check 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:

frontend/app/plugins/00.settings-public.ts
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. Individual get() calls delegate to all(), so there is no N+1 problem — one cache hit serves any number of get() calls.
  • Logo URLs are computed on read: The EffectiveSettingsController calls Storage::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's skipCaseTransform option preserves the original key format.
  • After updates, the store reloads: Both updateUser() and updateTenant() in the Pinia store call reload() 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