Skip to content
SaaS4Builders
Settings

Defining Settings

How to add new settings to the registry: SettingKey enum, SettingDefinition metadata, supported types, validation rules, database storage, and value casting.

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:

backend/app/Enums/SettingKey.php
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:

backend/app/Enums/SettingKey.php
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:

backend/app/Enums/SettingKey.php
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:

backend/database/seeders/ApplicationSettingsSeeder.php
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:

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)
Setting keys use dot notation (e.g., 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:

TypeDescriptionExample ValueAuto-generated Rules
stringAny text value"My SaaS App"string + custom (e.g., max:255)
boolBoolean toggletrueboolean
intInteger number42integer
enumOne of a fixed set of values"en"in:en,fr,es,it
emailEmail address"admin@example.com"email
timezoneIANA timezone identifier"Europe/Paris"timezone
currencyISO 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:

backend/app/Settings/SettingDefinition.php
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:

backend/app/Enums/SettingKey.php
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:

backend/app/Settings/SettingDefinition.php
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)
    }
}
PropertyPurpose
scopesArray of SettingScope values — determines where the setting can be read and written
typeOne of string, bool, int, enum, email, timezone, currency — drives validation
defaultFallback value when no database record exists. Often pulled from config() for environment flexibility
allowedValuesFor enum type only — the list of valid values (e.g., ['en', 'fr', 'es', 'it'])
validationExtra Laravel validation rules as a pipe-separated string (e.g., `nullable
sensitiveIf 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');
});

Key design choices:

  • application_settings has a unique key column — there is exactly one value per setting at the platform level.
  • tenant_settings has a composite unique constraint on (tenant_id, key) — each tenant can have at most one value per setting.
  • user_settings has 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 value column 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:

backend/app/Casts/SettingValueCast.php
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:

ValueStored AsWithout 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)
The {"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