Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ Requires PHP 8.4.1+ or 8.5 and Laravel 13. Works on MySQL, PostgreSQL and SQLite
> **Pre-1.0.** 0.1.0 is the first tagged release. Before 1.0 a minor release may change the API;
> [Upgrading](docs/upgrading.md) and the [CHANGELOG](CHANGELOG.md) say what changed.

```mermaid
flowchart LR
php["PHP API: facade, settings(), HasSettings, typed classes"] --> actions
rest["REST API, opt-in"] --> actions
cli["Artisan commands"] --> actions
actions["the same actions: authorize, validate, write, audit"] --> stores
subgraph stores ["Stores, each holding values per owner: the application, a user, a tenant, a product"]
direction LR
database["database layer"] --> runtime["runtime JSON file"] --> shipped["shipped defaults file"]
end
```

Reads walk a store's layers in order. While the application installs, a store in bootstrap mode
is answered by its files, and `promote` later moves what was written to them into the database. [Architecture](docs/architecture.md)
has the read and write paths and the tables.

## Install

```bash
Expand Down
158 changes: 158 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,164 @@ database exists, and after it does.
- **Categories.** A shared `settings_categories` table, scoped per store, organises settings for
people and interfaces; `group` remains a lightweight code namespace.

## How a read resolves

`Services\Resolution\Resolver` walks the cascade one scope at a time: the owner, then its parents
(only when the definition, or else the store, inherits), then the global value. At each scope it
tries the requested locale, the fallback locale and no locale, and within each the store's layers
in their configured order; the first stored value wins, and a stored `null` is an answer. Nothing
stored anywhere falls through to the definition's default, then to the default passed to `get()`.
The diagram shows the default store's layers; a store lists its own.

```mermaid
flowchart TD
read["get('ui.theme') on a handle for user:7"] --> owner["owner scope, user:7"]
owner -->|nothing stored| parents["parent scopes, e.g. team:3, when the setting inherits"]
parents -->|nothing stored| global["global scope"]
global -->|nothing stored| definition["the definition's default"]
definition -->|no default| caller["the default passed to get()"]
owner -.-> layers
parents -.-> layers
global -.-> layers
subgraph layers ["At each scope and locale, the store's layers in order; the first stored value wins"]
direction LR
database["database"] --> runtime["runtime JSON file"] --> shipped["shipped PHP file"]
end
subgraph loading ["How the database layer loads a scope"]
direction LR
preloaded["rows preloaded this request"] -->|miss| bag["cached bag for the scope"] -->|miss or old generation| query["one query on the values table"]
end
database -.-> preloaded
```

The cache sits inside the database layer (`Drivers\CachingDriver`), never in front of a resolved
value, so a write invalidates one scope's bag (see [Why cache raw bags?](#why-cache-raw-bags)). A
store in bootstrap mode skips its database layer while an install is under way, and the files answer.

## How a write runs

Every write, from any channel, is one action. The sequence below is `set()`; `forget()`, `lock()`,
`unlock()`, a rollback and an approved change request take the same path.

```mermaid
sequenceDiagram
autonumber
participant Caller as Caller, from PHP, REST or a command
participant Guard as SetSetting and WriteGuard
participant Access as AccessGuard
participant Writer as SettingWriter
participant Values as ValuePipeline
participant Db as Database layer
Caller->>Guard: set key to value
Guard->>Access: authorize update, token ceiling then authorizer then step-up
alt the setting needs approval
Guard-->>Caller: a change request is opened instead, nothing is written
end
Guard->>Db: begin AuditedTransaction
Guard->>Writer: write
Writer->>Writer: refuse a locked row or an owner alias the store does not list
Writer->>Db: read the current row, FOR UPDATE on the retry
Writer->>Writer: compare the expected version, ConcurrentModification on a mismatch
Writer->>Values: validate, cast, encode, check values.max_bytes, encrypt
Writer->>Db: UPDATE at the version, else restore a trashed row, else INSERT in a savepoint
Writer->>Db: append the settings_history row
Guard->>Db: append the settings_audit row, outcome allowed
Guard->>Db: commit
Db-->>Caller: after commit, rotate the scope's cache generation and dispatch SettingSaved
```

A plain `set()` that loses a race retries once (`writes.retry_on_conflict`); a write naming an
expected version (`If-Match`) never does. A failure rolls the change, its history and its audit
row back together, and records a `failed` audit entry outside the transaction. A store without a
database layer writes to its first writable file layer, with no history row.

## The tables

The one migration creates these tables, on the store's connection. `settings` is the default
store's values table; each database store has its own, of the same shape. `settings_definitions`
and `settings_stores` live on the default store's connection only. A dedicated store has an
`owner_id` foreign key to its owner table instead of `settable_type` and `settable_id`, and in
`column` tenancy every table except those two gains a `tenant` column that leads its keys.

```mermaid
erDiagram
settings_categories |o--o{ settings_categories : "parent_id"
settings_categories |o--o{ settings : "category_id, category_store"
settings_categories |o--o{ settings_definitions : "category_id"
settings ||..o{ settings_history : "uid is setting_ref"
settings ||..o{ settings_change_requests : "store, scope, key, locale"
settings_change_requests ||..o{ settings_audit : "change_request"
settings_categories {
ulid id PK
string store UK
string slug UK
ulid parent_id FK
json title
boolean is_system
}
settings {
ulid id PK
string scope UK "global or alias:id"
string key UK
string locale UK
ulid uid UK
string settable_type
string settable_id
ulid category_id FK
longText value
boolean is_encrypted
boolean is_locked
int version
timestamp deleted_at
}
settings_definitions {
ulid id PK
string store UK
string key UK
string slug UK
ulid category_id FK
string type
boolean is_system
}
settings_history {
ulid id PK
string setting_ref
string scope
string key
int version
longText old_value
longText new_value
}
settings_change_requests {
ulid id PK
string scope
string key
longText payload
int base_version
string status
timestamp expires_at
}
settings_audit {
ulid id PK
timestamp occurred_at
string action
string outcome
string scope
string key
string change_request
string channel
}
settings_stores {
ulid id PK
string kind UK
string name UK
json config
}
```

Solid lines are foreign keys; dotted lines are joins the package makes without one. Every column
is listed in [Database](tools/database.md).

## How the code is layered

| Layer | Holds | Examples |
Expand Down
27 changes: 27 additions & 0 deletions docs/recipes/moving-from-files-to-the-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,33 @@ Settings::store('settings')->asSystem('installer')->promoteWhenReady();
It promotes at once when it can, and otherwise sets the store to `auto` for the rest of the
request, job or command.

## The lifecycle

Manual and automatic promotion are the same action, `Actions\PromoteStore`; they differ in what
starts it and in how a held lock is reported.

```mermaid
flowchart TD
install["Bootstrap mode: writes go to the runtime file"] --> migrate["php artisan migrate"]
migrate --> mode{"the store's promote mode"}
mode -->|manual| command["laranail::settings.promote, or promote() in code"]
mode -->|auto| triggers["MigrationsEnded, each request once the response is sent, each queued job"]
code["promoteWhenReady() in code"] --> ready
triggers --> ready{"the file holds a selected value, the database answers and the table exists"}
ready -->|no| later["nothing runs; promoteWhenReady() sets the store to auto for the rest of the request"]
ready -->|yes| lock{"the promote lock"}
command --> lock
lock -->|held elsewhere| held["automatic: skipped quietly. Command: PromotionLocked"]
lock -->|taken| copy["copy the selected values in one audited transaction, skipping keys already in the database and, in a strict store, undefined keys"]
copy -->|fails| failed["automatic: a warning is logged, the file keeps answering, the next run retries"]
copy --> left{"values the selection left out"}
left -->|none| archive["archive the file as settings.promoted-timestamp.json"]
left -->|some| remainder["archive the original, rewrite the file with the remainder"]
left -->|all of them| untouched["the file is left as it is"]
archive --> done["reads come from the database"]
remainder --> done
```

## Leaving bootstrap mode

Once the values are in the database, remove `LARANAIL_SETTINGS_BOOTSTRAP`. From then on an unreachable database raises
Expand Down
23 changes: 23 additions & 0 deletions docs/tools/approvals.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ The last approval applies the change through the normal write pipeline, with the
| `stale` | the setting changed first; nothing written |
| `expired` | not decided in time; nothing written |

```mermaid
stateDiagram-v2
[*] --> open: propose(), or a write that needs approval
state open {
[*] --> pending
pending --> approved: an approval, more still needed
approved --> approved: another approval, more still needed
}
open --> applied: the last approval, setting still at its base version
open --> stale: the last approval, setting changed since
open --> rejected: reject() by an approver
open --> cancelled: cancel() by the requester
open --> expired: past expires_at, found on a decision or by prune-changes
applied --> [*]
stale --> [*]
rejected --> [*]
cancelled --> [*]
expired --> [*]
```

`pending` and `approved` are open; every other status is final, and `prune-changes` deletes a
final request once it is older than `approvals.keep_days`.

Events, all dispatched after commit and carrying identifiers only: `ChangeRequested`, `ChangeApproved` (with how many approvals remain), `ChangeApplied` (with the new version), `ChangeRejected`, `ChangeCancelled`, `ChangeStale`. The audit trail records the proposal (`propose`, outcome `pending`), each approval, the rejection or cancellation, and a self-approval refused, each with the change request's id.

## Where requests are kept
Expand Down
2 changes: 1 addition & 1 deletion docs/tools/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Nineteen Artisan commands, all named `laranail::settings.<command>`.
| `laranail::settings.install` | publishes the config and the package's one migration, syncs definitions once the table exists; refuses to run over a foreign table of the same name |
| `laranail::settings.make-store {store} [--owner=*] [--connection=]` | writes a new store's migration (a copy of the package migration pinned to that store) and prints its configuration entry |
| `laranail::settings.promote [store] [--force] [--from=] [--only=] [--owner=]` | copies values written before install from the runtime file into the database; `--only=settings,owners` and `--owner=user,team` copy only some (default: the store's resolved `promote` options), and the rest stay in the file |
| `laranail::settings.doctor [--json] [--fix]` | checks readiness, missing columns and rows the database cannot guard; `--fix` syncs definitions first |
| `laranail::settings.doctor [--json] [--fix]` | checks each database store's readiness, waiting automatic promotion, columns, owner key type, scopes, encryption, overdue change requests and categories, then definitions and persisted configuration; `--fix` syncs definitions first |
| `laranail::settings.show {key} [--store=] [--owner=] [--locale=] [--reveal]` | prints how one setting resolves: value, layer, scope, type, version, flags and the latest audit entry |
| `laranail::settings.sync-definitions` | copies code and config definitions into the definitions table as system rows |
| `laranail::settings.prune-orphans [--dry-run]` | removes the settings of owners that no longer exist |
Expand Down
34 changes: 34 additions & 0 deletions docs/tools/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,40 @@ Every route, with request and response examples, status codes and problem types,

Each route runs the same action the PHP API runs, and a test reading the live router fails if a route names no controller method, a controller method is not routed, or one does not delegate to an action.

## How a request is handled

A write, end to end. A read takes the same path without the idempotency middleware, `If-Match`
or a change request.

```mermaid
sequenceDiagram
autonumber
participant Client
participant Mw as http.middleware, throttle and IdempotentWrites
participant Ctl as SettingsController
participant Access as ApiActor and AccessGuard
participant Action as SetSetting
Client->>Mw: PUT /api/settings/v1/stores/settings/settings/site.name
Mw->>Mw: authenticate, rate limit
alt Idempotency-Key already answered
Mw-->>Client: the stored response, Idempotent-Replayed true
end
Mw->>Ctl: the request
Ctl->>Access: resolve the actor on the rest channel, 401 for nobody or the system actor
Ctl->>Ctl: If-Match against the current ETag, 412 on a mismatch
Ctl->>Action: handle the write with the actor and the expected version
Action->>Access: token ability laranail-settings:update, then the authorizer, then step-up
alt written
Ctl-->>Mw: 200 with the setting and its new ETag
else the setting needs approval
Ctl-->>Mw: 202 with the change request
else refused or invalid
Ctl-->>Mw: problem+json, e.g. 403, 412, 422, 423 or 428
end
Mw->>Mw: store the response under the Idempotency-Key, unless it is a 5xx
Mw-->>Client: the response
```

## Requests and responses

A setting:
Expand Down
16 changes: 16 additions & 0 deletions docs/tools/tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ two tenants can hold the same key, and `''` is the central application's rows. T
Definitions are shared by every tenant: they describe settings, they hold no tenant's values. In
`column` mode, synced definitions link to the central (`''`) categories.

```mermaid
flowchart TD
call["a read or a write"] --> mode{"tenancy.mode"}
mode -->|none| single["one set of rows, no tenant column"]
mode -->|column| current["the TenantDiscriminator names the current tenant, or '' outside one"]
current --> stamp["each write stamps the tenant column"]
current --> filter["each query adds where tenant is the current one, in the driver and the models' tenant scope"]
stamp --> unique["tenant leads the unique keys, so two tenants can hold the same key"]
mode -->|database| connection["the tenancy package switches the connection"]
connection --> own["the store queries the current tenant's own tables"]
single --> cache["cache keys and preloaded rows are partitioned by tenant in every mode"]
unique --> cache
filter --> cache
own --> cache
```

## Who the current tenant is

```php
Expand Down
4 changes: 2 additions & 2 deletions tests/Unit/DocsConformanceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@

uses(AssertsDocsConformance::class);

// Floor = the 52 pages shipped today (7 guides, 26 reference, 19 recipes; counted 2026-11-11). Raise it as pages
// Floor = the 53 pages shipped today (7 guides, 26 reference, 20 recipes; counted 2026-11-14). Raise it as pages
// are added; a glob that suddenly matches fewer has broken rather than found a clean tree.
it('conforms to the org docs standard', function (): void {
$this->assertDocsConformToStandard(dirname(__DIR__, 2), 52);
$this->assertDocsConformToStandard(dirname(__DIR__, 2), 53);

expect(true)->toBeTrue();
});
Loading
Loading