Defining Settings
Settings in SaaS4Builders are defined centrally in the SettingKey enum. Each key carries a SettingDefinition that describes its allowed scopes, data type, default value, and validation rules. There is no external YAML or JSON configuration — the code is the single source of truth.
This page walks through how to add a new setting, the available types and validation, and how values are stored in the database.
How to Add a New Setting
Adding a setting is a four-step process:
Step 1: Add a Case to the SettingKey Enum
Open backend/app/Enums/SettingKey.php and add a new case with a dot-notation string value:
enum SettingKey: string
{
// ... existing cases
case MY_FEATURE_ENABLED = 'my_feature.enabled';
}
The dot-notation convention groups related settings (e.g., billing.company.*, mail.from.*). The string value is what gets stored in the database and used in API requests.
Step 2: Add a Definition
In the same file, add a match arm to the definition() method. The definition specifies which scopes the setting supports, its type, default value, and validation:
public function definition(): SettingDefinition
{
return match ($this) {
// ... existing definitions
self::MY_FEATURE_ENABLED => new SettingDefinition(
scopes: [SettingScope::Application, SettingScope::Tenant],
type: 'bool',
default: false,
),
};
}
Here is a real example — the currency setting, which includes custom validation:
self::MONEY_CURRENCY => new SettingDefinition(
scopes: [SettingScope::Application, SettingScope::Tenant],
type: 'currency',
default: config('billing.default_currency', 'EUR'),
),
The currency type automatically adds validation for a 3-character uppercase string and checks that the currency exists in the active currency registry.
Step 3: Seed Application Defaults
If the setting is allowed at the application scope, run the seeder to populate its default value:
docker compose exec php php artisan db:seed --class=ApplicationSettingsSeeder
The seeder iterates over all application-scope keys and creates or updates their defaults:
class ApplicationSettingsSeeder extends Seeder
{
public function run(): void
{
$applicationKeys = SettingKey::forScope(SettingScope::Application);
foreach ($applicationKeys as $key) {
$definition = $key->definition();
ApplicationSetting::updateOrCreate(
['key' => $key->value],
['value' => $definition->default]
);
}
}
}
Step 4: Add Frontend Support (Optional)
If the setting needs to be accessible on the frontend, add it to the TypeScript types in frontend/features/foundation/docs/settings/types.ts:
// Add the key to the SettingKey union type
type SettingKey = 'i18n.locale' | 'time.timezone' | /* ... */ | 'my_feature.enabled'
// Add the value type mapping
interface SettingValues {
// ... existing mappings
'my_feature.enabled': boolean
}
The setting will then be available through the existing composables without any API changes:
const { get } = useSettings()
const isEnabled = get('my_feature.enabled', false)
billing.company.name), but they are flat string identifiers, not nested array paths. This distinction is important for validation — Laravel's standard rules() method interprets dots as array access, so settings use a custom validation approach. See Reading & Writing Settings for details.Supported Types
Each setting has a type that determines its storage semantics and auto-generated validation rules:
| Type | Description | Example Value | Auto-generated Rules |
|---|---|---|---|
string | Any text value | "My SaaS App" | string + custom (e.g., max:255) |
bool | Boolean toggle | true | boolean |
int | Integer number | 42 | integer |
enum | One of a fixed set of values | "en" | in:en,fr,es,it |
email | Email address | "admin@example.com" | email |
timezone | IANA timezone identifier | "Europe/Paris" | timezone |
currency | ISO 4217 currency code | "EUR" | string, size:3, uppercase, ActiveCurrency rule |
Validation Rules
Validation rules are auto-generated by the SettingDefinition::validationRules() method based on the setting's type and any additional validation string:
public function validationRules(): array
{
$stringRules = [];
$objectRules = [];
// Add custom validation rules if specified
if ($this->validation !== null) {
$customRules = explode('|', $this->validation);
$stringRules = array_merge($stringRules, $customRules);
}
// Add type-specific rules
switch ($this->type) {
case 'bool':
$stringRules[] = 'boolean';
break;
case 'int':
$stringRules[] = 'integer';
break;
case 'enum':
if ($this->allowedValues !== null) {
$stringRules[] = 'in:' . implode(',', $this->allowedValues);
}
break;
case 'email':
$stringRules[] = 'email';
break;
case 'timezone':
$stringRules[] = 'timezone';
break;
case 'currency':
$stringRules[] = 'string';
$stringRules[] = 'size:3';
$stringRules[] = 'uppercase';
$objectRules[] = new ActiveCurrency;
break;
case 'string':
default:
if (! in_array('string', $stringRules, true) && ! in_array('max:255', $stringRules, true)) {
$stringRules[] = 'string';
}
break;
}
return array_merge(array_values(array_unique($stringRules)), $objectRules);
}
Currency Validation
Currency settings use the ActiveCurrency custom rule (backend/app/Rules/ActiveCurrency.php), which checks that the provided currency code exists in the system's active currency registry. This prevents setting a currency that the platform doesn't support.
Enum Validation
Enum settings define their valid values through the allowedValues parameter. For example, the locale setting only accepts the four supported locales:
self::I18N_LOCALE => new SettingDefinition(
scopes: [SettingScope::Application, SettingScope::Tenant, SettingScope::User],
type: 'enum',
default: config('app.locale', 'en'),
allowedValues: ['en', 'fr', 'es', 'it'],
),
The allowedValues array is sourced from the application's supported_locales configuration, so adding a new locale to the config automatically makes it available as a setting value.
Combining Custom and Type Rules
The validation parameter allows adding extra Laravel rules on top of the type-specific ones. For example, the company address has both the auto-generated string rule from its type and a custom max:500 length limit:
self::BILLING_COMPANY_ADDRESS => new SettingDefinition(
scopes: [SettingScope::Application],
type: 'string',
default: config('billing.company.address', ''),
validation: 'string|max:500',
),
The SettingDefinition Value Object
Every setting key returns a SettingDefinition from its definition() method. This value object encapsulates all metadata:
final readonly class SettingDefinition
{
/**
* @param array<SettingScope> $scopes Allowed scopes for this setting
* @param SettingType $type Storage semantics
* @param mixed $default Default value when not set
* @param array<string>|null $allowedValues For enum types: list of valid values
* @param string|null $validation Additional Laravel validation rules
* @param bool $sensitive If true, never expose in public API
*/
public function __construct(
public array $scopes,
public string $type,
public mixed $default,
public ?array $allowedValues = null,
public ?string $validation = null,
public bool $sensitive = false,
) {}
public function allowedAtScope(SettingScope $scope): bool
{
return in_array($scope, $this->scopes, true);
}
public function validationRules(): array
{
// Builds complete Laravel validation rules from type + custom validation
// (see Validation Rules section above)
}
}
| Property | Purpose |
|---|---|
scopes | Array of SettingScope values — determines where the setting can be read and written |
type | One of string, bool, int, enum, email, timezone, currency — drives validation |
default | Fallback value when no database record exists. Often pulled from config() for environment flexibility |
allowedValues | For enum type only — the list of valid values (e.g., ['en', 'fr', 'es', 'it']) |
validation | Extra Laravel validation rules as a pipe-separated string (e.g., `nullable |
sensitive | If true, the setting is excluded from the public API response. Currently no settings use this flag, but the mechanism is available |
Database Storage
Settings are stored in three separate tables, one per scope. All three follow the same key-value structure:
Schema::create('application_settings', function (Blueprint $table) {
$table->id();
$table->string('key', 100)->unique();
$table->json('value');
$table->timestamps();
$table->index('key');
});
Schema::create('tenant_settings', function (Blueprint $table) {
$table->id();
$table->foreignUuid('tenant_id')->constrained()->cascadeOnDelete();
$table->string('key', 100);
$table->json('value');
$table->timestamps();
$table->unique(['tenant_id', 'key']);
});
Schema::create('user_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('key', 100);
$table->json('value');
$table->timestamps();
$table->unique(['user_id', 'key']);
});
Key design choices:
application_settingshas a uniquekeycolumn — there is exactly one value per setting at the platform level.tenant_settingshas a composite unique constraint on(tenant_id, key)— each tenant can have at most one value per setting.user_settingshas a composite unique constraint on(user_id, key)— each user can have at most one value per setting.- All tables use
cascadeOnDelete()— when a tenant or user is deleted, their settings are automatically cleaned up. - The
valuecolumn is JSON, using a custom cast for type preservation (see below).
Value Casting
All three settings models use the SettingValueCast custom cast on their value column. This cast wraps values in a {"v": <actual>} JSON envelope:
final class SettingValueCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
{
if ($value === null) {
return null;
}
$decoded = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
return $decoded['v'] ?? null;
}
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return json_encode(['v' => $value], JSON_THROW_ON_ERROR);
}
}
The wrapper ensures type fidelity across storage:
| Value | Stored As | Without Wrapper |
|---|---|---|
true (boolean) | {"v":true} | "1" (loses type) |
false (boolean) | {"v":false} | "0" or empty (ambiguous) |
null | {"v":null} | null (ambiguous with "not set") |
42 (integer) | {"v":42} | "42" (becomes string) |
"hello" (string) | {"v":"hello"} | "hello" (ok) |
{"v": ...} wrapper exists because PostgreSQL's JSON column type can lose type information during round-trips. For example, a boolean true stored directly as JSON might be read back as the string "1". The wrapper guarantees that a boolean stays a boolean, an integer stays an integer, and null is distinguishable from "no value set."What's Next
- Reading & Writing Settings — How to read resolved settings and update them through API endpoints and frontend composables
- Caching Strategy — How settings are cached in Redis and how cache invalidation works
Settings Overview
Hierarchical settings system with three-level cascade (Application, Tenant, User), centralized key registry, scope-aware resolution, and full-stack API support.
Reading & Writing Settings
How to read and write settings: SettingsResolver cascade, scope-specific repositories, API endpoints with examples, custom validation, authorization, and frontend composables.