Skip to content

Context

Context is the data a component carries from where it’s placed to where it runs: a row’s record for an action, a form’s parent model, the value behind a page’s URL segment. It’s always scalar on the wire — sealed into a component’s signed reference, never a serialized object — and sealed per component, so the value a definition reads back is the value the server issued, not something a client controlled.

Lattice::context() registers, per key, how that scalar resolves into a typed model. Once registered, a key is resolved at most once per request, and cascades automatically into every child component Lattice builds from a definition or a page that has it.

Register a key from a service provider’s boot(). The Eloquent sugar resolves through the model’s own route binding, exactly like a route parameter would:

use Lattice\Core\Facades\Lattice;
Lattice::context('tenant', Tenant::class, by: 'slug');

Or register a closure for anything that isn’t a plain route-bound Eloquent lookup. It resolves through the same closure evaluation as every other Lattice callback: $value (the raw context scalar), $key (the context key, as a string), $context (the definition’s full raw context array, so one resolver can read another key), a typed Request, and any container type.

Lattice::context('workspace', function (string $value, Request $request): Workspace {
return Workspace::where('slug', $value)->firstOrFail();
});

Registering the same key twice replaces the previous resolver — the last Lattice::context() call for a key wins.

Give a closure-registered key a keyBy closure too, for turning the resolved object back into its wire scalar — needed when a model is passed directly as a context value. It resolves the model as $value, or by its own type:

Lattice::context(
'workspace',
fn (string $value): Workspace => Workspace::where('slug', $value)->firstOrFail(),
keyBy: fn (Workspace $workspace): string => $workspace->slug,
);

Without a keyBy, Lattice falls back to the resolved object’s own getRouteKey(), throwing only if neither exists and something actually needs to serialize the value. The Eloquent sugar always builds both closures for you, from by (or the model’s own route key name).

Every Definition (form, table, action, bulk action, fragment, layout) reads context back with:

  • context('key') — the raw scalar, untyped, as before.
  • hasContext('key') — presence, distinct from “not found”: a key that’s set but whose resolver finds nothing still passes this check.
  • contextModel('key') — the value resolved through its registered resolver, memoized for the request. Aborts with a 404 when the key is absent or the resolver finds nothing. Throws a LogicException when no resolver is registered for the key at all.
  • contextModelOrNull('key') — the same resolution, returning null instead of aborting.

Pass the class you expect as the second argument — contextModel('workspace', Workspace::class) — and the result is typed as that class for static analysis. It still resolves through the registered resolver; a result of any other class means the resolver is registered wrong and throws a LogicException.

use Lattice\Actions\ActionDefinition;
use Lattice\Actions\ActionResult;
class ArchiveWorkspaceAction extends ActionDefinition
{
public function handle(): ActionResult
{
$workspace = $this->contextModel('workspace');
$workspace->update(['status' => 'archived']);
return ActionResult::success();
}
}

Lattice\Core\Concerns\ResolvesContextModels narrows the same accessors to Eloquent models. A key with a registered resolver resolves through it, typed form included, so the resolver’s own rules — a dependent resolver’s ownership check — always apply. A key with no resolver resolves through the model’s own route binding instead, and so does an explicit by column, even when a resolver is registered for the key:

$workspace = $this->contextModel('workspace', Workspace::class); // the registered resolver
$owner = $this->contextModel('owner', User::class, by: 'email'); // route binding on `email`

The one-argument form, contextModel('workspace'), asserts the resolved object is an Eloquent model, throwing a LogicException otherwise.

A resolver runs at most once per request for a given key and scalar value, however many times it’s read and by however many definitions. Two contextModel() calls in the same handle(), or an authorize() and the handle() that follows it, see the result of one evaluation. A miss (“not found”) is cached too.

A resolver that reads the surrounding context — a $context parameter, or another key through a typed ContextResolutions — can answer the same value differently under another parent: a client looked up within the realm the context names. Its results are memoized per key, value, and context, so two components under different realms never share one resolution.

Memoization is per key and value, though, so a resolver is the wrong place for a side effect. A page that builds one gated component per workspace — a switcher menu — resolves the key once per workspace, and the side effect fires for every one of them, not just the one the request is about. Put it in activated() instead.

Definition::activated() runs once on the definition’s own endpoint, after the gate has passed and the trusted context is active — and never while components are merely being built. It is where request-wide setup keyed to the resolved context belongs: a signed endpoint runs none of the route middleware a page load does, so state a page establishes up front has to be re-established here.

class WorkspaceInvoicesTable extends EloquentTableDefinition
{
public function activated(Request $request): void
{
$this->contextModel('workspace')->makeCurrent();
}
}

The work that follows is deferred — a table’s builder is executed after builder() returns, a form’s schema serializes after handle() — so set state that lasts the request rather than state scoped to the call. A page has no activated(): its request belongs to the page, and route middleware already covers it.

A key with a resolver registered via Lattice::context() cascades into every child component a definition builds — nested actions, a modal’s form, a row’s actions — with no configuration. config('lattice.context.inherited_keys') still exists for a key that has no resolver but should cascade anyway. Explicit context passed at a component’s own placement always wins over an inherited value under the same key.

table is reserved and never cascades, registered or whitelisted — Lattice uses it internally to route a bulk action back to its owning table, and it must never leak into an unrelated child.

A context value doesn’t have to be the scalar itself — pass the resolved model directly, and Lattice normalizes it before the definition gates, seals, or inherits its context:

Table::use(WorkspaceMembersTable::class, ['workspace' => $workspace]);

An object under a key with a registered resolver is turned into its wire-safe scalar through that key’s keyBy closure (or getRouteKey()) — the sealed reference never carries a serialized model, only the same scalar a route parameter would. A BackedEnum value normalizes to its ->value regardless of whether the key has a resolver registered — it was always wire-safe on its own. Any other object passed under a key with no registered resolver throws, rather than being silently JSON-encoded wholesale into the sealed ref.

Lattice opens a “frame” — the currently inheritable context — everywhere it builds child components, so a key registered once cascades through every seam Lattice threads data across:

  • Definitions — a definition’s own gated children (row actions, a modal’s schema, nested actions) build inside a frame opened from its context, and its endpoint activates the same frame from the sealed reference it verifies.

  • Pages, by convention — before render() runs, a page opens a frame from the route’s bound parameters. An object parameter seeds the key whose resolver was registered for its class, whatever the parameter itself is named — render(Tenant $current_tenant) seeds tenant because Lattice::context('tenant', Tenant::class) registered that model, not because of the parameter’s name. A closure resolver takes part through its declared return type — fn (string $value): Tenant records Tenant the same way — or through an explicit model: Tenant::class when it declares none. A scalar parameter seeds the key sharing its own name, when that name is itself registered. PageSchema::context([...]) extends or overrides the frame explicitly for anything the convention misses:

    public function render(PageSchema $schema, Workspace $workspace): PageSchema
    {
    return $schema
    ->context(['workspace' => $workspace])
    ->schema([
    Table::use(WorkspaceMembersTable::class),
    ]);
    }

    Chain context() before schema(). PHP builds schema()’s array argument — and every component in it — only after context() has already returned, so those components see the extended frame only when context() runs first in the chain.

  • Slots — each Lattice::extend() factory runs inside the inherited frame merged with the slot’s own ->context([...]), filtered to the registered/whitelisted keys before it cascades further. An object under an unrelated, unregistered key in a slot’s context is dropped silently rather than throwing — the factory itself still receives it directly, by injection, exactly as before.

  • Closure-built modals->modal(fn (): Modal => ...) snapshots the inherited frame at the moment it’s built, not when the closure eventually runs — which happens later, during serialization, after that frame has already closed. The modal’s own schema — a form, say — inherits the frame its trigger was built in.

  • Layouts — a layout renders inside the page’s still-open frame, so a layout’s schema() sees the same context the page’s own render() does.