Settings Overview
SaaS4Builders ships a hierarchical settings system that manages configuration at three levels: Application (platform-wide defaults), Tenant (organization preferences), and User (individual preferences). The most specific scope always wins — if a user sets their locale to Italian but the tenant default is French, the user sees Italian.
All valid setting keys are defined in a single PHP enum (SettingKey), which serves as the central registry for key names, allowed scopes, types, defaults, and validation rules. There is no external configuration file — the code is the source of truth.
The Three-Level Cascade
Settings are resolved through a three-level hierarchy where more specific scopes override less specific ones:
- Application — Platform-wide defaults set by the platform administrator. These apply to every tenant and user unless overridden. Examples: app name, company information, default locale.
- Tenant — Organization-level preferences set by tenant admins. Override application defaults for all members of the tenant. Examples: preferred locale, timezone, currency.
- User — Individual preferences set by each user. Override both tenant and application values. Examples: personal locale, timezone, notification preferences.
If no value is found at any level, the system falls back to the code-level default defined in the setting's definition.
billing.company.*) is only available at the application scope, while notification preferences (notifications.*) are only available at the user scope. The SettingKey enum defines which scopes each setting supports.Setting Keys
All valid setting keys are defined in the SettingKey enum. Each case maps to a dot-notation string identifier:
enum SettingKey: string
{
// Application settings
case APP_NAME = 'app.name';
case APP_SUPPORT_EMAIL = 'app.support_email';
// Internationalization
case I18N_LOCALE = 'i18n.locale';
case I18N_FALLBACK_LOCALE = 'i18n.fallback_locale';
// Time
case TIME_TIMEZONE = 'time.timezone';
// Money
case MONEY_CURRENCY = 'money.currency';
case MONEY_FALLBACK_CURRENCY = 'money.fallback_currency';
// Company Information
case BILLING_COMPANY_NAME = 'billing.company.name';
case BILLING_COMPANY_ADDRESS = 'billing.company.address';
case BILLING_COMPANY_ADDRESS_LINE2 = 'billing.company.address_line2';
case BILLING_COMPANY_CITY = 'billing.company.city';
case BILLING_COMPANY_STATE = 'billing.company.state';
case BILLING_COMPANY_POSTAL_CODE = 'billing.company.postal_code';
case BILLING_COMPANY_COUNTRY = 'billing.company.country';
case BILLING_COMPANY_VAT_NUMBER = 'billing.company.vat_number';
// Branding
case APP_LOGO = 'app.logo';
case APP_EMAIL_LOGO = 'app.email_logo';
// Email
case MAIL_FROM_ADDRESS = 'mail.from.address';
case MAIL_FROM_NAME = 'mail.from.name';
case MAIL_REPLY_TO_ADDRESS = 'mail.reply_to.address';
case MAIL_REPLY_TO_NAME = 'mail.reply_to.name';
case APP_SUPPORT_NAME = 'app.support_name';
// Notifications
case NOTIFICATIONS_EMAIL_ENABLED = 'notifications.email_enabled';
case NOTIFICATIONS_DESKTOP_ENABLED = 'notifications.desktop_enabled';
}
The 23 keys are organized into eight groups:
Application Settings
| Key | Description | Default |
|---|---|---|
app.name | Platform display name | From config('app.name') |
app.support_email | Support contact email | support@example.com |
Internationalization
| Key | Description | Default |
|---|---|---|
i18n.locale | Active locale (en, fr, es, it) | en |
i18n.fallback_locale | Fallback when translation is missing | en |
Time
| Key | Description | Default |
|---|---|---|
time.timezone | IANA timezone identifier | UTC |
Money
| Key | Description | Default |
|---|---|---|
money.currency | Default currency (3-letter ISO 4217) | EUR |
money.fallback_currency | Fallback when currency is unavailable | EUR |
Company Information
| Key | Description | Default |
|---|---|---|
billing.company.name | Legal business name | Your Company |
billing.company.address | Primary address line | Empty |
billing.company.address_line2 | Secondary address line | null |
billing.company.city | City | Empty |
billing.company.state | State or province | null |
billing.company.postal_code | ZIP / postal code | Empty |
billing.company.country | ISO 3166-1 alpha-2 country code | FR |
billing.company.vat_number | VAT / tax ID | Empty |
Branding
| Key | Description | Default |
|---|---|---|
app.logo | Application logo file path | null |
app.email_logo | Email template logo file path | null |
| Key | Description | Default |
|---|---|---|
mail.from.address | Sender email address | From config('mail.from.address') |
mail.from.name | Sender display name | From config('mail.from.name') |
mail.reply_to.address | Reply-to email address | null |
mail.reply_to.name | Reply-to display name | null |
app.support_name | Support contact display name | null |
Notifications
| Key | Description | Default |
|---|---|---|
notifications.email_enabled | Email notifications enabled | true |
notifications.desktop_enabled | Desktop notifications enabled | false |
Scope Matrix
This table defines which scopes each setting supports. A checkmark means the setting can be set at that scope:
| Setting Key | Application | Tenant | User |
|---|---|---|---|
app.name | ✓ | — | — |
app.support_email | ✓ | — | — |
i18n.locale | ✓ | ✓ | ✓ |
i18n.fallback_locale | ✓ | — | — |
time.timezone | ✓ | ✓ | ✓ |
money.currency | ✓ | ✓ | — |
money.fallback_currency | ✓ | — | — |
billing.company.name | ✓ | — | — |
billing.company.address | ✓ | — | — |
billing.company.address_line2 | ✓ | — | — |
billing.company.city | ✓ | — | — |
billing.company.state | ✓ | — | — |
billing.company.postal_code | ✓ | — | — |
billing.company.country | ✓ | — | — |
billing.company.vat_number | ✓ | — | — |
app.logo | ✓ | — | — |
app.email_logo | ✓ | — | — |
mail.from.address | ✓ | — | — |
mail.from.name | ✓ | — | — |
mail.reply_to.address | ✓ | — | — |
mail.reply_to.name | ✓ | — | — |
app.support_name | ✓ | — | — |
notifications.email_enabled | — | — | ✓ |
notifications.desktop_enabled | — | — | ✓ |
Key observations:
- Two settings support all three scopes:
i18n.locale,time.timezone - One setting supports application + tenant:
money.currency - Two settings are user-only:
notifications.email_enabled,notifications.desktop_enabled - All remaining settings are application-only (platform admin managed)
Resolution Algorithm
The SettingsResolver service implements the cascade. When resolving a setting, it checks scopes from most specific to least specific:
public function resolve(SettingKey $key, ?Tenant $tenant = null, ?User $user = null): array
{
$definition = $key->definition();
// 1. Try user scope (highest priority)
if ($user !== null && $key->allowedAtScope(SettingScope::User)) {
$value = $this->userSettings->get($user->id, $key);
if ($value !== null) {
return ['value' => $value, 'source' => 'user'];
}
}
// 2. Try tenant scope
if ($tenant !== null && $key->allowedAtScope(SettingScope::Tenant)) {
$value = $this->tenantSettings->get($tenant->id, $key);
if ($value !== null) {
return ['value' => $value, 'source' => 'tenant'];
}
}
// 3. Try application scope
if ($key->allowedAtScope(SettingScope::Application)) {
$value = $this->appSettings->get($key);
if ($value !== null) {
return ['value' => $value, 'source' => 'application'];
}
}
// 4. Fall back to registry default
return ['value' => $definition->default, 'source' => 'default'];
}
Every resolved setting returns both the value and its source (which scope it came from: user, tenant, application, or default). This lets the frontend show users where a setting originates — for example, displaying "inherited from organization" when a tenant-level setting is in effect.
Setting Definitions
Each setting key carries a SettingDefinition value object that describes its metadata:
final readonly class SettingDefinition
{
public function __construct(
public array $scopes, // Allowed scopes (e.g., [Application, Tenant, User])
public string $type, // Storage type: string, bool, int, enum, email, timezone, currency
public mixed $default, // Default value when not set in database
public ?array $allowedValues = null, // For enum types: list of valid values
public ?string $validation = null, // Additional Laravel validation rules
public bool $sensitive = false, // If true, excluded from public API responses
) {}
}
For example, the locale setting is defined as:
self::I18N_LOCALE => new SettingDefinition(
scopes: [SettingScope::Application, SettingScope::Tenant, SettingScope::User],
type: 'enum',
default: config('app.locale', 'en'),
allowedValues: ['en', 'fr', 'es', 'it'],
),
See Defining Settings for the full guide on adding new settings, supported types, and validation.
Architecture Overview
The settings feature spans both backend and frontend:
backend/app/
├── Enums/
│ ├── SettingKey.php # Central registry of all setting keys
│ └── SettingScope.php # Application, Tenant, User enum
├── Settings/
│ └── SettingDefinition.php # Value object: scopes, type, default, validation
├── Casts/
│ └── SettingValueCast.php # JSON {"v": ...} wrapper for type-safe storage
├── Models/
│ ├── ApplicationSetting.php # Platform-wide settings model
│ ├── TenantSetting.php # Tenant-scoped settings model
│ └── UserSetting.php # User-scoped settings model
├── Repositories/
│ ├── ApplicationSettingsRepository.php
│ ├── TenantSettingsRepository.php
│ └── UserSettingsRepository.php
├── Services/
│ ├── SettingsResolver.php # Cascaded resolution logic
│ └── SettingsCacheManager.php # Redis caching with scope-specific TTLs
├── Http/
│ ├── Controllers/Api/V1/
│ │ ├── Settings/ # Effective, User, Tenant, Admin controllers
│ │ ├── Admin/Settings/ # Admin view + logo management
│ │ └── Public/Settings/ # Public settings (no auth)
│ └── Requests/Settings/ # Validation with scope enforcement
└── Application/Admin/Actions/
├── UploadSettingsLogo.php
└── DeleteSettingsLogo.php
frontend/
├── features/foundation/docs/settings/
│ ├── types.ts # TypeScript types and payloads
│ ├── schemas.ts # Zod validation schemas
│ ├── index.ts # Barrel exports
│ ├── stores/
│ │ └── useSettingsStore.ts # Pinia store (load, update, cache)
│ └── composables/
│ ├── useSettings.ts # Read facade (get, getWithSource)
│ ├── useUserSettings.ts # User-level mutations
│ ├── useTenantSettings.ts # Tenant-level mutations
│ ├── useSettingsLocale.ts # i18n integration + locale switching
│ ├── useSettingsTimezone.ts # Timezone + date formatting
│ └── useSettingsCurrency.ts # Currency + money formatting
├── features/product/platform/
│ ├── composables/
│ │ ├── useAdminSettings.ts # Platform admin read
│ │ └── useAdminSettingsActions.ts # Platform admin mutations
│ └── components/
│ ├── CompanySettingsForm.vue
│ ├── EmailSettingsForm.vue
│ ├── RegionalSettingsForm.vue
│ └── BrandingSettingsForm.vue
└── app/plugins/
├── 00.settings-public.ts # SSR: loads app name + logo
└── settings.client.ts # Client: loads on auth, syncs i18n
| Layer | Key Files | Purpose |
|---|---|---|
| Domain | SettingKey, SettingScope | Type-safe enums for keys and scopes |
| Domain | SettingDefinition | Metadata per key: scopes, type, validation, default |
| Data | ApplicationSetting, TenantSetting, UserSetting | Eloquent models, one per scope |
| Data | SettingValueCast | JSON wrapper for type-safe storage |
| Repository | *SettingsRepository | CRUD operations per scope with cache integration |
| Service | SettingsResolver | Cascaded resolution: User > Tenant > Application > default |
| Service | SettingsCacheManager | Redis caching with automatic invalidation |
| HTTP | Controllers, Requests | API endpoints with scope-aware validation |
| Frontend | useSettingsStore | Pinia store: load, update, promise deduplication |
| Frontend | Composables | Read/write facades with i18n, timezone, currency integration |
API Endpoints
The settings system exposes eight API endpoints across four authorization levels:
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/v1/docs/settings/public | None | Public settings (app name, logo, currency) |
GET | /api/v1/docs/settings/effective | Authenticated | All settings with cascade resolution |
PATCH | /api/v1/user/docs/settings | Authenticated | Update user preferences |
PATCH | /api/v1/tenant/docs/settings | Tenant + tenant.update | Update tenant settings |
GET | /api/v1/admin/docs/settings | Platform admin | All application settings with metadata |
PATCH | /api/v1/admin/docs/settings | Platform admin | Update application settings |
POST | /api/v1/admin/docs/settings/logo | Platform admin | Upload app or email logo |
DELETE | /api/v1/admin/docs/settings/logo/{type} | Platform admin | Delete a logo |
See Reading & Writing Settings for detailed request/response examples for each endpoint.
What's Next
- Defining Settings — How to add new settings, supported types, validation rules, and database storage
- Reading & Writing Settings — API endpoints, frontend composables, and plugin architecture
- Caching Strategy — Redis caching, automatic invalidation, and frontend deduplication
Seat-Based Billing Integration
How team size connects to subscription billing: seat quotas, entitlement checks, no-subscription modes, automatic seat syncing, and frontend awareness.
Defining Settings
How to add new settings to the registry: SettingKey enum, SettingDefinition metadata, supported types, validation rules, database storage, and value casting.