Skip to content

Settings

Introduction

A Setting is a declared configuration value: a named, typed, JSON-safe value with a mandatory default, written as an attribute on the class that consumes it. It is the answer to "how is this configured?" — the row limit of a card, the threshold of a validation, the endpoint a provider talks to.

Settings sit between the other two value channels: a #[Parameter] answers which record, a #[Setting] answers how is this configured, and a #[Slot] answers in what context.

The mechanism is deliberately small. In Core a Setting resolves to its declared default and nothing else — there is no storage, no per-user or per-tenant override, no admin UI. What Core provides is the declaration: a catalog that a storage layer can read, and an injection path that hands the value to your code. The SDK builds the storage and the governance on top of exactly this.

When a Setting, when a database model

The Settings API is for lightweight configuration that can be expressed as simple key–value pairs. It fits when the value is UI-driven, ephemeral, or low-risk:

  • UI filters and personalisation (a card showing weekly hours for selected employees);
  • display options of one artifact (how many rows a card shows, which date-range presets it offers);
  • feature toggles and thresholds that change frequently;
  • lists of IDs used as filters — as long as you accept weak references (IDs stored without a database-level foreign key).

Reach for a dedicated database model instead when the configuration expresses a business relationship or a critical domain rule:

  • relationships that must stay referentially intact (employees assigned to cost centres);
  • audit-relevant or legally binding data (who is eligible for billing, regulatory roles);
  • data that needs real constraints, migrations, or reporting queries (joins, aggregates);
  • long-lived associations where zombie IDs — deleted or reassigned objects — are unacceptable.

Rule of thumb. Ephemeral, UI-scoped, or best-effort → a Setting is pragmatic and safe. Domain-critical, audited, or relational → design a model.

Global values are slots. A value that holds across artifacts — the currency, the locale, the time zone, the reporting period — is a Slot, not a setting. A setting configures the one artifact that declares it.

Weak foreign keys in Settings (an array of partner IDs, say) are fine if you sanitise them at read time — ignore IDs that no longer resolve. You are trading referential integrity for developer speed, and that trade is intentional.

Declaring a setting

plannedToday the resolver also reads `#[Setting]` off the handler or provider it invokes, and the registry catalogues only what sits on the artifact — so a declaration on a provider is injected but never configurable. Collapsing the two into one declaration site is queued.

#[Setting] belongs on the artifact — the Ui5App, Ui5Card, Ui5Action, Ui5Report, … — and is repeatable: stack one per value the artifact depends on. The artifact is the thing that has a namespace, and a setting is addressed by that namespace, so this is the only place a declaration can be seen by the registry, by tooling and by storage.

php
use LaravelUi5\Core\Parameters\Attributes\Setting;
use LaravelUi5\Core\Parameters\Enums\EditLevel;
use LaravelUi5\Core\Parameters\Enums\ValueType;
use LaravelUi5\Core\Ui5\AbstractUi5Card;

#[Setting(
    key:     'maxItems',
    type:    ValueType::Integer,
    default: 10,
    note:    'Maximum number of rows the card renders.',
    level:   EditLevel::Administrator,
)]
#[Setting(
    key:     'showTrend',
    type:    ValueType::Boolean,
    default: true,
    note:    'Render the trend sparkline beside each figure.',
)]
class RevenueCard extends AbstractUi5Card
{
    public const string NAMESPACE = 'com.acme.sales.cards.revenue';
    // …
}

The class that consumes the value — the handler or provider the artifact points at — declares nothing. It extends AbstractConfigurable and reads the values its artifact declared:

php
use LaravelUi5\Core\Ui5\AbstractConfigurable;

class RevenueCardProvider extends AbstractConfigurable implements DataProviderInterface
{
    public function provide(): array
    {
        return $this->query()->limit($this->maxItems)->get()->all();
    }
}
ArgumentRequiredPurpose
keyyestechnical identifier; also the property name you read it back by
typeyesthe ValueType the stored JSON value casts to
defaultyesthe value the package ships — what Core always resolves to
noteyesone sentence on the setting's purpose
levelnominimum EditLevel allowed to change a stored value (default: Organization)
modelClassnoEloquent FQCN; required when type is Model or ModelArray — a missing or unknown class fails at boot

⚠️ There is no scope: argument. Scope is a property of a stored value, not of the declaration — see Scope below.

Reading a setting

The settings of the artifact being served arrive as virtual, read-only properties on the invoked instance. There is nothing to declare on that class and nothing to wire:

php
$this->maxItems;   // 10
$this->showTrend;  // true

For that to work the class must extend AbstractConfigurable. A class that carries #[Setting] attributes without extending it throws InvalidSettingException at resolution time — a loud failure, not a silent null. (A class with no #[Setting] attributes need not extend anything; the base class is only required where injection actually happens.)

The rules the base class enforces:

  • Injected exactly once, by the framework, before your method runs. A second injection is a LogicException.
  • No mutation — there is no setter, and the backing array is private.
  • No collision — if a real property of the same name already exists on the class, injection throws rather than shadowing it.
  • No silent misses — reading a key you never declared throws a LogicException naming the key and the class.
  • Duplicate keys on one class throw InvalidSettingException, rather than the last declaration quietly winning.

A dotted key is legal and common for namespaced configuration — read it with brace syntax:

php
#[Setting(key: 'billing.settlement.maxHours', type: ValueType::Integer, default: 8, note: '…')]
// …
$this->{'billing.settlement.maxHours'};

One declaration site, two mechanisms

This is the part worth reading twice. A #[Setting] is declared once, on the artifact — and that one declaration is picked up by two independent mechanisms:

MechanismWhenEffect
Ui5Registryat bootthe declaration is catalogued under the artifact's namespace, for tooling and storage to read
SettingResolverat request timethe artifact's resolved settings are injected into the invoked handler or provider as virtual properties

The catalog is the contract surface. Every registered artifact is reflected for #[Setting] and the result is filed under its namespace; a duplicate key within one artifact is a LogicException at boot.

php
$registry->settings();                        // every namespace
$registry->settings('com.example.showcase');  // one artifact's settings

Each catalog entry is a plain array carrying default, type, scope, level, note, and model — not the attribute instance. It is what ui5:sync seeds the SDK's storage from, what the Settings app lists, and what an administrator's override attaches to. A declaration the registry cannot see does not exist for any of that — which is the whole reason the artifact is the only declaration site.

The injection is the reading half. The invoked class — an Action handler, a Card, Tile, Chart or Report provider, a Resource provider — extends AbstractConfigurable and receives the settings of the artifact it serves. It declares nothing itself.

With the SDK. SDK action handlers (SdkActionHandlerInterface) read settings through the SDK's context instead of through injected properties — $sdk->setting() for a setting of the artifact, $sdk->appSetting() for one of the app; see Reading & Writing Settings. That is also the path that carries stored overrides: an injected property in Core is always the declared default, because Core stores nothing.

📝 Practical consequence. There is nothing to decide about placement, and nothing to declare twice. Put the attribute on the artifact; read the value where you need it.

The type vocabulary

ValueType is the Settings vocabulary — wider than the ParameterType used by Parameters and Slots, because a stored configuration value may legitimately be a list:

ScalarArrayNotes
StringStringArray'EUR', 'dark'
IntegerIntegerArray42, 365
FloatFloatArraybinary floating point
BooleanBooleanArraya feature toggle
Decimalfixed precision, distinct from Float's binary semantics
DateISO-8601 date string
DateTimeISO-8601 timestamp (2026-05-20T14:23:00Z)
ModelModelArraya foreign key (or list of them); requires modelClass

Store JSON-safe primitives only — strings, numbers, booleans, ISO-8601 dates, and arrays of those. Model and ModelArray are stored as bare IDs; that is the weak reference discussed above, and the reason you sanitise on read.

EditLevel — who may change a stored value

EditLevel is a governance threshold, ordered and numeric:

LevelValue
User1
Administrator2
Organization3
Operator4
Platform5

A change is permitted when the actor's level is at least the declared one ($actual->allows($required)). The #[Setting] default is EditLevel::Organization — conservative on purpose, so exposing a setting to end users is an explicit opt-in.

Core defines EditLevel but does not enforce it. Core is auth-blind: it has no user, no partner, no tenant. The field is a declared policy, and it is the storage-backed layer (the SDK) that compares it against an actor and permits or refuses the write. Everything Core does with it is carry it into the catalog.

EditLevel is not RBAC. It says which structural authority level is required — never which abilities are granted.

Scope — where a value was stored

Scope models override precedence — which stored value wins when several exist for one key:

Platform < Installation < Tenant < Site < User

The value stored at the highest available scope wins. Scope answers "at which structural layer was this defined?" — it says nothing about who may edit it (EditLevel) or who the actor is.

Because Core has no storage, every entry Core catalogs is Scope::Platform — the package-shipped default, the bottom of the hierarchy, the one layer that always exists. Everything above it arrives with a storage layer.

What Core does, and what it does not

Core doesCore does not
harvest #[Setting] from artifacts into the registry catalogpersist a value anywhere
inject an artifact's declared defaults into the invoked handler or providerread User / Site / Tenant / Installation overrides
validate uniqueness, the base class, and property collisionscast the default — the declared value is injected verbatim
carry type, level, note, model for downstream toolingenforce EditLevel (Core is auth-blind)

That is the whole of Core's reach, and it is deliberate: a Core-only app gets deterministic, declared configuration with no infrastructure. The SDK stores the declarations and lets administrators override them per scope. Code then reads the stored value through the SDK's context; an injected property stays the declared default. See Reading & Writing Settings.

The slot bridge

Every #[Slot] automatically expands into a synthetic Setting with the key slot.{name}, owned by Core's own namespace (com.laravelui5.core) and flagged synthetic so tooling can label it as auto-expanded. Type, default, note and level carry across from the slot declaration.

That is why Setting is a link in the slot resolution chain: a slot is addressable through the settings channel without being declared twice. The bridge runs one way only — a #[Setting] does not become a slot.