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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **Runtime overrides.** `override()`, `forgetOverride()`, `hasOverride()`, `clearOverrides()` and
`withOverrides()` on the `Settings` handle hold a value in memory for the current request or job,
ahead of every stored layer. An override is keyed like a stored value (store, owner, key, locale)
and follows the same owner, parents and global walk, so a global override reaches every owner
that has none of its own. It is validated and cast as a write is (including `values.max_bytes`),
never written, cached, audited, versioned or encrypted, needs no write ability, wins over locks
and precedence, and is not exposed over the REST API. `explain()` reports the layer `override`,
`laranail::settings.show` marks it, typed settings `load()` reads it and `save()` never persists
it, and the fake supports it. The new `Services\Resolution\Overrides` is bound scoped, so Octane
and the queue worker drop overrides between requests and jobs. See
`docs/tools/runtime-overrides.md`.

## [0.1.1] - 2026-11-14

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ The hosted docs live at <https://opensource.simtabi.com/documentation/laranail/s
- [Access control](docs/tools/access-control.md) — actors, abilities, authorizers, guarded locks, sensitive reads and step-up verification.
- [Audit and logging](docs/tools/audit-and-logging.md) — the audit trail, its sinks and the operational log.
- [Runtime configuration](docs/tools/runtime-configuration.md) — stores, definitions and access changed in code for one request.
- [Runtime overrides](docs/tools/runtime-overrides.md) — a value held in memory for one request or job, ahead of every stored layer; never written, cached or audited.
- [Audit queries](docs/tools/audit-queries.md) — reading the audit trail from PHP, gated by `view_audit`.
- [Tenancy](docs/tools/tenancy.md) — tenant column or database per tenant, the discriminator, and the stancl and spatie adapters.
- [Models and factories](docs/tools/models-and-factories.md) — Eloquent models and factories for tests, seeding and admin screens.
Expand All @@ -146,6 +147,7 @@ The hosted docs live at <https://opensource.simtabi.com/documentation/laranail/s

- [Ship defaults with the application](docs/recipes/shipped-defaults.md) — a versioned defaults file instead of `.env`.
- [Test code that uses settings](docs/recipes/testing-with-the-fake.md) — `Settings::fake()` and its assertions.
- [Override a setting for one request](docs/recipes/overriding-a-setting-for-one-request.md) — middleware, a queued job and a feature switch, with nothing written.
- [Act as a user](docs/recipes/acting-as-a-user.md) — authorize and audit changes made on a user's behalf.
- [Move from files to the database](docs/recipes/moving-from-files-to-the-database.md) — bootstrap mode during install, then promotion.
- [Seed the default categories](docs/recipes/seeding-default-categories.md) — the shipped set, your own set, and per tenant.
Expand Down
23 changes: 21 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ database exists, and after it does.

## How a read resolves

`Services\Resolution\Resolver` walks the cascade one scope at a time: the owner, then its parents
`Services\Resolution\Resolver` first asks the request's runtime overrides
(`Services\Resolution\Overrides`), walking the same scopes and locales described below; the first
override found answers, and no store is read. Overrides are held in memory for the request or job
only, so they come first and never reach the cache (see [Runtime overrides](tools/runtime-overrides.md)).

Then it 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
Expand All @@ -29,7 +34,8 @@ 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"]
read["get('ui.theme') on a handle for user:7"] --> override["runtime overrides for this request or job, owner then parents then global"]
override -->|no override| 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"]
Expand Down Expand Up @@ -399,6 +405,19 @@ the queries saved. On a local SQLite file a query is cheap, so against a databas
each saved query is a round trip and the gain is larger. A shared cache store (Redis) costs two
round trips per store on the first read of a request and none after it.

## Why runtime overrides sit in memory, first

An override is for a value that should differ for one request, job or block of code: a preview, an
import that must not send mail, a support session, a test. Writing it to a store would make it
durable, audited, versioned and visible to every other request, which is the opposite of what the
caller asked for. So `Overrides` is a plain scoped object (one instance per request or job, dropped
by `forgetScopedInstances()` under Octane and the queue worker), the resolver asks it before any
layer, and it is keyed like a stored value so the owner, parents, global and locale walk is the
same. It is a different thing from `RuntimeConfiguration`, which changes the configuration (stores,
layers, definitions, access) rather than a value. Overrides are a code-level call: they need no
write ability, ignore locks and precedence, and are not exposed over the REST API, because a value
that is never stored changes nothing another caller can see.

## Why headless?

The package ships no views, Blade components, Livewire, Filament or assets. A settings engine is consumed by many interfaces (an admin panel, a CLI, an API client, another package), and any UI bundled here would be the wrong one for most of them and would drag view dependencies into every host. Its surfaces are the PHP API, events, Artisan commands and an opt-in JSON API; an admin UI is a separate companion package. An architecture test fails on any view, Blade, Livewire or Filament reference in `src/` and on a `resources/views` directory.
Expand Down
3 changes: 3 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ errors and what to run.
- [Caching](tools/caching.md), [History](tools/history.md) and [Approvals](tools/approvals.md) for
production use.
- [Tenancy](tools/tenancy.md) before the first migration of a multi-tenant application.
- [Runtime overrides](tools/runtime-overrides.md) to change what settings read for one request or
job without writing anything; the [recipe](recipes/overriding-a-setting-for-one-request.md) shows
middleware, a queued job and a feature switch.

---

Expand Down
117 changes: 117 additions & 0 deletions docs/recipes/overriding-a-setting-for-one-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Override a setting for one request

Change what settings read for one request, one queued job or one block of code, without writing anything.

An override is held in memory and answers ahead of every stored value; it ends with the request or job. The full reference is [Runtime overrides](../tools/runtime-overrides.md).

## For one request, in middleware

A preview of a theme a user has not saved yet, passed as a signed query parameter:

```php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Simtabi\Laranail\Settings\Contracts\Settings;

final readonly class PreviewTheme
{
public function __construct(private Settings $settings) {}

public function handle(Request $request, Closure $next): Response
{
$theme = $request->query('preview_theme');

if ($request->hasValidSignature() && is_string($theme) && $request->user() !== null) {
$this->settings->for($request->user())->override('ui.theme', $theme);
}

return $next($request);
}
}
```

Everything that reads `ui.theme` for that user in this request (views, view composers, an API resource) sees the preview. Nothing is saved, the next request reads the stored theme, and Octane drops the override between requests. The key is named in code: never take the key from the request.

## For one queued job

A backfill that must not send mail while it runs, whatever the application's mail setting says:

```php
namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Simtabi\Laranail\Settings\Contracts\Settings;

final class BackfillInvoices implements ShouldQueue
{
use Queueable;

public function __construct(public int $tenantId) {}

public function handle(Settings $settings, InvoiceBackfill $backfill): void
{
$settings->withOverrides(
['notifications.mail_enabled' => false, 'limits.batch_size' => 500],
fn () => $backfill->run($this->tenantId),
);
}
}
```

The queue worker starts every job with no overrides, so set them inside `handle()`: a job does not inherit the overrides of the request that dispatched it. `withOverrides()` restores the previous state when the callback returns or throws, so a retry starts clean too.

## As a feature-flag style switch

Turn a feature on for staff for the request, while the stored value stays off for everyone else:

```php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Simtabi\Laranail\Settings\Contracts\Settings;

final readonly class StaffSeesBetaFeatures
{
public function __construct(private Settings $settings) {}

public function handle(Request $request, Closure $next): Response
{
if ($request->user()?->is_staff === true) {
$this->settings->for($request->user())->override([
'features.new_checkout' => true,
'features.ai_summary' => true,
]);
}

return $next($request);
}
}
```

Code reads the flag as it always does, `$settings->for($user)->boolean('features.new_checkout')`, and cannot tell an override from a stored value. `explain()` can: its layer is `override`. Overriding needs no write ability and is not audited, so keep it for decisions your code makes; a flag a person turns on for someone belongs in `set()`, where it is authorized and audited.

## In a test

When one test needs a single value different, an override is lighter than the fake:

```php
use Simtabi\Laranail\Settings\Contracts\Settings;

it('hides the beta banner when the feature is off', function () {
app(Settings::class)->override('features.beta', false);

$this->get('/')->assertDontSee('Try the beta');
});
```

Use [`Settings::fake()`](testing-with-the-fake.md) instead when you need to assert on what the code wrote.

---

[← Docs index](../../README.md#documentation)
14 changes: 14 additions & 0 deletions docs/recipes/testing-with-the-fake.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ it('refuses a non-admin', function () {

`auditEntries()` returns every recorded entry.

## Fake or override?

Use the fake when the test is about what the code **writes**: it replaces every layer with memory and records each save, forget and audit entry for the assertions above. Use a [runtime override](../tools/runtime-overrides.md) when the test only needs one value to **read** differently and the engine can stay real:

```php
it('hides the beta banner when the feature is off', function () {
Settings::override('features.beta', false);

$this->get('/')->assertDontSee('Try the beta');
});
```

An override is never recorded as saved, so with the fake installed, `assertNotSaved()` still passes for an overridden key. The two combine: `Settings::fake()` first, then `Settings::override()` or `Settings::withOverrides()` on top. The [override recipe](overriding-a-setting-for-one-request.md) covers overrides outside tests.

---

[← Docs index](../../README.md#documentation)
5 changes: 5 additions & 0 deletions docs/tools/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ definition, or show `-`. `Last audit` is the newest entry for that key and scope
audit trail is not stored in the database. The command runs as the system actor, through the same
`Actions\Reads\ExplainSetting` action as `explain()` and the REST API.

When a [runtime override](runtime-overrides.md) answered, `Layer` reads `override` and an
`Override` row says the value is held in memory for the request or job and not stored. Overrides
belong to the process that set them, so this shows only when the command is called from that code
(for example `Artisan::call()` in a test); a separate `php artisan` run has none.

When the answer came through the settings cache, `Stored` is the version of the row read past the
cache, and `Cache` says `stale: run laranail::settings.clear-cache` when the two differ (a row
edited by hand, say).
Expand Down
2 changes: 2 additions & 0 deletions docs/tools/runtime-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

`$settings->configuration()` changes stores, layers, definitions, drivers and access for the current request only.

To change a setting's **value** for the request instead, use a [runtime override](runtime-overrides.md).

## Scope

`RuntimeConfiguration` is bound `scoped`: Octane and queue workers flush it between requests and jobs, so nothing set here outlives the request that set it. Store and layer entries merge over `laranail.settings.stores` and `.layers`; the runtime entry wins.
Expand Down
Loading
Loading