This is the full developer documentation for Lattice
# Server-driven React UIs for Laravel.
> Stop building every screen twice. Describe pages, forms, and tables once in PHP — one typed schema, rendered by real React components over Inertia. No hand-written API, no duplicated UI contract.
import { Card, CardGrid } from "@astrojs/starlight/components";
## What it provides
Compose pages from server-side component trees that serialize to typed React nodes and render
through Inertia.
Field definitions, server-side validation, rich content, and conditional fields that react to
other inputs.
Eloquent-backed tables with columns, sorting, filtering, pagination, and row actions.
Row actions and bulk actions that run on the server and dispatch effects — toasts, redirects,
and refreshes — back to the client.
# Bulk actions
> Actions that run over a table selection, receiving the selected records.
A bulk action runs over the rows selected in a [table](/tables/actions/#bulk-actions). It works like a
regular action, except `handle()` receives the selected records as a collection.
## Defining a bulk action
Extend `BulkActionDefinition` and implement `definition()` and `handle()`. The `#[AsBulkAction]`
attribute registers it.
```php
use Illuminate\Support\Collection;
use Lattice\Actions\ActionResult;
use Lattice\Actions\BulkActionDefinition;
use Lattice\Actions\Components\Action;
use Lattice\Core\Attributes\AsBulkAction;
use Lattice\Ui\Enums\Variant;
#[AsBulkAction('app.products.archive-selected')]
class ArchiveSelectedProductsAction extends BulkActionDefinition
{
public function definition(Action $action): Action
{
return $action
->label('Archive selected')
->variant(Variant::Danger);
}
public function handle(Collection $records): ActionResult
{
$records->each(fn (Product $product) => $product->update(['status' => 'archived']));
return ActionResult::success(['archived' => $records->count()])
->toast("Archived {$records->count()} products.", Variant::Success)
->reloadComponent('app.products');
}
}
```
`definition()` returns the same `Action` component as a single action, so labels, variants,
[confirmation](/actions/confirmation-and-forms/), and [forms](/actions/confirmation-and-forms/#collecting-input-with-a-form)
all apply — including a [`->lazyForm()`](/actions/confirmation-and-forms/#deferring-the-schema)
one, which fetches its schema together with the selection payload once the bulk action bar opens
it. `handle()` returns an [`ActionResult`](/actions/effects/) like any action.
`$records` is a reserved parameter name — declare it to receive the selected records, and add
`FormData $data` alongside it when the bulk action also collects a form: `handle(Collection
$records, FormData $data): ActionResult`.
## Attaching it to a table
Return bulk actions from a table's `bulkActions()`:
```php
use Lattice\Actions\Components\BulkAction;
public function bulkActions(): array
{
return [
BulkAction::use(ArchiveSelectedProductsAction::class),
];
}
```
When at least one row is selected, the table shows a bulk action bar.
## How records are resolved
The collection passed to `handle()` is resolved by the table's
[data source](/tables/data-sources/) — both an explicit set of checked rows and "select all
matching", which re-runs the current filters on a signed [endpoint](/advanced/security/). With the
[Eloquent source](/tables/eloquent-tables/#selecting-bulk-action-rows) this needs no extra code: the
records arrive as models, ready to act on.
# Confirmation & forms
> Confirm an action before it runs, or collect validated input in a modal and pass it to handle().
An action can interrupt the click with a modal — either a simple confirmation, or a full form whose
values are passed to `handle()`.
Both dialogs render through the app's shared [modal host](/components/modals/#stacking): they
survive the popover or kebab menu that triggered them closing, and they open above whatever modal
is already open — a row action's confirmation above the modal that contains its table, for
instance.
## Confirmation modals
`->confirm()` shows a confirmation dialog before the action runs. The user must accept; cancelling
does nothing. Pass a title and, optionally, a description and custom button labels.
```php
public function definition(Action $action): Action
{
return $action
->label('Archive')
->variant(Variant::Danger)
->confirm(
'Archive product?',
'This hides it from the catalogue.',
confirmLabel: 'Archive',
cancelLabel: 'Keep',
);
}
```
## Collecting input with a form
`->form()` renders a [form](/forms/overview/) in a modal before the action runs. The collected values
are posted to the [action endpoint](/advanced/security/) and validated server-side, then `handle()` reads them. Use it for
"reject with a reason", "assign a category", and the like.
```php
use Lattice\Form\Components\Select;
use Lattice\Form\Components\Textarea;
public function definition(Action $action): Action
{
return $action
->label('Reject')
->variant(Variant::Danger)
->confirm('Reject product?', 'Tell the seller why.', 'Submit rejection')
->form([
Textarea::make('reason', 'Reason')->required()->rules(['string', 'max:255']),
Select::make('replacement', 'Suggested replacement')->rules(['nullable']),
]);
}
```
The form fields are the same `Field` builders used everywhere, so [validation](/forms/validation/),
[conditions](/forms/conditional-fields/), and searchable selects all work. Validation is precognitive
by default — the modal validates as the user types.
Declare `FormData $data` on `handle()` to receive the validated, cast values — the endpoint
validates before calling you, so there's nothing to trigger yourself:
```php
public function handle(FormData $data, Request $request): ActionResult
{
$this->product($request)->update(['status' => 'rejected']);
return ActionResult::success()
->toast("Rejected: {$data->string('reason')}")
->reloadComponent('app.products');
}
```
### Sheet presentation and width
A form modal opens as a centered dialog at the default width. `->slideOut()` presents it as a
full-height sheet docked to a viewport edge, and `->modalWidth()` adjusts its width on the same
scale the [Modal component](/components/modals/) uses:
```php
use Lattice\Ui\Enums\ModalWidth;
public function definition(Action $action): Action
{
return $action
->label('Edit')
->slideOut()
->modalWidth(ModalWidth::Xl)
->form([
// …
]);
}
```
### Deferring the schema
By default the form schema ships inline with the action. For a per-record form — one prefilled from
the row it acts on — call `->lazyForm()`. The action ships a flag instead of the schema, and the
client fetches the prefilled form from the action endpoint when the modal opens.
```php
$action->lazyForm()->form([/* … */]);
```
### Building the schema per request
`->lazyForm()->form([...])` ships a fixed schema that the client fetches on open. When the schema
itself needs the request — to prefill from the record being acted on, or to vary fields by user —
extend `FormActionDefinition` instead and build it in `formSchema()`:
```php
use Lattice\Actions\FormActionDefinition;
use Lattice\Form\Components\Form;
use Lattice\Form\FormData;
use Illuminate\Http\Request;
#[AsAction('products.edit')]
final class EditProduct extends FormActionDefinition
{
public function formSchema(Form $form, Request $request): Form
{
$product = $this->product($request);
return $form->schema([
TextInput::make('name', 'Name')->value($product->name),
]);
}
public function handle(FormData $data): ActionResult
{
// …
}
}
```
Lattice marks these actions lazy automatically and fetches the schema from the trusted record context
on open, so the prefilled values never ship in the page payload. You can also delegate to an existing
[`FormDefinition`](/forms/overview/): `return app(MyForm::class)->definition($form, $request);`.
Confirmation and forms compose with everything else: the same action still returns
[effects](/actions/effects/) from `handle()`, and still runs its [authorization](/actions/overview/#authorization) check first.
# Effects & results
> What an action returns — a result carrying effects the client dispatches.
import Info from "@components/Info.astro";
import Warning from "@components/Warning.astro";
`handle()` returns an `ActionResult`: optional data and a list of **effects**
the client runs in order once the action responds.
## Results
Build a result with `success()`, optionally passing data:
```php
ActionResult::success(['id' => $product->id]);
```
Then chain effects — each returns a new result, so they read as a pipeline:
```php
return ActionResult::success()
->toast('Archived.')
->reloadComponent('app.products');
```
## Effects
| Effect | What it does |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `->toast($message, $variant)` | Shows a toast. Variant defaults to success. |
| `->callout($callout)` | Shows a persistent in-flow banner in the layout's callout slot. |
| `->retractCallout($key)` | Drops a keyed callout, even on a same-URL visit. |
| `->reloadComponent($id)` | Re-fetches a single component (e.g. the table the action changed). |
| `->reloadPage($full = false)` | Reloads the current page. `full` does a real browser reload. |
| `->to($url)` / `->toRoute($name, $params)` / `->back()` | Navigates to a URL, a named route, or the previous page. |
| `->download($url)` | Triggers a file download. |
| `->openModal($modal)` / `->closeModal($id)` | Ships a `Modal` to open, or closes one by id (`closeModal()` with no id closes every open modal). |
| `->resetForm($id)` | Resets a form to its initial values (`resetForm()` resets the current form). |
| `->localeChange($locale)` | Persists the frontend locale and dispatches `lattice:locale-change`. |
| `->toggleSidebar($target)` | Toggles the layout sidebar (optionally a named target). |
```php
return ActionResult::success()
->toast('Report ready.', Variant::Success)
->download(route('reports.download', $report));
```
This effect builder is shared with form and controller responses. A `LatticeResponse` (returned from
a form's `handle()`, or via `Effects::respond()`) exposes the identical `->toast()`, `->callout()`,
`->openModal()`, … methods. The only difference is the return: an action serializes to JSON, while a
`LatticeResponse` performs a real redirect — so `->to()` / `->toRoute()` / `->back()` navigate on the
server there, and emit a redirect effect here.
### Shipping a modal with the effect
`->openModal($modal)` takes a `Modal` instance, not an id — the modal's full schema travels in the
effect's payload, and the client-side host renders and opens it. There is no page placement to wire
up: the modal doesn't sit anywhere in the page tree, the action ships it directly.
```php
use Lattice\Ui\Components\Modal;
return ActionResult::success()->openModal(
Modal::make('order-details')
->title('Order details')
->schema([...]),
);
```
Reach for this when the modal's content depends on work the action just did — a record it looked up,
a document it generated — rather than data already available at the trigger. When the content is
already known at render time, embed the modal on the trigger instead with
[`->modal()`](/components/modals/#trigger-embedded). See [Modals](/components/modals/) for both
patterns, including how to open one on page load.
### Toasts
`->toast()` accepts a message and an optional `Variant` (the shared `Primary`…`Danger` vocabulary),
defaulting to success: `->toast('Saved.')`, `->toast('Could not save.', Variant::Danger)`. Pass a
`Toast` instead to set a lifetime, control dismissal, or attach a link or action — see
[Toasts](/actions/toasts/).
### Callouts
A callout is a persistent in-flow banner — appropriate for warnings, trials, subscription notices, or
any message that should stay visible until the user acts. Unlike a toast, a callout is not transient:
it appears in the layout where the `Callouts` slot is placed. There is no duration or auto-dismiss.
An unkeyed callout stays until the user dismisses it, and navigating between pages within the same
layout does not clear it; a keyed callout follows the lifetime described below instead.
Build a `Callout` value object and pass it to `->callout()`:
```php
use Lattice\Ui\Enums\Variant;
use Lattice\Ui\Effects\Builtin\Callout;
return ActionResult::success()
->callout(
Callout::make('Your trial ends in 3 days.', Variant::Warning)
->title('Trial ending')
->link('Upgrade', '/billing')
);
```
The `Callout` builder options:
| Method | Effect |
| --------------------------------- | ------------------------------------------------------------------------------------ |
| `->title($string)` | Optional heading above the message. |
| `->dismissible(bool)` | Show or hide the close button (default: dismissible). |
| `->link($label, $href, $method?)` | Render a link in the callout (`$method` defaults to `HttpMethod::Get`). |
| `->action($component)` | Render an action instead of a link. |
| `->unique($key)` | Treat the callout as a projection of server state under `$key` rather than an event. |
**Callout lifetimes**
Without a key, a callout is an **event**: emitted once, it stays until the user dismisses it.
With a key it is a **projection of state**. The client replaces any callout sharing the key, and
drops it on navigation unless the server asserts it again — so a keyed callout must be re-emitted on
every request for which it still holds, typically from middleware.
Retraction only happens on a navigation that **changes the URL**. A same-URL `router.reload()`, a
`redirect()->back()` to the same URL, polling, and partial reloads do not clear a keyed callout — the
server must overwrite it explicitly if the condition no longer holds. Browser back/forward is its own
case: Inertia restores those pages from a history snapshot that carries no flash data, so a keyed
callout disappears on back/forward regardless of whether the condition still holds, until the next
request re-asserts it.
```php
Callout::make(__('billing.past-due'), Variant::Danger)
->unique('billing.state')
->dismissible(false);
```
The example above disables dismissal: dismissing is a client-only action, and the next re-assertion of
the same key brings the callout straight back, so letting the user dismiss a standing condition would
just be undone on the next matching request.
This is what makes a standing condition — a failing payment, a maintenance window, a read-only mode —
expressible. Flashing an unkeyed callout on every request instead would stack a fresh copy per
navigation, because the layout that hosts the slot persists across visits.
**Retracting a keyed callout**
The same-URL cases described above — `router.reload()`, `redirect()->back()` to the same URL,
polling, and partial reloads — have no way to clear a keyed callout just by omitting it.
`Callout::retract($key)` states explicitly that the key no longer applies; the client drops any
callout carrying it regardless of visit type.
The natural place to assert or retract a keyed callout is middleware that runs on every request, so
the callout always reflects current state:
```php
Effects::flash($callout ?? Callout::retract('billing.state'));
```
Here `$callout` is `null` when the condition (a failing payment, say) no longer holds, so the
middleware falls back to retracting the key instead of asserting a `Callout`. `ActionResult` and
`LatticeResponse` expose the same behavior as `->retractCallout($key)`, for when the clearing
condition is detected inside an action rather than middleware.
Callouts hold no server-side state. A message that needs identity, a recipient, and read/unread
tracking is a [notification](/components/notifications/), not a callout.
**Placing the slot in your layout**
A callout only renders where the `Callouts` layout slot is placed. Add `Callouts::make()` to the
layout's `schema()`, typically between the header bar and `Outlet::make()`:
```php
use Lattice\Core\PageSchema;
use Lattice\Layouts\Components\Outlet;
use Lattice\Ui\Components\Callouts;
public function schema(PageSchema $schema, Request $request): PageSchema
{
return $schema->schema([
$this->headerBar(),
Callouts::make(),
Outlet::make(),
]);
}
```
A page or layout that does not include `Callouts::make()` silently drops any callout effect. If a
callout does not appear, check that the active layout's `schema()` contains the slot.
**Callout vs. toast**
| | Callout | Toast |
| ----------- | --------------------------------- | ------------------------------------ |
| Placement | In-flow, where the layout slot is | Overlay, anchored bottom center |
| Persistence | Always persistent | Auto-dismisses after a duration |
| Scope | Requires the layout slot | Global — rendered by the `` |
Use a callout when the message warrants visible, persistent attention; use a toast for transient
confirmations. See [Toasts](/actions/toasts/) for toast details.
### Refreshing what changed
After an action mutates data, refresh just the affected component rather than the whole page.
`->reloadComponent()` takes the component id — a table's `#[AsTable]` id, for example — so only that
component re-fetches:
```php
return ActionResult::success()->reloadComponent('app.products');
```
`->reloadPage()` re-fetches the current page's props with an Inertia visit — the layout and its
client state (open menus, form drafts, scroll position) stay mounted. That is the right default for
almost every server-side change, including a stale callout that should just disappear: use
`Callout::retract()` for that, not a page reload.
Pass `true` for a full browser reload (`window.location.reload()`) when the change invalidates the
whole shell and an Inertia revisit is not enough — starting or stopping impersonation, a role change
that reshapes navigation, or an asset/version bump:
```php
return ActionResult::success()->reloadPage(full: true);
```
## Flashing effects without an action
`Effects::flash()` sends any effect(s) into the session and delivers them with the next Inertia
response — no `ActionResult` needed. Use it from a controller, before returning a redirect, in an
event listener, or in middleware — anywhere outside an action:
```php
use Lattice\Ui\Effects\Builtin\Callout;
use Lattice\Ui\Enums\Variant;
use Lattice\Facades\Effects;
// Flash a callout after a controller redirect
Effects::flash(
Callout::make('Your export is being processed.', Variant::Info)
->title('Export queued')
);
return redirect('/exports');
```
Pass multiple effects to flash them all at once:
```php
Effects::flash(
Effects::toast('Settings saved.', Variant::Success),
Callout::make('Some changes require a page reload.', Variant::Warning),
);
```
The flashed effects are stored in the `latticeEffects` session bag, drained on the next request, and
run through the normal client-side effect pipeline in order.
### Deferred translation with `rt()`
Toast and callout messages (and callout titles) also accept `rt()` — a
[`Translatable`](/core/i18n/) that carries an i18next key plus replacements instead of a finished
string. The client resolves it in the viewer's locale at display time. Reach for it when the code
producing the effect can't know that locale — a queued listener flashing a callout, or a
[realtime toast](/core/realtime/) broadcast to many subscribers:
```php
Effects::flash(
Callout::make(rt('billing:trial-ending.body')->with(['days' => 3]), Variant::Warning)
->title(rt('billing:trial-ending.title'))
);
```
`->with()` also accepts `DateTimeInterface` values, formatted client-side in the reader's own
locale — see [`Translatable` and `rt()`](/core/i18n/#translatable-and-rt) for how dates travel over
the wire and render.
Effects built inside a normal request already run in the user's locale, so plain `__()` strings
remain the default there.
## How effects reach the client
The result serializes to `{ ok, data, effects }`. Each effect is a `{ type, props }` envelope — the
`type` discriminant (`toast`, `callout`, `reload-component`, `reload-page`, `redirect`, `download`,
`open-modal`, `close-modal`, `reset-form`, `locale-change`, `toggle-sidebar`) plus its props, exactly
like a node; the client dispatches them in order.
Each `type` is declared once on its PHP class via `#[AsEffect('…')]` and generated into the
discriminated `Effect` union, so the PHP helpers and the client dispatcher stay in lockstep.
## Custom effects
The effect system is extensible — define your own effect type and a client handler for it, the same
way [components](/extending/registry-and-types/) and columns extend the renderer.
On the server, an effect is a readonly value object marked with `#[AsEffect]`, where the constructor
arguments become the wire `props`:
```php
use Lattice\Ui\Effects\Attributes\AsEffect;
use Lattice\Ui\Effects\Effect;
#[AsEffect('confetti')]
final class ConfettiEffect extends Effect
{
public function __construct(public readonly int $pieces = 100) {}
}
```
Return it from `handle()` with `->effect()`, alongside any built-ins:
```php
return ActionResult::success()
->toast('Onboarding complete!')
->effect(new ConfettiEffect(pieces: 250));
```
On the client, author a handler with `effectHandler("confetti", …)` and register it through your
[registry plugin](/extending/registry-and-types/#createplugin)'s `effects` map. The handler receives
the typed payload:
```ts
import { effectHandler } from "@lattice-php/lattice";
const confetti = effectHandler("confetti", (effect) => {
launchConfetti(effect.props.pieces);
});
```
Unknown effect types are skipped with a console warning, so a server effect with no registered handler
fails soft rather than breaking the page.
Failed action requests dispatch a `lattice:action-error` DOM event carrying the error. Listen for
it to add global error handling or observability — a toast, a Sentry report — without touching
each action.
# Actions
> Server-run actions that respond to a click and return effects — toasts, redirects, refreshes, and modals — to the client.
import Mermaid from "@components/Mermaid.astro";
An action runs on the server in response to a click and returns **effects** the client dispatches: a
toast, a redirect, a component or page refresh, opening a modal. An action can both change data and
drive the UI that follows.
## How an action runs
A click posts the action's signed reference to its endpoint. The server verifies the reference (and the
trusted context baked into it), authorizes, runs `handle()`, and returns an `ActionResult` whose
effects the client then dispatches:
>B: Open the page
B->>L: GET page
L-->>B: Page payload — action trigger (signed ref + context)
U->>B: Click (confirm if required)
B->>L: POST action endpoint with signed ref
L->>L: verify ref + trusted context, authorize()
alt authorized
L->>L: handle() — run the work
L-->>B: ActionResult — effects
B->>U: Dispatch effects (toast, redirect, reload, modal)
else denied
L-->>B: 403
end`}
/>
## Defining an action
Extend `ActionDefinition` and implement two methods: `definition()` describes the trigger (label,
icon, variant, confirmation), and `handle()` runs the work and returns an `ActionResult`. The
`#[AsAction]` attribute gives the action a stable id so it can be discovered and addressed by its
[endpoint](/advanced/security/).
```php
use App\Models\Product;
use Illuminate\Http\Request;
use Lattice\Actions\ActionDefinition;
use Lattice\Actions\ActionResult;
use Lattice\Actions\Components\Action;
use Lattice\Core\Attributes\AsAction;
use Lattice\Core\Concerns\ResolvesContextModels;
use Lattice\Ui\Enums\Emphasis;
use Lattice\Ui\Enums\Variant;
#[AsAction('app.products.archive')]
class ArchiveProductAction extends ActionDefinition
{
use ResolvesContextModels;
public function definition(Action $action): Action
{
return $action
->label('Archive')
->variant(Variant::Danger)
->confirm('Archive product?', 'This hides it from the catalogue.');
}
public function handle(): ActionResult
{
$product = $this->contextModel('product_id', Product::class);
$product->update(['status' => 'archived']);
return ActionResult::success()
->toast('Product archived.', Variant::Success)
->reloadComponent('app.products');
}
}
```
`contextModel()` reads the sealed context and resolves it into the model through its own
[route binding](/core/context/#reading-it) — it aborts with a 404 when the key is missing or nothing
matches. That two-argument form comes from the opt-in `ResolvesContextModels` trait rather than
`context()` itself, which stays untyped on every definition.
When a resolver for the key is [registered](/core/context/#registering-a-resolver) once via
`Lattice::context('product', Product::class)`, the same call shrinks to a one-argument
`contextModel('product')` — memoized for the request, and usable directly on `Definition` without the
trait.
## Placing an action
Reference an action anywhere a component is accepted with `Action::use()`. The most common spot is a
table's [row actions](/tables/actions/), where `->context()` scopes it to the record:
```php
Action::use(ArchiveProductAction::class)
->context(['product_id' => $row['id']]);
```
`->context()` carries data from the page to the action; `handle()` reads it back server-side, typically
through a typed accessor like `contextModel()` (see [Defining an action](#defining-an-action)). The
context is signed into the action's reference, so it can't be tampered with on the way back.
The value doesn't have to be the scalar id — pass the model directly when you already have it, and
Lattice normalizes it to the same scalar before the action gates or seals its context:
```php
Action::use(ArchiveProductAction::class)->context(['product' => $product]);
```
This only works for a key with a registered resolver; see [Context](/core/context/#passing-models-as-context-values).
Group related actions behind a single trigger with `ActionGroup`:
```php
use Lattice\Actions\Components\ActionGroup;
ActionGroup::make('row-actions')->actions([
Action::use(EditProductAction::class)->context(['product_id' => $row['id']]),
Action::use(ArchiveProductAction::class)->context(['product_id' => $row['id']]),
]);
```
Render the same group inline when the actions should stay visible:
```php
use Lattice\Ui\Enums\Orientation;
ActionGroup::make('locale-switcher')
->label('Language')
->inline(Orientation::Horizontal)
->actions([
Action::use(SetLocaleAction::class)->context(['locale' => 'en']),
Action::use(SetLocaleAction::class)->context(['locale' => 'de']),
]);
```
## The result
`handle()` returns an `ActionResult`. Start from `ActionResult::success()`,
optionally attaching data, then chain [effects](/actions/effects/):
```php
return ActionResult::success(['id' => $product->id])
->toast('Saved.')
->reloadComponent('app.products');
```
## Authorization
Override `authorize()` to gate an action. It receives the request — the signed context is available
through `$this->context()` — and returns a boolean; a denied action never reaches `handle()`.
```php
public function authorize(Request $request): bool
{
return $this->contextModel('product_id', Product::class)->status !== 'archived';
}
```
An action's `authorize()` also runs at render time — when it's hidden from the page rather than
rejected outright, a strict accessor's 404 takes the whole page down with it. See
[Context](/core/context/#reading-it) for the `OrNull` variants to reach for there instead.
## Calling an action from a custom component
A [custom component](/extending/component-packages/) can carry an action as a prop and run it with
its own payload instead of rendering the built-in trigger. Declare a nullable `Action` prop on the
PHP component and fill it with `Action::use()`:
```php
use Lattice\Actions\Components\Action;
use Lattice\Core\Attributes\AsComponent;
use Lattice\Ui\Components\Component;
#[AsComponent('color-picker')]
final class ColorPicker extends Component
{
public ?Action $saveAction = null;
public function saveAction(string $action, array $context = []): static
{
$this->saveAction = Action::use($action, $context);
return $this;
}
}
```
On the client, `callAction` does the whole round trip: it posts a JSON payload to the action's
endpoint with its signed ref, dispatches the returned effects, and resolves with the response status
and the data attached via `ActionResult::success([...])`. The `useCallAction()` hook binds it to the
current effect dispatcher:
```tsx
import type { RendererComponent } from "@lattice-php/core/types";
import { useCallAction } from "@lattice-php/lattice/runtime";
const ColorPicker: RendererComponent<"color-picker"> = ({ node }) => {
const callAction = useCallAction();
const save = async (color: string) => {
if (!node.props.saveAction) {
return;
}
const { ok, data } = await callAction(node.props.saveAction, { color });
if (ok) {
applyColor(String(data.color ?? color));
}
};
// ...
};
```
An action the server hid or denied serializes without an endpoint; `callAction` treats it as an ok
no-op, so optimistic UI keyed on the result stays put. A rejected action (non-2xx) still dispatches
its effects — the error toast shows — and resolves `ok: false` with the response `data`. Outside a
hook context, use `callAction(action, payload, dispatch)` from `@lattice-php/action` with the
dispatcher `useEffectDispatcher()` returns.
## Next steps
- [Effects & results](/actions/effects/) — every effect an action can return.
- [Confirmation & forms](/actions/confirmation-and-forms/) — confirmation modals and collecting input before running.
- [Bulk actions](/actions/bulk-actions/) — acting on a table selection.
# Toasts
> Transient notifications raised from the server — from an action's result or flashed into the next response.
A toast is a short notification the client shows and dismisses. Toasts are raised on the server and
carry a `Variant` — the shared vocabulary (`Primary`, `Secondary`, `Success`, `Info`, `Warning`,
`Danger`) buttons use — that styles them.
## From an action
The most common source is an [action](/actions/overview/). Add a toast to the `ActionResult` it
returns; the variant is optional and defaults to success:
```php
return ActionResult::success()
->toast('Product archived.') // defaults to Success
->toast('Could not reach the warehouse.', Variant::Danger);
```
See [Effects & results](/actions/effects/#toasts) for the full effect list.
## Flashing from outside an action
To show a toast after a controller redirect, from a listener, middleware, or anywhere an `ActionResult`
is not available, use `Effects::flash()`:
```php
use Lattice\Ui\Enums\Variant;
use Lattice\Facades\Effects;
use Lattice\Form\FormData;
public function handle(FormData $data): Response
{
// … persist …
Effects::flash(Effects::toast('Profile saved.', Variant::Success));
return redirect('/profile');
}
```
The flashed toast is stored in the `latticeEffects` session bag, delivered with the next Inertia
response, and shown once. `Effects::flash()` accepts any number of effects — toast, callout,
and more — see [Effects & results](/actions/effects/#flashing-effects-without-an-action) for details.
## Building a message directly
Both paths accept a `Toast` effect. Build one explicitly to set a lifetime, control
dismissal, or attach an action, then pass it to `->toast()` (or `Effects::toast()`):
```php
use Lattice\Ui\Effects\Builtin\Toast;
return ActionResult::success()->toast(
Toast::make('Product archived.', Variant::Success)
->duration(8000) // auto-dismiss after 8s (default 4000ms)
->link('View products', '/products'), // a link rendered in the toast
);
```
The builder options:
| Method | Effect |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `->duration($ms)` | Auto-dismiss after `$ms` milliseconds (default 4000). |
| `->persistent()` | Never auto-dismiss; the toast stays until it is closed. |
| `->dismissible(false)` | Hide the close button. |
| `->link($label, $href, $method)` | Render a link in the toast (`$method` defaults to `HttpMethod::Get`). |
| `->action($component)` | Render an action instead of a link — e.g. an [`Action`](/actions/overview/) that opens a confirm dialog or [modal form](/actions/confirmation-and-forms/). |
## Rendering
Toasts render through the `` the Lattice `Provider` mounts by default, anchored bottom
center and dismissing each after its duration. Pass `toaster={false}` to the `Provider` to opt out
and mount your own.
# Bundle size
> What Lattice adds to your JavaScript bundle, how it is code-split, and a live breakdown regenerated on every docs build.
import BundleReport from "@components/BundleReport.astro";
Lattice is server-driven, but it still ships a React runtime that renders the component tree in the
browser. This page explains what that costs and how the bundle is split — and the breakdown at the
bottom is **live**, regenerated by [Sonda](https://sonda.dev) on every docs build, so it always
reflects the current `main`.
## What is measured
The numbers come from a **bench build of the npm package**: a minimal consumer entry that calls
`createLatticeApp()` with the default registry (every built-in component), compiled as a production
app build — tree-shaken and minified, exactly like your own Vite build. The package's peer
dependencies (React, React-DOM, Inertia, Echo) are external, because your app ships them whether or
not it uses Lattice. What remains is Lattice's marginal cost: its own code across
`@lattice-php/core`, `ui`, `form`, `table`, and `action`, plus the third-party dependencies it
brings along.
First-party packages are measured separately — each package page ([Tree](/packages/tree/),
[Media](/packages/media/), [Map](/packages/map/), [Calendar](/packages/calendar/),
[API Reference](/packages/api-reference/)) carries its own breakdown of what that package adds on
top of the framework.
## Code splitting
Every component loads eagerly in the entry chunk, except the ones below. Each lazy-loads its heavy
dependency from inside the component (`React.lazy` + dynamic `import()`), so that dependency loads only
when the component renders:
- The **rich editor** loads TipTap + ProseMirror.
- The **code editor** loads CodeMirror.
- The **chart** loads Recharts.
- The **date** inputs load their zag-js date picker.
- The **i18n bootstrap** loads i18next only when the backend shares the `lattice.i18n` prop.
The "Emitted JavaScript files" table below shows this split: the entry chunk and its few static
imports are what every visitor downloads; the rest arrives on demand.
## Live breakdown
## Stylesheet
The breakdown above is JavaScript only. Lattice also ships a stylesheet you import into your Tailwind
entry with `@import "@lattice-php/lattice/css"`. The source file is small — theme tokens plus a few
component styles — but it carries an `@source` directive, so your own Tailwind build scans Lattice's
components and generates the utility classes they use. The compiled CSS lands in the tens of
kilobytes raw — a handful gzipped — and its real cost depends on which components you actually render
and your own Tailwind configuration.
# Enums reference
> The backed enums used across Lattice builders, and their string values.
import EnumTable from "@components/EnumTable.astro";
Lattice uses backed enums for the fixed vocabularies that appear in builders — alignments, gaps,
variants, operators, and so on. Each is generated to a TypeScript union too, so the PHP value and the
client type can't drift. The string value in parentheses is what ends up on the wire.
The case lists below are generated by reflecting over the real enums, so this page can never fall
behind the code.
## Layout & spacing
`Lattice\Ui\Enums` — the vocabulary for arranging components, used by `Stack` and the other
layout primitives.
## Pages
`Lattice\Core\Enums` — how a [page](/core/pages/) frames itself and which
[layout](/core/layouts/) it renders into.
## Text & sizing
Sizing lives in `Lattice\Ui\Enums`; the color vocabulary lives in
`Lattice\Core\Enums` and backs the `Color` value object.
The icon names a `Lattice\Ui\Enums\Icon` accepts are the enum's own cases — one per bundled
SVG. The [Icons](/core/icons/) page covers where they come from and how to add your own.
## Code blocks
`Lattice\Ui\Enums\CodeBlockLanguage` — the built-in syntax languages accepted by a
`CodeBlock` component.
## Buttons & feedback
`Lattice\Ui\Enums`
`Variant` is used by toasts and [callouts](/actions/effects/#callouts).
## HTTP
`Lattice\Ui\Enums\HttpMethod` — the request method used by links, forms, and actions.
## Operators
`Lattice\Core\Enums\Op` — the shared comparison vocabulary used by both
[form conditions](/forms/conditional-fields/#operators) and [table filters](/tables/filtering/).
## Tables
`Lattice\Table\Enums` — column types, alignment, width, filtering, and pagination for
[tables](/tables/overview/).
## Forms
The row layout and built-in row actions used by the [repeater and builder](/forms/fields/repeater/)
fields, plus the field-type vocabulary.
## Description lists
`Lattice\Ui\Enums` — the entry-type and rendering-semantics vocabulary of the
[description list](/components/description-list/).
## Formatting
`Lattice\Ui\Enums` — date/time and number formatting options shared by UI components and
tables.
## Charts
`Lattice\Ui\Enums` — the series shapes a `Chart` component can render.
## Progress
`Lattice\Ui\Enums` — the bar and circle variants of the
[progress component](/components/progress/).
## Avatar
`Lattice\Ui\Enums` — the outline an [avatar](/components/avatar/) is clipped to.
## Chat
`Lattice\Chat\Enums` — the message roles and part kinds used by the chat components.
## Realtime
`Lattice\Realtime\Enums` — the broadcast channel visibility a page listener subscribes to.
# Remote components
> Embed components that fetch from another application through short-lived, audience-scoped browser tokens.
import Info from "@components/Info.astro";
import Warning from "@components/Warning.astro";
import Mermaid from "@components/Mermaid.astro";
Remote lets one Lattice application embed a component whose data — or whole schema — comes from a
**different** application. A page renders a `remote.data-list` (or a remote-configured chat box); the
browser exchanges a signed reference for a short-lived, audience-scoped token and calls the remote
service directly. The remote service stays in control of what it hands out, and the embedding app
never proxies the data.
Remote is an experimental POC. The wire shapes, class names, and the set of remote-capable
components may still change. Build on it with that in mind.
## Two roles
| Role | Responsibility |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| **Consumer** (the Lattice backend) | Renders the page and embeds the remote component. Hosts the token endpoint that mints browser tokens for its logged-in user. |
| **Remote service** | Serves the data endpoint (and optionally the schema manifest) that the browser calls with the issued token. |
A single application is often both — it embeds its own remote sources during development, which is
exactly what the [workbench](/contributing/local-development/) does.
## How a request flows
>B: Visit the page
B->>L: GET page
L->>L: Build DataList / resolve schema manifest
Note over L: seal ref, resolve tokenEndpoint
L-->>B: props.remote { source, audience, scopes, tokenEndpoint, ref }
B->>L: POST tokenEndpoint — ref header, session cookie
L->>L: verify ref, match source/audience/scopes, authorize
L-->>B: BrowserToken { accessToken, tokenType, expiresIn }
B->>R: GET dataEndpoint — Authorization Bearer, credentials omitted
R-->>B: { data: [ ... ] }
B->>B: render rows via dataBindings
B-->>U: Render the remote list`}
/>
The Lattice backend seals a tamper-proof `ref` that binds the node identity to a `source`, `audience`,
and `scopes`. The browser sends that `ref` (as a header, with the session cookie) back to the Lattice
backend's token endpoint, which re-checks every bound value before asking the matching source to issue
a token. The call to the remote service carries only the resulting bearer token — `credentials` are
omitted, so no cookies leak across the origin boundary.
## Embedding a data list
`DataList` (registered as `remote.data-list`) fetches rows from a remote endpoint and renders a
reusable row schema for each one. Configure the source, the audience it is for, the scopes it needs,
and the remote service's data endpoint:
```php
use Lattice\Remote\Components\DataList;
use Lattice\Ui\Components\Card;
use Lattice\Ui\Components\Text;
DataList::make('todos')
->source('workbench.todos')
->audience('https://todos.example.test')
->scopes(['todos.read'])
->dataEndpoint('https://todos.example.test/api/todos')
->emptyLabel('No todos yet')
->schema([
Card::make()->dataBindings(['title' => 'title', 'description' => 'detail']),
]);
```
Each row in the remote service's `{ "data": [...] }` payload is materialized through the child schema.
`dataBindings` map a component prop to a row key (dotted paths are supported), so the same row schema
renders every record.
`source()` and `audience()` are required together, and the node needs an `id` (the first argument
to `make()`). Serializing a remote component without them throws — the `ref` cannot be sealed
otherwise.
## Remote chat
A chat box can also read from a remote source. There is **no** `remote.chat-box` type — the regular
`chat.box` component extends the remote machinery, so you opt in by calling
`source()`/`audience()` on it:
```php
use Lattice\Chat\Components\ChatBox;
ChatBox::make('assistant')
->source('workbench.todos')
->audience('https://todos.example.test')
->scopes(['chat.read', 'chat.write'])
->streamEndpoint('https://todos.example.test/api/chat/stream')
->historyEndpoint('https://todos.example.test/api/chat/history')
->fill();
```
## Remote sources
A **source** describes a remote service. Extend `RemoteSourceDefinition`, tag it with `#[AsRemoteSource]`,
and override `issueBrowserToken()` to mint a token after the consumer's endpoint has verified the
request:
```php
use Illuminate\Http\Request;
use Lattice\Core\Attributes\AsRemoteSource;
use Lattice\Core\Remote\BrowserToken;
use Lattice\Remote\RemoteSourceDefinition;
#[AsRemoteSource('workbench.todos')]
final class TodoSource extends RemoteSourceDefinition
{
public function issueBrowserToken(Request $request): BrowserToken
{
return new BrowserToken(
accessToken: '…', // call your real authorization server here
tokenType: 'Bearer',
expiresIn: 120,
audience: $request->string('audience')->toString(),
scopes: $request->array('scopes'),
);
}
}
```
Sources are [discovered](/introduction/configuration/#discovery) like every other definition, or you
can register them explicitly:
```php
use Lattice\Core\Facades\Lattice;
Lattice::remoteSources([\App\Remote\TodoSource::class]);
// Resolve keys dynamically — e.g. one source per tenant:
Lattice::remoteSourceResolver(
fn (string $key, $container) => str_starts_with($key, 'tenant:')
? new TenantSource(/* … */)
: null,
);
```
Override `authorize(Request)` on the definition to gate token issuance per user; it defaults to
allowing the request.
## Schema-driven federation
A source can also hand the consumer an entire **schema manifest** to render, not just rows. Override
`schemaEndpoint()` to point at a JSON manifest — an allow-listed URL or a local file — and return its
nodes from `schema()`:
```php
use Lattice\Remote\RemoteSchemaEndpoint;
public function schemaEndpoint(Request $request): RemoteSchemaEndpoint
{
return RemoteSchemaEndpoint::url(
'https://todos.example.test/lattice/manifest',
allowedHosts: ['todos.example.test'],
);
// …or RemoteSchemaEndpoint::file(storage_path('manifests/todos.json'));
}
```
Render it by resolving the source on a page:
```php
public function render(PageSchema $schema, Request $request, RemoteSourceRegistry $remoteSources): PageSchema
{
return $schema->schema(
$remoteSources->resolve('workbench.todos')->schema($request),
);
}
```
The manifest is a tree of `{ "type", "id", "props", "schema" }` nodes. A remote-capable node only
needs to declare its `audience` and `scopes` — the consumer stamps the trusted `remote` access:
```json
{
"type": "remote.data-list",
"id": "todos",
"props": {
"dataEndpoint": "/api/todos",
"audience": "https://todos.example.test",
"scopes": ["todos.read"]
},
"schema": [{ "type": "card", "props": { "dataBindings": { "title": "title" } } }]
}
```
`RemoteSchemaResolver` treats everything the remote service returns as hostile. It strips any
`ref`, `remote`, `endpoint`, `tokenEndpoint`, and `action` keys from the manifest and re-stamps a
server-trusted `remote` descriptor with a freshly sealed `ref`, so a remote service can never
forge a usable token endpoint. External URL props (a data list's `dataEndpoint`, a chat box's
`streamEndpoint`/`historyEndpoint`) are validated against `allowedHosts` (relative URLs are
allowed); a host outside the list is rejected before any request is sent. The consumer's
`Accept-Language` is forwarded when fetching the manifest so the remote service can localize it.
## Frontend setup
The `remote.data-list` renderer ships in a separate plugin that you add to your registry:
```ts
import { createRegistry } from "@lattice-php/core";
import { remoteComponents } from "@lattice-php/lattice/remote";
export const registry = createRegistry(/* …core plugins…, */ remoteComponents);
```
A remote chat box needs the [chat package](/packages/chat/) instead — its plugin registers the
`chat.box` type that remote chat reuses and is picked up automatically via
`virtual:lattice/plugins`.
## Configuration
The token endpoint and its middleware live under `remote-sources` in `config/lattice.php`:
```php
'remote-sources' => [
'endpoint' => 'lattice/remote-sources/{source}/token',
'middleware' => ['web', 'auth'],
],
```
`{source}` is URL-encoded into the path, so the wire `tokenEndpoint` for a source keyed
`workbench.todos` is `/lattice/remote-sources/workbench.todos/token`. The signed `ref` lifetime is
shared with the rest of Lattice via [`security.ref_lifetime`](/introduction/configuration/#security).
See [Configuration](/introduction/configuration/#endpoints-and-middleware) for the full endpoint
table and [Security](/advanced/security/) for how component references are signed and verified.
# Security
> How Lattice signs component references so a server-driven endpoint can trust what the client sends back.
Lattice drives the UI from the server but the endpoints behind forms, tables, and actions are called
by the client. To keep that safe, every interactive component carries a **signed reference** — a
sealed token that the matching endpoint verifies before doing any work.
## The signed reference
When an interactive component (a `Form`, `Table`, `Action`, `Fragment`, …) serializes, Lattice seals a
reference into its props and ships it as the `X-Lattice-Ref` header on the component's requests. The
reference is an **encrypted** payload containing:
- the component **type** and **key** (which definition this is for),
- the **context** you attached with `->context()`,
- the current **user id** and a hash of the **session**,
- the [endpoint area](/introduction/configuration/#endpoint-areas) it was minted for,
- an **expiry** timestamp.
On the way back, the endpoint decrypts the reference and rejects the request (`403`) if the type or
key doesn't match, the token has expired, or the user, session, or endpoint area no longer matches
the one it was issued to. Only then does it run.
:::caution
A reference proves **authenticity, not entitlement**. It says the server issued this component to
this user, in this session, recently — not that the user is still allowed to use it. Permissions are
re-checked only by the definition's own [authorization](/core/authorization/). A definition with no
`can` declaration and no `authorize()` override is therefore reachable for as long as its reference
lives, even if the user's access was revoked in the meantime: revoke someone's admin role while they
have the page open and their existing references keep working until they expire. Declare the ability
on any definition whose data must reflect a permission change immediately.
:::
## Why context is trustworthy
Because the context travels inside the encrypted reference — not as ordinary request input — a client
can't change it. The endpoint restores the **trusted** context onto the definition, so
`$this->context('product_id')` always returns the value the server sealed. This is what lets
[row actions](/tables/actions/) safely carry a record id and [authorization](/core/authorization/)
trust it.
## Expiry
References expire after a configurable lifetime (30 minutes by default). Tune it with the
`lattice.security.ref_lifetime` config value (in minutes):
```php
// config/lattice.php
'security' => [
'ref_lifetime' => 30,
],
```
A longer lifetime keeps long-lived pages working without a refresh; a shorter one narrows the window
in which a captured reference could be replayed.
# Server-side rendering
> Render Lattice pages to HTML on the server with Inertia SSR and hydrate them in place on the client.
Lattice works with [Inertia SSR](https://inertiajs.com/server-side-rendering): the first visit is
rendered to full HTML on the server and the client hydrates it in place. Same pages, same
components — SSR is a deployment choice, not a different way of building.
## The SSR entry
`@inertiajs/vite` normally generates the SSR bootstrap by detecting a literal `createInertiaApp`
call — which a Lattice app doesn't have. The package ships the equivalent for `createLatticeApp`:
**`createLatticeSsr`**, on its own `@lattice-php/lattice/ssr` subpath. Create `resources/js/ssr.tsx`
next to your `app.tsx` and pass it the **same options**:
```tsx
// resources/js/ssr.tsx
import createServer from "@inertiajs/react/server";
import { createLatticeSsr } from "@lattice-php/lattice/ssr";
import plugins from "virtual:lattice/plugins";
import sprite from "virtual:svg-sprite";
createServer(
createLatticeSsr({
plugins,
sprite,
pages: import.meta.glob("./Pages/**/*.tsx"),
}),
);
```
`@inertiajs/vite` finds `resources/js/ssr.tsx` on its own and rewrites the `createServer` call into
the development endpoint and the production HTTP bootstrap — one file covers both. Keep the options
in sync with [`createLatticeApp`](/introduction/installation/#register-the-inertia-renderer) (or
extract them into a shared module); browser-only options such as `boot` never run on the server, so
sharing one options object is safe.
:::note
`createLatticeSsr` lives on its own subpath on purpose: it imports `react-dom/server`, which has no
business in a client bundle. Import it only from the SSR entry, never from `app.tsx`.
:::
## Development
Nothing else to start. With the entry in place and `inertia.ssr.enabled` on (the default), the
Laravel adapter renders through the Vite dev server directly — no separate Node process.
## Production
Build the SSR bundle alongside the client build and run the SSR server. With the Laravel Vite
plugin, point its `ssr` option at the same entry so `vite build --ssr` emits
`bootstrap/ssr/ssr.mjs` where the adapter expects it:
```ts
// vite.config.ts
laravel({
input: ["resources/css/app.css", "resources/js/app.tsx"],
ssr: "resources/js/ssr.tsx",
}),
```
```bash
vite build && vite build --ssr
php artisan inertia:start-ssr
```
## What the server renders
- The full page — layout, navigation, and every eagerly registered component. Components registered
with `lazyComponent()` render their loading fallback on the server and stream in after hydration;
register a component with `eagerComponent()` if its markup should be part of the server HTML.
- The theme. Lattice shares the `appearance` cookie with the server render, so a user who picked
dark mode gets dark-mode HTML instead of a flash of the default.
- `boot` and the rest of the client bootstrap run after hydration. On a server-rendered page the
first client render intentionally does not wait for them — hydration must match the HTML the
server produced.
## SSR-safe custom components
Anything you register yourself ([custom fields](/extending/custom-fields/),
[component packages](/extending/component-packages/)) renders on the server too. Two rules keep a
component SSR-safe:
- Don't touch `window`, `document`, or other browser globals during render — move that work into an
effect, or guard it with `typeof window === "undefined"`.
- Import `useLayoutEffect` from `@lattice-php/ui/lib/use-layout-effect` instead of `react`.
It is the same hook in the browser and substitutes `useEffect` on the server, where React's own
`useLayoutEffect` warns.
# Avatar
> A circular user image with initials and icon fallbacks.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
`Avatar::make($src?)` renders a circular avatar. Pass an image URL to show a photo; when no image is
available it falls back to the person's initials, and with no name at all it shows a neutral user icon.
direction(Orientation::Horizontal)->gap(Gap::Small)->schema([
Avatar::make('https://i.pravatar.cc/96?img=13')->name('Ada Lovelace'),
Avatar::make()->name('Grace Hopper'),
Avatar::make()->name('Katherine Johnson')->size(Size::Lg),
Avatar::make(),
]);`}
fixture="components.avatar"
/>
## Source, name, and fallbacks
- `->src($url)` sets the image. When present, the image renders and its `alt` is the name.
- `->name($name)` provides the accessible label and the initials fallback — the first letter of the
first two words, uppercased (`Grace Hopper` → `GH`).
- With neither a source nor a name, the avatar shows a neutral user glyph.
The name is used for both the `alt` text and the initials, so set it even when you pass an image —
it keeps the avatar accessible and gives it something to fall back to if the image fails.
## Size
`->size(Size $size)` scales the avatar. It defaults to `Size::Md` and accepts any
[`Size`](/advanced/enums/) from `Xs` through `Xl4`.
```php
Avatar::make()->name('Ada Lovelace')->size(Size::Xl);
```
## Shape
`->shape(AvatarShape $shape)` clips the avatar to a circle (the default) or to the theme's corner
radius. Reach for `Rounded` where the avatar sits among other rounded surfaces — a user menu next to
cards and buttons, say — so it does not read as the one round element in the row.
```php
Avatar::make()->name('Ada Lovelace')->shape(AvatarShape::Rounded);
```
# Buttons & links
> Buttons, badges, navigational links, and standalone segmented controls.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
## Button
`Button::make($label)` renders a button. Colour it with `->variant()` (`Primary`, `Secondary`,
`Success`, `Info`, `Warning`, `Danger`) and shape its emphasis with `->emphasis()`
(`Solid`, `Outline`, `Ghost`, `Link`) — see [`Variant` and `Emphasis`](/advanced/enums/).
Solid is the default emphasis and reads as primary until you set a variant.
direction(Orientation::Horizontal)->gap(Gap::Small)->schema([
Button::make('Primary'),
Button::make('Secondary')->variant(Variant::Secondary),
Button::make('Success')->variant(Variant::Success),
Button::make('Info')->variant(Variant::Info),
Button::make('Warning')->variant(Variant::Warning),
Button::make('Danger')->variant(Variant::Danger),
Button::make('Outline')->emphasis(Emphasis::Outline),
Button::make('Ghost')->emphasis(Emphasis::Ghost),
]);`}
fixture="components.buttons"
/>
A button carries exactly one click behavior — `->href()`, `->action()`, or `->effects()` — plus
optional styling:
- `->href($url)` renders it as a link.
- `->action(SomeAction::class)` runs a server [action](/actions/overview/) on click.
- `->effects(Effects::…)` dispatches client-side [effects](/actions/effects/) on click with no request
to the server, e.g. `->effects(Effects::toggleSidebar('app-sidebar'))`.
- `->submit()` makes it a form's submit control — see [Forms](/forms/overview/#the-submit-button).
- `->icon($name)` adds a sprite [icon](/core/icons/) before the label.
## Badge
`Badge::make($label)` renders the soft tone chip used across the system — the same look
[badge columns](/tables/columns/badge/) produce in tables. Color it with `->color()`, which takes
any of the 14 named tones or a raw CSS color; unset badges render gray:
```php
Badge::make('Active')->color('green');
Badge::make('Beta')->color(Color::purple());
Badge::make('Draft');
```
## Link
`Link::make($label)->href($url)` renders a navigational link. It navigates through Inertia by default,
takes an `->icon($name)`, and supports affixes. Like a button, it can instead trigger an
[action](/actions/overview/) or dispatch client [effects](/actions/effects/) rather than navigating.
```php
Link::make('Documentation')->href('https://latticephp.com');
```
## Segmented control
`SegmentedControl::make($name, $label?)` renders single-select pills that live **outside** a form and
emit a client event when the selection changes — reach for it for client-side settings like an
appearance switcher, not as a form field (use [Choice](/forms/fields/choice/) for that). Give it
`->options([...])`, an initial `->value()`, and `->emits($event)` to name the window event dispatched
on change (its `detail` carries `{ name, value }`).
options([
SegmentedControl::option('Light', 'light'),
SegmentedControl::option('Dark', 'dark'),
SegmentedControl::option('System', 'system'),
])
->value('light')
->emits('appearance-changed');`}
fixture="components.segmented-control"
/>
`->options()` also accepts an enum class-string or an associative `value => label` array, the same
as form fields — see [Choice](/forms/fields/choice/) and [Select](/forms/fields/select/).
# Charts
> Data charts — line, bar, area, pie, doughnut, gauge, and distribution series from a single builder.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
import Warning from "@components/Warning.astro";
`Chart::make($title)` renders a data chart. You feed it rows with `->data()`, name the category (X)
axis with `->categoryKey()`, then declare one or more **series** — each series plots one data key as a
line, bar, area, or pie. The PHP builder serializes to a typed node the renderer draws with
[Recharts](https://recharts.org).
description('New users per month')
->categoryKey('month')
->data([
['month' => 'Jan', 'free' => 240, 'pro' => 90],
['month' => 'Feb', 'free' => 300, 'pro' => 140],
['month' => 'Mar', 'free' => 280, 'pro' => 180],
['month' => 'Apr', 'free' => 360, 'pro' => 240],
['month' => 'May', 'free' => 420, 'pro' => 320],
])
->line('free', 'Free')
->line('pro', 'Pro')
->height(260);`}
fixture="charts.line"
/>
## Data and series
A chart is `->data()` — a list of rows — plus one or more series. Each series names the row key it
plots, with an optional display name and color:
- **`->line($dataKey, $name?, $color?)`** — a line.
- **`->bar($dataKey, $name?, $color?, $stackId?)`** — a bar; stackable via `$stackId`.
- **`->area($dataKey, $name?, $color?, $stackId?)`** — a filled area; stackable via `$stackId`.
- **`->pie($dataKey, $nameKey?, $name?, $color?)`** — a pie; `$nameKey` names each slice.
- **`->doughnut($dataKey, $nameKey?, $name?, $color?, $innerRadius = '60%')`** — a pie with a hole; `$innerRadius` sizes it.
- **`->gauge($dataKey, $nameKey?, $name?, $color?, $maxValue?, $innerRadius = '70%')`** — a semicircle gauge; one ring per row, scaled against `$maxValue`.
- **`->distribution($dataKey, $nameKey?, $name?, $color?)`** — a proportional segmented bar; one segment per row.
`->categoryKey($key)` names the row field used for the X axis. `->height($px)` sets the plot height
(default `320`).
Cartesian charts only show the X axis when a `->categoryKey()` is set — without it the series
still plot, but the axis ticks are hidden. Always set it for line, bar, and area charts.
## Bar charts
Two `->bar()` series render grouped side by side:
categoryKey('week')
->data([
['week' => 'W1', 'online' => 120, 'store' => 80],
['week' => 'W2', 'online' => 150, 'store' => 70],
['week' => 'W3', 'online' => 170, 'store' => 90],
['week' => 'W4', 'online' => 210, 'store' => 110],
])
->bar('online', 'Online')
->bar('store', 'In-store')
->height(260);`}
fixture="charts.grouped-bar"
/>
### Stacking
Give bars — or areas — the same `stackId` to stack them into one column instead:
description('Stacked by revenue type')
->categoryKey('month')
->data([
['month' => 'Jan', 'new' => 1200, 'expansion' => 300],
['month' => 'Feb', 'new' => 1500, 'expansion' => 450],
['month' => 'Mar', 'new' => 1800, 'expansion' => 600],
['month' => 'Apr', 'new' => 2100, 'expansion' => 780],
])
->bar('new', 'New', stackId: 'mrr')
->bar('expansion', 'Expansion', stackId: 'mrr')
->height(260);`}
fixture="charts.stacked-bar"
/>
## Area and composed charts
Series types mix freely in one chart. Declaring an `->area()` and a `->line()` together layers the
line over the filled band:
description('Actuals as a line over the forecast band')
->categoryKey('month')
->data([
['month' => 'Jan', 'forecast' => 26000, 'revenue' => 28000],
['month' => 'Feb', 'forecast' => 30000, 'revenue' => 32000],
['month' => 'Mar', 'forecast' => 34000, 'revenue' => 36500],
['month' => 'Apr', 'forecast' => 37000, 'revenue' => 34000],
['month' => 'May', 'forecast' => 39500, 'revenue' => 41500],
])
->area('forecast', 'Forecast')
->line('revenue', 'Revenue')
->height(260);`}
fixture="charts.composed"
/>
When every series shares one type, Lattice draws it with the dedicated Recharts container
(`LineChart` / `BarChart` / `AreaChart`); mixing types falls back to `ComposedChart`. This follows
the series you declare — there's nothing to configure.
## Pie charts
A `->pie()` series plots one value per row as slices. `nameKey` names each slice; give a row a `color`
field to color its slice, or let the theme palette pick:
description('Share of total revenue')
->data([
['channel' => 'Direct', 'amount' => 42000, 'color' => '#2563eb'],
['channel' => 'Partner', 'amount' => 27000, 'color' => '#16a34a'],
['channel' => 'Marketplace', 'amount' => 19000, 'color' => '#f59e0b'],
['channel' => 'Retail', 'amount' => 12000, 'color' => '#dc2626'],
])
->pie('amount', nameKey: 'channel')
->height(260);`}
fixture="charts.pie"
/>
Pies, gauges, and distribution bars can't share a chart with cartesian series. If a chart declares
any line, bar, or area series, only those render and the other series are dropped. Declaring
several pie, gauge, or distribution series together renders only the first. Give each its own
chart.
### Doughnut
`->doughnut()` is a pie with a hole in the middle — same data shape, same `nameKey` and coloring. It
takes an optional `$innerRadius` (default `'60%'`) that sets the size of the hole:
description('Share of total revenue')
->data([
['channel' => 'Direct', 'amount' => 42000, 'color' => '#2563eb'],
['channel' => 'Partner', 'amount' => 27000, 'color' => '#16a34a'],
['channel' => 'Marketplace', 'amount' => 19000, 'color' => '#f59e0b'],
['channel' => 'Retail', 'amount' => 12000, 'color' => '#dc2626'],
])
->doughnut('amount', nameKey: 'channel')
->height(260);`}
fixture="charts.doughnut"
/>
## Gauges
`->gauge()` plots each row as a radial ring sweeping a semicircle — a value read against a maximum.
`$maxValue` sets the scale; omit it to scale against the largest row value. `nameKey` labels each
ring, and `$innerRadius` (default `'70%'`) sets the ring thickness. A single-row gauge prints its
formatted value in the center of the arc; multiple rows render as concentric rings sharing one scale:
description('Current utilization')
->data([
['label' => 'CPU', 'value' => 72],
])
->gauge('value', nameKey: 'label', maxValue: 100)
->valueFormat(NumberFormat::make()->unit(NumberFormatUnit::Percent))
->height(260);`}
fixture="charts.gauge"
/>
Gauges ignore the grid and axes, so `->categoryKey()` and `->categoryFormat()` have no effect —
`->valueFormat()` drives the center label and tooltip.
## Distribution bars
`->distribution()` plots each row as a segment of one proportional bar — the linear sibling of a
pie. Segments size themselves against the row total, the legend prints each share as a percentage,
and hovering a segment shows its exact value formatted with `->valueFormat()`. Rows with zero or
negative values are skipped:
description('Share of total revenue')
->data([
['channel' => 'Direct', 'amount' => 42000],
['channel' => 'Partner', 'amount' => 27000],
['channel' => 'Marketplace', 'amount' => 19000],
['channel' => 'Retail', 'amount' => 12000],
])
->distribution('amount', nameKey: 'channel')
->valueFormat(NumberFormat::currency('USD')->compact());`}
fixture="charts.distribution"
/>
Distribution bars render as plain markup and size to their content, so `->height()`, the grid, and
the axes have no effect.
## Colors and theming
Omit a series color and Lattice cycles a palette built on your [theme tokens](/theming/) —
`--lt-primary`, `--lt-success`, `--lt-info`, `--lt-warning`, `--lt-danger`, `--lt-muted-fg` — so
charts follow light and dark mode automatically. Pass an explicit `color` to override — a colour
name (`'success'`, `Color::success()`) resolves to the matching theme token, and any CSS colour
(`'#2563eb'`, `Color::hex('#2563eb')`) is used as-is. Give a CSS colour a `->dark()` counterpart to
swap it in dark mode:
```php
Chart::make('Signups')
->line('total', color: Color::hex('#2563eb')->dark('#60a5fa'));
```
For pies, gauges, and distribution bars, a per-row `color` data field — the same named colours or
CSS colours — wins over the series color.
## Formatting values and categories
By default the axes print raw values. Attach a **format** to render them locale- and timezone-aware —
the same Intl formatting the tables use. The **value** axis (always numeric) takes a `NumberFormat`; the
**category** axis takes either a `NumberFormat` or a `DateFormat`.
description('Compact currency on the value axis, month labels on the category axis')
->categoryKey('month')
->data([
['month' => '2026-01-01', 'revenue' => 28000],
['month' => '2026-02-01', 'revenue' => 32000],
['month' => '2026-03-01', 'revenue' => 36500],
['month' => '2026-04-01', 'revenue' => 41500],
])
->line('revenue', 'Revenue')
->categoryFormat(DateFormat::monthYear()) // raw dates → 'Jan 2026', localized
->valueFormat(NumberFormat::currency('USD')->compact()) // 28000 → $28K
->height(260);`}
fixture="charts.formatting"
/>
`NumberFormat` mirrors the numeric table columns: `->decimals($min, $max?)`, `->compact()`,
`NumberFormat::currency($code)`, and `->unit(NumberFormatUnit::Percent)`. For dates,
`DateFormat::date()`, `::time()`, and `::dateTime()` take a `DateTimeStyle` (Full/Long/Medium/Short),
while `DateFormat::month()` and `::monthYear()` render just the month (`Jan`) or month and year
(`Jan 2026`) — pass `long: true` for the full month name. All format with the active locale and
timezone, so you feed the chart **raw dates** instead of pre-translated labels.
The value axis is always numeric, so `->valueFormat()` only accepts a `NumberFormat`. The
category can be a string (no format needed), a number, or a date, so `->categoryFormat()` accepts
either a `NumberFormat` or a `DateFormat`.
Date categories are parsed on the client with `new Date(...)`, so pass **ISO-8601** date strings.
Carbon instances (including Eloquent date casts) serialize to ISO-8601 automatically and work
as-is; plain PHP `DateTime` objects and numeric Unix timestamps do **not** parse — format them to
an ISO string first.
## Chrome
Every part of the chart frame toggles independently. All default to on:
- `->legend(bool)` — the series legend.
- `->tooltip(bool)` — the hover tooltip.
- `->grid(bool)` — the background grid.
- `->xAxis(bool)` / `->yAxis(bool)` — the axes.
- `->description($text)` — a subtitle under the title.
```php
Chart::make('Signups')
->data($rows)
->categoryKey('month')
->line('total')
->legend(false)
->grid(false);
```
## Dynamic series
When the series aren't known ahead of time, build `ChartSeries` value objects
(typed by [`ChartSeriesType`](/advanced/enums/#charts)) and hand them to `->series([...])` instead of the per-type helpers. The static factories
mirror the fluent methods:
```php
use Lattice\Ui\Values\ChartSeries;
$series = collect($metrics)
->map(fn (string $key): ChartSeries => ChartSeries::line($key, ucfirst($key)))
->all();
Chart::make('Metrics')
->data($rows)
->categoryKey('day')
->series($series);
```
# Description list
> Label/value rows describing one subject, optionally expanding to reveal an editor.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
`DescriptionList::make()` lays out label/value pairs for a single subject — the read-only counterpart
to a [form](/forms/overview/). Give it a record and each entry reads its own value from it:
schema([
DescriptionList::make()->bleed()->record($user)->schema([
TextEntry::make('name'),
TextEntry::make('email')->copyable(),
DateEntry::make('joined_at', 'Joined')->style(DateTimeStyle::Long),
BooleanEntry::make('is_active', 'Active'),
]),
]);`}
fixture="components.description-list"
/>
An entry's label defaults to its name in headline case (`joined_at` → `Joined at`); pass a second
argument to override it. The record may be an associative array, a keyed Collection, a model, or
another object. Entry names use Laravel's `data_get()`, so dotted paths such as `profile.email` work
across those record types.
When the list has no record, it treats its entries as a row template and binds each value to the
entry name through Lattice's `dataBindings`. This lets the same list work inside a client-materialised
schema. Use `->dataKey('value', 'profile.email')` when the row path differs from the entry name.
`->record()` is the source for record-backed lists and does not add automatic row bindings.
`->value('…')` — which also accepts a Closure resolved against the render context — overrides the
record and prevents an automatic binding. An explicit `->dataKey('value', '…')` remains an
intentional client-materialisation instruction.
## Entries
| Entry | Renders |
| ---------------- | ----------------------------------------------------------------------------------------------------- |
| `TextEntry` | The value as text. `->copyable()` adds a copy affordance, `->placeholder('—')` covers an empty value. |
| `DateEntry` | A localised date. `->style(DateTimeStyle::…)` or `->dateTime()`. |
| `BooleanEntry` | A check or cross icon. `->icons($true, $false)` swaps them. |
| `BadgeEntry` | The value as a [badge](/components/layout/); `->color(…)` tints it. |
| `ComponentEntry` | Any component as the value — a stack, a segmented control, whatever the schema can express. |
`ComponentEntry` is the escape hatch when a value is not a formatted scalar:
```php
ComponentEntry::make('appearance', __('Theme'))
->value(SegmentedControl::make('appearance')->options([...])),
```
## Dividers and bleed
Rows are separated by hairlines; `->divided(false)` turns them off. Inside a padded panel such as a
[`Card`](/components/layout/#card) the lines stop at the gutter — `->bleed()` runs them to the panel
edge while the rows stay aligned with the rest of its content.
## Disclosure
An entry with `->disclosure([...])` turns its row into a toggle that reveals the content beneath it.
The typical body is the form that edits the value the row displays:
schema([
DescriptionList::make()->bleed()->schema([
TextEntry::make('email')->value('ada@example.com'),
TextEntry::make('password')
->value('••••••••')
->disclosure([Form::use(PasswordForm::class)]),
]),
]);`}
fixture="components.description-list-disclosure"
/>
A list of plain entries renders as a `
` of `
`/`
` pairs, so a screen reader announces
each label with its value. One entry with a disclosure switches the whole list to `role="list"`:
the row becomes the toggle, and a `
## Empty state
`->emptyLabel('No memberships yet')` covers a list whose entries all resolved to nothing — building
rows from a collection that turned out empty, for instance.
# Floating panel
> Pin a group of children to a corner of the viewport — useful for compact persistent controls.
import Info from "@components/Info.astro";
`FloatingPanel::make()` fixes its children to a corner of the viewport, so they stay in place as the
page scrolls. It suits compact persistent controls — a language or appearance switcher, a support
launcher — that should always be reachable without taking a slot in the page flow.
```php
FloatingPanel::make()
->placement(FloatingPlacement::BottomEnd)
->schema([
SegmentedControl::make('appearance')
->options(['light' => 'Light', 'dark' => 'Dark'])
->emits('appearance-changed'),
]);
```
- `->placement(FloatingPlacement::…)` sets the corner: `BottomEnd` (default), `BottomStart`, `TopEnd`,
or `TopStart` (see the [enums reference](/advanced/enums/)).
- `->offset($px)` is the distance from the viewport edge — `16` by default.
- `->label('…')` labels the panel for assistive tech.
- `->trigger([...])` sets the components rendered as the panel's trigger.
Because a floating panel is fixed to the viewport rather than the page, its examples here are
shown as code — a live preview would escape this box and pin itself to the corner of the docs
window.
# Image
> A server-addressable image with a built-in click-to-zoom lightbox.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
`Image::make($src)` renders an image. Clicking it opens the full-size image in a lightbox overlay —
press Esc, click the backdrop, or use the close button to dismiss it.
direction(Orientation::Horizontal)->gap(Gap::Small)->schema([
Image::make('https://picsum.photos/id/1060/600/400')
->alt('Coffee brewing setup')
->size(96),
Image::make('https://picsum.photos/id/1080/600/400')
->alt('Strawberries')
->size(96)
->circular(),
Image::make('https://picsum.photos/id/1084/600/400')
->alt('Walrus resting')
->size(96)
->previewable(false),
]);`}
fixture="components.image"
/>
## Options
- `->alt($text)` sets the accessible description. Always set it — it labels both the inline image and
the lightbox for screen readers.
- `->size(96)` fixes the rendered size in pixels (square). Without it the image keeps its natural size.
- `->circular()` clips the image to a circle. For people, prefer the [Avatar](/components/avatar/)
component and its initials fallback.
- `->previewable(false)` turns the lightbox off and renders a plain image.
- `->previewSrc($url)` gives the lightbox a larger source than the inline `src` — render a thumbnail
conversion inline and open the original on click.
The [Image column](/tables/columns/image/) renders through the same lightbox, so table thumbnails
get click-to-zoom for free.
# Layout
> Stack, Grid, and Card — the containers that arrange and frame a page's children.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
Layout components arrange their children. They are containers — pass children to `->schema([...])`,
and nest them freely. This page covers the three you reach for most; the disclosures
([Section & Collapsible](/components/section-collapsible/)) and the corner-pinned
[Floating panel](/components/floating-panel/) have their own pages.
## Who decides width
Containers do — never the content. A form, a heading, a paragraph and a table all fill whatever
contains them, so the same definition looks right in a card, a tab, a grid column and a modal
without knowing which one it landed in.
That leaves two places to set a measure:
- **The page**, via [`#[AsPage(width: PageWidth::…)]`](/core/pages/). Caps the whole page and
centres it in the layout slot, so a heading and the form beneath it stay aligned.
- **A container in the schema**, via `Stack::make()->width(Width::…)`, when one zone of a page needs
a narrower measure than the rest — a settings form beside a full-width table, say.
Forget both and content stretches to the full content width. That is the intended failure mode: a
stretched form is obvious on sight, whereas a component quietly overriding its container's layout is
not.
## Stack
`Stack::make()` lays children out in one direction with a consistent gap. It defaults to a vertical
column; pass `->direction(Orientation::Horizontal)` to lay them out horizontally.
direction(Orientation::Horizontal)->gap(Gap::Small)->schema([
Button::make('Save'),
Button::make('Cancel')->emphasis(Emphasis::Ghost),
]);`}
fixture="components.stack"
/>
Tune the layout with enum-typed options (see the [enums reference](/advanced/enums/)):
- `->gap(Gap::ExtraSmall … ExtraLarge)` — the space between children.
- `->align(Align::…)` and `->justify(Justify::…)` — cross-axis and main-axis alignment.
- `->width(Width::…)` and `->height(Height::…)` — sizing. The capped widths (`Small` … `ExtraLarge`)
centre the stack; add `->float(Side::Start)` or `->float(Side::End)` to pin it to one edge instead.
- `->float(Side::…)` — float the stack to one side.
- `->sticky()` — pin the stack below the sticky chrome above it (a sticky `Topbar`, an earlier sticky
stack) while the page scrolls. The stack paints the page background, keeps a small gutter once
stuck, and publishes its own height as the sticky offset for its siblings, so a sticky vertical
tab rail or another sticky stack further down lines up beneath it. A page header with the title
and primary actions is the typical candidate.
## Grid
`Grid::make()->columns(…)` lays children out in a responsive CSS grid:
- `->columns(3)` — three equal columns from the `md` breakpoint up, one column below.
- `->columns(['default' => 1, 'md' => 2, 'xl' => 4])` — an explicit count per breakpoint
(`default`, `sm`, `md`, `lg`, `xl`, `2xl`), mobile-first: each value applies from its
breakpoint up until a larger breakpoint overrides it.
- `->columns('2fr 1fr 1fr 1fr')` — a raw
[`grid-template-columns`](https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template-columns)
track list for unequal columns, e.g. one wide plus three narrow. Track lists work inside
breakpoint maps too.
Any child component or field controls how many columns it covers with `->columnSpan(n)`
(from `md` up), a breakpoint map, or `->columnSpanFull()` to stretch across the whole row.
columns(3)->schema([
Badge::make('Wide')->columnSpan(2),
Badge::make('Narrow'),
Badge::make('Full width')->columnSpanFull(),
]);`}
fixture="components.grid"
/>
A span larger than the active column count overflows the grid — pair breakpoint-mapped spans with
matching `columns` maps when a grid gets narrow.
## Responsive visibility
Every component can opt out of rendering at a breakpoint range: `->hiddenFrom(Breakpoint::Md)`
hides it at `md` and up, `->visibleFrom(Breakpoint::Md)` shows it only from `md` up. The toggle is
CSS-only (a `display: contents` wrapper), so it is layout-transparent inside flex and grid parents —
use it for chrome that differs between mobile and desktop, like a logo mark that only appears in a
mobile topbar.
## Card
`Card::make('Title', 'Description')` wraps children in a bordered panel. Both arguments are optional —
omit them for a plain panel. Call `->tooltip('…')` to attach an ⓘ info popover next to the title; its
content is trusted HTML and may include links.
tooltip('These settings affect everyone on the team.')
->schema([
Stack::make()->gap(Gap::Small)->schema([
Heading::make('Members', 2),
Text::make('Three people have access to this team.'),
Badge::make('3 active'),
]),
Button::make('Invite member'),
]);`}
fixture="components.card"
/>
Need the panel to collapse or carry buttons in its header? Reach for
[`Section`](/components/section-collapsible/), which is a `Card`-like titled panel with those
extras.
# Modals
> A dialog embedded on a trigger or shipped by a server effect.
import Info from "@components/Info.astro";
import Warning from "@components/Warning.astro";
`Modal::make($id)->title('…')->schema([...])` declares a dialog. It never sits in the page tree on
its own — a single host renders it, reached one of two ways.
The host is a stack, not a single slot: opening a modal from inside another one pushes it above
the current modal instead of replacing it, and an action's confirmation or form dialog (see
[Confirmation & forms](/actions/confirmation-and-forms/)) opens the same way. Opening the *same*
`Modal::make($id)` again — the same instance re-triggered, or a re-dispatched `open-modal` effect
— replaces that entry in place rather than pushing a duplicate.
## Trigger-embedded
Pass a `Modal` to `->modal()` on `Button`, `Link`, or `MenuItem`. Clicking the trigger opens it —
no id to wire up, no effect to dispatch:
```php
use Lattice\Ui\Components\Button;
use Lattice\Ui\Components\Modal;
Button::make('Invite')
->modal(
Modal::make('invite-member')
->title('Invite a member')
->description('They will receive an email invitation.')
->schema([
// form fields, text, buttons…
]),
);
```
`->modal()` also accepts a closure returning a `Modal`, resolved like any other
[closure parameter](/core/closure-evaluation/) at serialization time — reach for it when the modal's
content depends on data that is expensive to build up front, or should be re-evaluated per render:
```php
Button::make('Details')->modal(fn (): Modal => Modal::make('order-details')
->title("Order #{$this->order->id}")
->schema([...]));
```
A clickable component carries exactly one behavior: an `href`, an `action`, `effects`, or a `modal`.
Setting a second one throws — `Button::make('Details')->href('/x')->modal(...)` is rejected the same
way `->href()->action()` is.
Reuse the same modal on two triggers by passing the same `Modal` instance to both `->modal()`
calls — the host renders whichever trigger opened it last.
## Effect-shipped
An [action](/actions/overview/) can ship a modal instead of embedding one on a trigger — the modal
travels in the `open-modal` [effect](/actions/effects/)'s payload:
```php
use Lattice\Actions\ActionResult;
use Lattice\Ui\Components\Modal;
return ActionResult::success()->openModal(
Modal::make('order-details')
->title('Order details')
->schema([...]),
);
```
This is the right shape when the modal's content depends on server-side work the action just did —
looking up a record, rendering a generated document — rather than data already available at the
trigger.
For a modal that should be open on page load, flash the same effect from a controller or middleware
instead of embedding it in the tree, with `Effects::flash()`:
```php
use Lattice\Facades\Effects;
use Lattice\Ui\Components\Modal;
Effects::flash(Effects::openModal(
Modal::make('welcome')->title('Welcome back')->schema([...]),
));
return redirect('/dashboard');
```
`->closeModal($id)` closes the hosted modal if it matches `$id`; `->closeModal()` with no argument
closes every modal currently open, not just the topmost one.
- `->title('…')` and `->description('…')` set the dialog header.
- `->closeLabel('…')` relabels the close button (defaults to `Close`).
- `->slideOut()` docks the dialog to a viewport edge as a full-height sheet; `->slideOut(Side::Start)`
picks the leading edge instead of the trailing one.
- `->width(ModalWidth::…)` sets the dialog width — the max width of a centered dialog and the panel
width of a sheet. Defaults to `ModalWidth::Lg` (32rem). The scale runs `Sm` (24rem) through `Xl7`
(80rem), plus `ModalWidth::Max`, which stretches to the viewport minus a 1rem inset.
- A centered dialog grows with its content: the backdrop is the scroll container, so a long form
scrolls the page behind the dialog instead of a scrollbar inside it. `->height(ModalHeight::…)`
caps the dialog instead (`Sm` ≈ 480px through `Xl5` ≈ 1280px) and scrolls content beyond the cap
inside the dialog. `ModalHeight::Max` pins the dialog to the viewport height minus a 1rem inset —
combined with `ModalWidth::Max` the dialog fills the screen. Sheets are always full height, so
`->height()` has no effect on them.
## Slide-out sheets
`->slideOut()` presents the dialog as a full-height sheet docked to a viewport edge instead of a
centered window — the pattern for quick previews and edits alongside a table. The default edge is
the trailing one; pass a `Side` to dock it to the leading edge.
```php
use Lattice\Ui\Enums\ModalWidth;
use Lattice\Ui\Enums\Side;
Modal::make('order-preview')
->title('Order #1042')
->slideOut()
->width(ModalWidth::Xl2)
->schema([
// …
]);
Modal::make('saved-filters')
->title('Saved filters')
->slideOut(Side::Start)
->schema([
// …
]);
```
Sheets and centered dialogs share the same width scale: `->width()` caps a centered dialog and sets
a sheet's panel width.
## Stacking
Opening a modal while another is already open — a button's `->modal()` inside a modal's schema, or
a row action's confirmation dialog above a modal that contains its table — pushes a new entry onto
the host rather than replacing the current one. Closing the top entry (Escape or its close button)
reveals whatever was open underneath; `->closeModal()` with no id closes the entire stack instead of
just the topmost entry.
Known issue: the z-scale is flat across the stack, so a second dialog's backdrop paints underneath
the first dialog's content instead of between the two. This affects every stacked dialog and is
cosmetic only — the stack order, focus, and closing behavior are all correct.
The host itself is cleared on real page navigation — an open modal does not survive a redirect or a
link to another page, since its content could otherwise hold stale props from the page it was opened
on. A partial reload (`->reloadComponent()`, polling, an action's own table refresh) does not count
as navigation and leaves an open modal untouched.
## Lazy content
A modal's schema can include a `Fragment::lazy(...)` — its content fetches from the server the first
time the modal opens, not when the trigger renders, so a rarely-opened modal never pays for data the
page doesn't need:
```php
use Lattice\Fragments\Components\Fragment;
Button::make('Activity')
->modal(
Modal::make('activity-log')
->title('Activity log')
->schema([
Fragment::lazy(ActivityLogFragment::class),
]),
);
```
See [Fragments](/core/fragments/) for how `Fragment::lazy()` works.
Because a modal takes over the viewport, its examples here are shown as code rather than a live
preview.
# Notifications
> A server-driven notification bell backed by Laravel's native notifications, with realtime delivery and a polling fallback.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
import Warning from "@components/Warning.astro";
The notifications bell is an opt-in module: a fluent `Notification` builder that sends a
self-describing payload through Laravel's native notification channels, and a `Notifications` bell
component that lists, reads, and dismisses them. Nothing is enabled until you publish the migration
and mark your notifiable model.
## Prerequisite
Lattice does not ship its own notifications table — it rides Laravel's native `notifications`
table. Publish it and add the `Notifiable` trait to whichever model receives notifications
(usually `User`):
```bash
php artisan notifications:table
php artisan migrate
```
```php
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
}
```
Without `Notifiable`, `$user->notify(...)`, `$user->notifications()`, and the bell's endpoints have
nothing to call.
Lattice notifications are queued — the internal notification implements `ShouldQueue`. A running
queue worker (`php artisan queue:work`) is required for them to be delivered and appear in the
bell; without one, a `->send()` sits in the queue and nothing shows up. In local development you
can set `QUEUE_CONNECTION=sync` to process them inline instead.
## The bell
The live preview above can't reach a real endpoint from a static docs page, so it renders empty —
in your app it hydrates from the [config-driven endpoint](#configuration) on mount.
## Sending a notification
`Notification::make()` builds the payload fluently, then `->send()` or `->sendToDatabase()` dispatches
it to any notifiable:
```php
use Lattice\Ui\Enums\Variant;
use Lattice\Notifications\Notification;
Notification::make()
->title('Order #1234 shipped')
->body('Tracking is now available.')
->icon('truck')
->variant(Variant::Success)
->href('/orders/1234')
->action(MarkOrderSeenAction::class, ['order_id' => 1234])
->link('Track shipment', '/orders/1234/track')
->send($order->user);
```
- **`->title()`** / **`->body()`** — the headline and supporting text.
- **`->icon($name)`** — a [sprite icon](/core/icons/) name or a backed enum case.
- **`->variant(Variant::…)`** — the same [`Variant`](/advanced/enums/#buttons--feedback) enum toasts
and callouts use; drives the bell item's accent color.
- **`->href($url)`** — makes the whole row a link: clicking the title/body navigates to `$url` (an
internal app route, followed as an Inertia visit) and marks the notification read.
- **`->action($actionClass, $arguments = [], $label = null)`** — attaches a real
[Lattice action](/actions/overview/) button, referenced by class name.
- **`->link($label, $url)`** — attaches a plain link button (an internal app route) instead of (or
alongside) an action.
- **`->send($notifiable)`** — delivers over `database` and `broadcast`.
- **`->sendToDatabase($notifiable)`** — persists only, without a broadcast event.
Notification links point at **internal app routes** and are followed as Inertia visits, not external
URLs. For a link off-site, put the destination in an [`->action()`](/actions/overview/) that
redirects, or link out from the page the notification opens.
Actions and links are stored as lightweight **descriptors**, not materialized components — the row
only carries an action's class name and arguments. The real [`Action`](/actions/overview/) is
built and signed when the notification is **fetched**, using the reading user's own authorization
context. If `$actionClass` is no longer registered, the descriptor is silently dropped from that
row instead of breaking the fetch.
## Localized notifications
A plain string bakes the sender's locale into the stored row — fine for content that is already
user-specific, wrong for UI copy read later by someone whose language you don't know at send time.
Pass `rt()` (a [`Translatable`](/core/i18n/)) instead and the notification stores the **key plus
replacements**; the bell resolves it through i18next in the reading user's locale, at render time,
and re-resolves it live when they switch languages:
```php
Notification::make()
->title(rt('orders:notification.shipped.title'))
->body(rt('orders:notification.shipped.body')->with(['order' => $order->number]))
->href("/orders/{$order->id}")
->send($order->user);
```
The key is an i18next key: with [laravel-i18next](/core/i18n/) serving your `lang/` files, each file
is a namespace, so `lang/en/orders.php` with `'notification' => ['shipped' => ['title' => '…']]` is
`orders:notification.shipped.title`. Make sure the namespace is in the `namespaces` list your app
passes to `createLatticeApp` — an unloaded namespace renders the raw key. Laravel's `:placeholder`
syntax is served as i18next `{{placeholder}}` tokens, so `->with([...])` replacements interpolate as
usual. Mixing is fine — `title` can be an `rt()` key while `body` stays a plain string.
## Reusable notification classes
For a notification that also needs other channels — mail, Slack, SMS — don't reach for a Lattice
base class. Write a plain Laravel notification and return the `Notification` builder's payload from
`toArray()`, the same shape `->send()` produces:
```php
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification as LaravelNotification;
use Lattice\Ui\Enums\Variant;
use Lattice\Notifications\Notification;
class OrderShipped extends LaravelNotification
{
public function __construct(private readonly Order $order) {}
public function via(object $notifiable): array
{
return ['mail', 'database', 'broadcast'];
}
public function toArray(object $notifiable): array
{
return Notification::make()
->title("Order #{$this->order->number} shipped")
->body('Tracking is now available.')
->variant(Variant::Success)
->href("/orders/{$this->order->id}")
->toArray();
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)->line("Order #{$this->order->number} has shipped.");
}
}
```
```php
$order->user->notify(new OrderShipped($order));
```
Laravel uses the array returned by `toArray()` for both the `database` and `broadcast` channels, so
the bell renders it and pushes it live exactly like `->send()` does — no Lattice base class involved,
just the builder's own `toArray()` as the reusable payload getter.
The bell renders **any** row in the `notifications` table, not only Lattice ones. A notification
sent through a different channel — plain `DatabaseNotification` data with a `title`, `message`, or
`subject` key — still shows up with a best-effort title and no actions.
## Placing the bell
Drop `Notifications::make()` into a [`Topbar`](/core/layouts/#the-header-bar) like any other
component:
```php
use Lattice\Ui\Components\Stack;
use Lattice\Ui\Enums\Side;
use Lattice\Ui\Components\Topbar;
use Lattice\Notifications\Components\Notifications;
Topbar::make('app-topbar')->sticky()->items([
Stack::make()->direction(Orientation::Horizontal)->float(Side::End)->schema([
Notifications::make(),
]),
]);
```
It defaults to a popover anchored under the bell. Call `->slideOut()` for a full-height panel that
slides in from the trailing edge instead — a better fit for a dense topbar or a mobile layout:
```php
Notifications::make()->slideOut();
```
## Configuration
```php
'notifications' => [
'endpoint' => 'lattice/notifications',
'middleware' => ['web', 'auth'],
'per_page' => 15,
'polling_interval' => null,
'prune_after_days' => 30,
],
```
- **`endpoint`** / **`middleware`** — where the list and mutation routes are served, same convention
as every other [Lattice endpoint](/introduction/configuration/#endpoints-and-middleware).
- **`per_page`** — page size for the list and its "load more" pagination.
- **`polling_interval`** — seconds between polling refetches; `null` disables polling (the default —
rely on realtime, or set an explicit interval as a fallback). Override per bell with
`->pollingInterval($seconds)`.
- **`prune_after_days`** — how long a **read** notification is kept before
[pruning](#retention) deletes it.
## Realtime and polling
Each notifiable gets a private channel — `NotificationChannel::for($notifiable)`, the same channel
Laravel's broadcast notifications use (or `receivesBroadcastNotificationsOn()` if the model defines
it). The bell subscribes to it with `useEchoNotification` from `@laravel/echo-react` and prepends
whatever arrives, no page reload needed.
If `@laravel/echo-react` isn't installed or `configureEcho()` hasn't run, the bell catches the
failure, logs a console warning, and falls back to plain polling — it never crashes the page. Set
`polling_interval` (globally or per bell) for a fallback cadence, or rely on the initial fetch
alone if neither realtime nor polling is needed.
See [Realtime](/core/realtime/) for the broadcasting setup (Echo, Reverb) this depends on — this is a
different mechanism from a page's declarative `Listen` listeners, purpose-built for one notifiable's
private notification stream.
## Retention
Read notifications older than `prune_after_days` are deleted by an Artisan command, not
automatically:
```bash
php artisan lattice:notifications:prune
```
Schedule it in your application:
```php
use Illuminate\Support\Facades\Schedule;
Schedule::command('lattice:notifications:prune')->daily();
```
Unread notifications are never pruned, regardless of age.
# Components
> The server-side visual vocabulary — layout, display, and interactive builders that serialize to React.
import ComponentExample from "@components/ComponentExample.astro";
Components are the building blocks a page renders. Each is a PHP builder that serializes to a typed
node (`type` plus `props`) the renderer maps to a React component. They compose into a tree:
container components take a `->schema([...])` of children, and children can be containers themselves.
tooltip('These settings affect everyone on the team.')
->schema([
Stack::make()->gap(Gap::Small)->schema([
Heading::make('Members', 2),
Text::make('Three people have access to this team.'),
Badge::make('3 active'),
]),
Button::make('Invite member'),
]);`}
fixture="components.card"
/>
Every example on these pages has four tabs: the **PHP** that builds it, the live **Preview**, the
serialized **Tree** the renderer receives, and the theme **Style** tokens it uses.
## Catalog
| Component | Purpose | Page |
| ---------------------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------- |
| `Stack`, `Grid`, `Card` | Arrange children — vertical/horizontal, columns, bordered panels | [Layout](/components/layout/) |
| `Section`, `Collapsible` | Titled and untitled disclosures that fold open and closed | [Section & Collapsible](/components/section-collapsible/) |
| `FloatingPanel` | Pin children to a viewport corner | [Floating panel](/components/floating-panel/) |
| `Heading`, `Text`, `Badge`, `Icon`, `RawBlock` | Display primitives — headings, prose, pills, sprite icons, raw HTML | [Text & badges](/components/text/) |
| `Button`, `Link`, `SegmentedControl` | Buttons, navigational links, and standalone segmented controls | [Buttons & links](/components/buttons/) |
| `Tabs`, `Tab` | A tab strip with URL-synced, lazily rendered panels | [Tabs](/components/tabs/) |
| `Modal` | A dialog opened and closed by action effects | [Modals](/components/modals/) |
| `Tooltip` | An ⓘ info popover you can place anywhere | [Tooltip](/components/tooltip/) |
| `Chart` | Line, bar, area, and pie charts | [Charts](/components/charts/) |
| `Notifications` | A bell listing a user's database notifications, realtime + polling | [Notifications](/components/notifications/) |
## Where components appear
Components are the same building blocks used across Lattice. You place them in a
[page](/core/pages/)'s schema, inside a [form](/forms/overview/) alongside fields, and in a
[table](/tables/overview/)'s empty and header slots. Containers nest freely, so a `Card` can hold a
`Stack` of `Text` and `Button`s, and a `Section` can hold a `Grid` of anything.
## Fragments
A `Fragment` renders a piece of UI resolved on its own, so it can be reloaded independently of the
page — see [Fragments](/core/fragments/).
## Custom components
Register your own components to extend the renderer with your own typed nodes and React views. See
[Extending → Custom fields](/extending/custom-fields/),
[Registry and types](/extending/registry-and-types/), and
[Component packages](/extending/component-packages/).
# Popover
> A click-triggered popover anchored to your own trigger, with any components as content.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
import Warning from "@components/Warning.astro";
`Popover::make()` anchors a floating panel to a trigger of your choice. Clicking the trigger opens
the panel; its content is a regular component schema.
trigger([Text::make('Pro plan')])
->schema([
Stack::make()->gap(Gap::Small)->schema([
Heading::make('Pro plan', 3),
Text::make('Unlimited seats, priority support, and SSO.'),
]),
]);`}
fixture="components.popover"
/>
Keep the trigger non-interactive: it becomes the popover's clickable button, so a button or link
inside it would nest one interactive element in another. Use plain [display](/components/text/)
components.
## Positioning
`->side()` and `->align()` place the panel relative to the trigger, taking the `Placement`
(`Top`, `Right`, `Bottom`, `Left`) and `ContentAlign` (`Start`, `Center`, `End`) enums. The default
is below the trigger, aligned to its start edge.
```php
use Lattice\Ui\Enums\ContentAlign;
use Lattice\Ui\Enums\Placement;
Popover::make('details')
->side(Placement::Right)
->align(ContentAlign::Center)
->trigger([Badge::make('3 members')])
->schema([/* … */]);
```
## Lazy content from a fragment
Popover content stays unmounted until the popover opens. Put a lazy
[fragment](/core/fragments/) inside and the server request happens on first open — the popover
shows the fragment's skeleton while it loads:
```php
Popover::make('customer-card')
->trigger([Text::make('Acme Inc.')])
->schema([
Fragment::lazy(CustomerCardFragment::class, ['customerId' => $customerId]),
]);
```
The popover's content is remounted each time it opens, so a lazy fragment re-fetches on every open
and always shows fresh data.
For the same pattern on a table cell, see the
[text column's `->popover()`](/tables/columns/text/#popover). For an ⓘ info popover with trusted
HTML content, reach for [`Tooltip`](/components/tooltip/) instead.
# Progress
> Linear bars and radial circles for determinate progress.
import ComponentExample from "@components/ComponentExample.astro";
import Info from "@components/Info.astro";
`Progress::bar($value)` renders a linear progress bar and `Progress::circle($value)` a radial
ring. Values count against `->max()` (default `100`) and are clamped into that range, so a
completed import with `Progress::bar(340)->max(340)` fills exactly once.
gap(Gap::Small)->schema([
Progress::bar(65)->showValue(),
Progress::bar(80)->color(Color::success())->size(Size::Lg),
Stack::make()->direction(Orientation::Horizontal)->gap(Gap::Medium)->schema([
Progress::circle(65)->showValue(),
Progress::circle(35)->max(50)->color(Color::warning())->size(Size::Xl)->showValue(),
]),
]);`}
fixture="components.progress"
/>
## Value and readout
- `->value($value)` and the constructor argument set the current value; `->max($max)` sets the
scale. Values clamp into `0..max`.
- `->showValue()` adds a percent readout — right of the bar, centered in the circle. The
percentage is formatted for the active locale.
- Screen readers get the same state through `role="progressbar"` with
`aria-valuenow`/`aria-valuemax` and the formatted percent as `aria-valuetext`.
## Color and size
`->color()` tints the fill — a colour name (`Color::danger()`, `'danger'`) or any CSS colour — and
defaults to the primary color; `->size(Size $size)` scales bar height or circle diameter and
defaults to `Size::Md`.
```php
Progress::circle(90)->color(Color::danger())->size(Size::Lg);
```
Progress is bindable like any component: `Progress::bar()->dataKey('value', 'completion')`
fills it per row in a [stack column](/tables/columns/stack/) or per option in a select's
[option schema](/forms/fields/select/).
# Section & Collapsible
> The disclosure containers — a titled panel that folds and carries header actions, an untitled clickable trigger, and an accordion that keeps one of them open at a time.
import ComponentExample from "@components/ComponentExample.astro";
Both of these fold a body of children open and closed. `Section` is a titled panel with a header;
`Collapsible` is an untitled row you build yourself. Reach for `Section` when the group has a heading,
`Collapsible` when the trigger _is_ the content — a settings row that reveals an inline form.
## Section
`Section::make('Title', 'Description')` groups content under a heading, like a [`Card`](/components/layout/#card)
that can also collapse and hold actions. Call `->tooltip('…')` to attach an ⓘ info popover next to the
title; the content may include links. Call `->collapsible()` to let it fold — pass `collapsed: true` to
start folded, or `rememberState: false` to not persist the state across reloads — and
`->headerActions([...])` to place buttons or actions in the header.
collapsible()
->tooltip('Only admins can change who has access.')
->headerActions([
Button::make('Invite member')->emphasis(Emphasis::Outline),
])
->schema([
Text::make('Three people have access to this team.'),
Badge::make('3 active'),
]);`}
fixture="components.section"
/>
## Collapsible
`Collapsible::make()` folds a body of children behind a clickable trigger. Pass the header content to
`->trigger([...])` (any components — a label, a value, a badge) and the body to `->content([...])`. It
starts folded; call `->collapsed(false)` to start open, and `->rememberState()` to persist the open
state across reloads. Unlike `Section`, the whole trigger row is clickable and there is no built-in
title — you supply it, which makes it a good fit for settings rows that reveal an inline edit form.
Call `->tooltip('…')` to attach an ⓘ info popover in the trigger row; the content may include links.
trigger([Text::make('Name')])
->tooltip('Shown on invoices and receipts.')
->content([Text::make('Update the name shown on your account.')]);`}
fixture="components.collapsible"
/>
## Accordion
`Accordion::make()` wraps `Collapsible` or collapsible `Section` children and keeps **at most one of
them open**: opening an item closes its open sibling, and clicking the open item closes it, so the
accordion can also sit fully folded. Coordination runs on the children's keys — give every item one —
and only reaches the accordion's direct children, so a collapsible nested inside an item's content
keeps its own local state. Pass the key of the item that should start open to `->defaultOpen('…')`;
inside an accordion it wins over a child's own `collapsed()` and `rememberState()`. `->gap()` spaces
the items like a `Stack`.
defaultOpen('shipping')
->schema([
Collapsible::make('shipping')
->trigger([Text::make('When does my order ship?')])
->content([Text::make('Orders placed before noon ship the same day.')]),
Collapsible::make('returns')
->trigger([Text::make('How do returns work?')])
->content([Text::make('Print a label from your account within 30 days.')]),
]);`}
fixture="components.accordion"
/>
# Separator
> A thin rule that divides content horizontally or vertically.
import ComponentExample from "@components/ComponentExample.astro";
`Separator::make()` renders a thin hairline rule that visually divides content. It defaults to a
full-width **horizontal** line — drop it between stacked items to group them:
gap(Gap::Small)->schema([
Text::make('Profile'),
Separator::make(),
Text::make('Billing'),
Separator::make(),
Text::make('Security'),
]);`}
fixture="components.separator"
/>
## Orientation
`->orientation(Orientation $orientation)` switches between `Horizontal` (the default) and `Vertical`.
A vertical separator fills the height of its row — use it between inline items:
direction(Orientation::Horizontal)->gap(Gap::Small)->schema([
Text::make('Draft'),
Separator::make()->orientation(Orientation::Vertical),
Text::make('Published'),
Separator::make()->orientation(Orientation::Vertical),
Text::make('Archived'),
]);`}
fixture="components.separator-vertical"
/>
## Bleed
Inside a gutter-padded panel such as a [`Card`](/components/layout/#card) or
[`Section`](/components/section-collapsible/), a separator stops at the padding and reads as a rule
floating inside the panel rather than dividing it. `->bleed()` extends it across the gutter so it
meets both edges:
```php
Card::make('Account')->schema([
$this->row('Name', $user->name),
Separator::make()->bleed(),
$this->row('Email', $user->email),
]);
```
# Tabs
> A tab strip whose active tab is kept in the URL and whose panels render lazily.
import ComponentExample from "@components/ComponentExample.astro";
import Warning from "@components/Warning.astro";
`Tabs::make()->schema([...])` renders a tab strip of `Tab::make('value', 'Label')->schema([...])`
children. The active tab is kept in the URL query string, and a tab's content renders lazily on first
open.
defaultValue('details')->schema([
Tab::make('details', 'Details')->schema([
Text::make('Team details go here.'),
]),
Tab::make('history', 'History')->schema([
Text::make('Recent activity for the team.'),
]),
]);`}
fixture="components.tabs"
/>
## Active tab and the URL
- `->defaultValue('details')` picks the tab shown when the URL names none. Otherwise the first tab
wins.
- `->queryKey('tab')` sets the query-string parameter that tracks the active tab (defaults to `tabs`),
so tabs survive reloads and are shareable by URL.
## Orientation and alignment
- `->orientation(Orientation::Vertical)` moves the strip to the side of the panels. Vertical tabs
render as a fixed-width rail on the page background — no pill panel — with the active item marked
by a primary accent bar and a subtle background.
- `->alignment(Align::…)` controls how the strip sits. Horizontal tabs default to `Stretch`
(tabs share the full width); `Start`, `Center`, and `End` shrink the strip to its content and pin it
left, center, or right. Vertical tabs only read `End` — it moves the strip to the right of the
panels; every other value keeps it on the left.
- `->sticky()` keeps a vertical rail in view while a long panel scrolls. The rail sits a gap below
whatever sticky chrome the page publishes above it — a sticky `Topbar`, a sticky `Stack` header —
so it never slides underneath. Horizontal strips ignore the option.
See the [enums reference](/advanced/enums/) for the full `Orientation` and `Align` cases.
## Responsive behavior
Below the `md` breakpoint, tabs collapse into a native select: vertical tabs always (a side rail
does not fit a phone), horizontal tabs once they have more than three entries. Selecting an option
switches panels exactly like clicking the tab, including the password-confirmation redirect for
gated tabs. A horizontal strip with three or fewer tabs is kept as-is.
## Password-confirmed tabs
`Tab::make('billing', 'Billing')->confirm()` gates a tab behind Laravel's password confirmation.
Until the user confirms — redirected to `/user/confirm-password` by default; pass your own URL and an
optional `timeout` — the tab's children are stripped from the response, so protected content never
reaches the client. Reach for it on sensitive panels.
# Text & badges
> Display primitives — headings, prose, status pills, sprite icons, and an escape hatch for raw HTML.
import ComponentExample from "@components/ComponentExample.astro";
import Warning from "@components/Warning.astro";
Display components render content and carry no interaction. Compose them inside a
[layout](/components/layout/) container.
gap(Gap::Small)->schema([
Heading::make('Billing', 2),
Text::make('Invoices are sent on the first of each month.'),
Badge::make('Trialing'),
]);`}
fixture="components.text"
/>
## Heading
`Heading::make($text, $level)` renders a heading; `$level` is `1`–`6` and defaults to `1`. Call
`->tooltip('…')` to attach an ⓘ info popover next to it, and `->copyable()` to render a
copy-to-clipboard affordance beside the text.
```php
Heading::make('Billing', 2);
Heading::make('sk-live-4242', 3)->copyable();
```
## Text
`Text::make($text)` renders a paragraph of muted prose. Tune it with enum-typed options (see the
[enums reference](/advanced/enums/)):
- `->size(Size::…)` — text size (`Md` by default).
- `->color()` — a colour name (`Color::success()`, `'success'`) or any CSS colour; text renders in
the `muted` colour when unset.
- `->align(TextAlign::Center)` — alignment.
- `->copyable()` — render a copy-to-clipboard affordance.
```php
Text::make('Invoices are sent on the first of each month.')->align(TextAlign::Center);
```
## Badge
`Badge::make($label)` renders a small status pill.
```php
Badge::make('Trialing');
```
## Icon
`Icon::make($name)` renders an icon from your app's SVG sprite by name — a string, or a backed enum
whose value is the name. Set `->size(Size::…)`, `->color()` (a colour name or any CSS colour), or
add classes with `->class('…')`.
```php
Icon::make('check-circle')->color(Color::success());
```
See [Icons](/core/icons/) for how the sprite is built and how to add or type your own icon names.
## Raw HTML
`RawBlock::make()->html($html)` renders a string of HTML verbatim — an escape hatch for markup that
has no dedicated component. `->blade($view, $data)` renders a Blade view and uses its output instead.
```php
RawBlock::make()->html('
');`}
fixture="components.raw-block"
/>
A `RawBlock` is injected into the page without escaping. Only ever pass HTML you control — never
user-supplied content — or you open the page to cross-site scripting. Prefer a real component when
one exists.
# Tooltip
> An ⓘ info popover you can place anywhere.
import ComponentExample from "@components/ComponentExample.astro";
import Warning from "@components/Warning.astro";
`Tooltip::make()->content('…')` renders an ⓘ info popover you can place anywhere in a schema. The
content is trusted HTML, so links are supported.
direction(Orientation::Horizontal)->gap(Gap::Small)->schema([
Badge::make('Plan: Pro'),
Tooltip::make()->content('Includes unlimited seats and priority support.'),
]);`}
fixture="components.tooltip"
/>
Pass `->trigger([...])` to use your own trigger — text, a badge, an icon — instead of the default ⓘ
icon. Clicking it opens the popover.
Keep the trigger non-interactive: it becomes the popover's clickable button, so a button or link
inside it would nest one interactive element in another. Use plain [display](/components/text/)
components.
Several components take a `->tooltip('…')` shorthand that attaches this same popover to their title or
header — [`Card`](/components/layout/#card), [`Section`](/components/section-collapsible/#section),
[`Collapsible`](/components/section-collapsible/#collapsible), and [`Heading`](/components/text/#heading).
# Local Development
> Consume Lattice from a local Composer checkout instead of the published packages.
There are two supported local-development loops:
- **Workbench development** — the default loop for changing Lattice itself.
- **External app source-linking** — an integration loop for testing an unreleased checkout inside a real Laravel app with Vite HMR.
Use package-linking only when you want publish-like verification. It exercises the built `dist` package and therefore requires rebuilding after JavaScript changes.
## Workbench development
The repository ships with an Orchestra Testbench workbench app. This is the default way to develop Lattice because PHP, routes, fixtures, Vite, Tailwind, icons, and the React renderer are already wired together.
Install dependencies once:
```bash
composer install
npm install
```
Serve the workbench in two terminals — `composer serve` runs the PHP server (building the workbench app first), and `npm run dev` runs Vite for hot-reloading the React and CSS:
```bash
composer serve # PHP server
npm run dev # Vite HMR (separate terminal)
```
The workbench compiles Lattice directly from `resources/js` and imports the stylesheet directly from `packages/ui/resources/css/lattice.css`, so frontend changes are picked up by Vite without running the package build.
The canonical references are:
- `workbench/resources/js/app.tsx`
- `workbench/resources/css/app.css`
- `vite.config.ts`
## External app source-linking
Use this when you need to try local Lattice changes inside an existing Laravel + Inertia React application. The PHP side is linked with Composer, and the React renderer is compiled from source by the consuming app's Vite dev server.
This mode gives you HMR for Lattice's TypeScript and CSS without running `npm run build:lib`.
### Link PHP with Composer
In the consuming app's `composer.json`, add a path repository and require the dev version:
```json
{
"repositories": [
{
"type": "path",
"url": "../lattice",
"options": {
"symlink": true
}
}
]
}
```
```bash
composer require lattice-php/lattice:"*@dev" -W
```
Composer symlinks your checkout into `vendor/lattice-php/lattice`, so PHP changes are picked up by the app immediately.
### Keep the public npm package installed
```bash
npm install @lattice-php/lattice
```
Keep application imports on the public package name:
```tsx
import LatticePage from "@lattice-php/lattice/page";
import { Provider, registry } from "@lattice-php/lattice";
```
Source-linking redirects that same public package name to your local checkout.
### Configure Vite
Add the Lattice helper to the consuming app's `vite.config.ts`:
```ts
import { lattice } from "@lattice-php/lattice/vite";
import { defineConfig } from "vite";
const useLocalLattice = process.env.LATTICE_SOURCE === "1";
export default defineConfig({
plugins: [
lattice({
source: useLocalLattice,
icons: {
dirs: ["resources/icons"],
},
}),
// your existing Laravel, Inertia, React, and Tailwind plugins
],
});
```
This keeps normal development on the installed npm package, while `LATTICE_SOURCE=1` switches Vite to the checkout under `vendor/lattice-php/lattice`. The helper configures the source aliases, Vite filesystem allow-list, React/Inertia dedupe, and the package-link Vitest dependency inline rule.
It also registers the SVG sprite plugin with Lattice's built-in icons and any app icon dirs you pass through `icons.dirs`.
If your editor or `tsc` does not follow Vite aliases, mirror the public package name in `tsconfig.json`:
```json
{
"compilerOptions": {
"paths": {
"@lattice-php/lattice": ["./vendor/lattice-php/lattice/resources/js/index.ts"],
"@lattice-php/lattice/*": ["./vendor/lattice-php/lattice/resources/js/*"]
}
}
}
```
### Scan Lattice source with Tailwind
Keep the regular stylesheet import, then add a Tailwind source path for the linked checkout:
```css
/* resources/css/app.css */
@import "tailwindcss";
@import "tw-animate-css";
@import "@lattice-php/lattice/css";
@source "../../vendor/lattice-php/lattice/resources/js";
```
### Run the app in source mode
```bash
LATTICE_SOURCE=1 npm run dev
```
Vite now compiles the linked checkout directly. TypeScript and CSS changes in Lattice update through the consuming app's dev server without a package build.
### Run tests against the built package
Source-linking is a browser-development loop; run the consuming app's Vitest suite against package-linking or the published npm package instead, so the app tests the built package surface rather than the checkout's source tree.
The reason is dependency resolution: the checkout has its own `node_modules`, so React-based dependencies imported from Lattice source can resolve their own copy of React instead of the application's copy — in tests this shows up as React's "Invalid hook call" error. Browser dev and production builds dedupe correctly; the test runner can still cross that dependency boundary.
## Package-link verification
Use package-linking when you want to test the same surface that npm publishes:
```bash
npm install @lattice-php/lattice@file:../lattice
```
This mode reads the package exports and `dist` files. It is closer to a release, but it is not a live source workflow. Run the package build after renderer changes:
```bash
cd ../lattice
npm run build:lib
```
For a continuously refreshed package-link build:
```bash
cd ../lattice
npm run build:lib:watch
```
Use source-linking for daily integration work, and package-linking for publish-like checks.
# Artisan commands
> Reference for the Lattice Artisan commands that scaffold definitions, generate types, and manage discovery.
Lattice registers its commands under the `lattice` namespace. Inspect the current list in an
application with:
```bash
php artisan list lattice
```
## Package setup and updates
After requiring `lattice-php/lattice` through Composer, synchronize its frontend packages:
```bash
php artisan lattice:install
```
The installer adds published npm counterparts for the installed Lattice Composer packages and lists any remaining Vite, CSS, or React wiring. In an application without `package.json`, it publishes the standalone no-build assets instead.
Use `lattice:update` to move every installed Lattice package to the latest stable coordinated release:
```bash
php artisan lattice:update --dry-run
php artisan lattice:update
```
The dry run shows package versions and planned package-manager commands without changing files. See [Installation](/introduction/installation/) and [No-Build Installation](/introduction/no-build/) for the complete workflows.
## Definition generators
These commands create PHP-only definition classes under `app/`. Each accepts a required `name`
argument and `--force` to overwrite an existing file.
| Command | Writes to | Base class |
| ------------------------------------------ | ------------------ | ------------------------- |
| `php artisan lattice:page Home` | `app/Ui/Pages` | `Page` |
| `php artisan lattice:form Contact` | `app/Ui/Forms` | `FormDefinition` |
| `php artisan lattice:table Users` | `app/Ui/Tables` | `EloquentTableDefinition` |
| `php artisan lattice:action Save` | `app/Ui/Actions` | `ActionDefinition` |
| `php artisan lattice:bulk-action Export` | `app/Ui/Actions` | `BulkActionDefinition` |
| `php artisan lattice:fragment Stats` | `app/Ui/Fragments` | `FragmentDefinition` |
| `php artisan lattice:layout App` | `app/Ui/Layouts` | `LayoutDefinition` |
| `php artisan lattice:remote-source Search` | `app/Ui/Remote` | `RemoteSourceDefinition` |
A bare name lands under `App\Ui\{Type}` — a single UI (adapter) layer that keeps
your generated Lattice classes separate from your domain code.
### Placing a class explicitly
Give the name a path separator and it is written verbatim under `App\` — the
type folder is _not_ inserted, so you own the whole location. This is the escape
hatch for feature-first apps that group by feature rather than by type:
```bash
php artisan lattice:form Projects/Ui/Forms/ProfileForm
```
That writes `app/Projects/Ui/Forms/ProfileForm.php` with the namespace
`App\Projects\Ui\Forms`. `/` and `\` are interchangeable, so you never need to
escape backslashes in your shell. Discovery scans `app/` recursively, so classes
found under either layout are registered the same way — no config change needed.
## Component-pair generators
These commands create both the PHP class and the React renderer, then append the registration to
`resources/js/registry.ts`.
| Command | PHP file | React file | Registry |
| ---------------------------------------- | ----------------------- | --------------------------------------- | ----------------------------- |
| `php artisan lattice:field ColorPicker` | `app/Ui/Forms/Fields` | `resources/js/fields/color-picker.tsx` | `components` |
| `php artisan lattice:component Rating` | `app/Ui/Components` | `resources/js/components/rating.tsx` | `components` |
| `php artisan lattice:column StatusBadge` | `app/Ui/Tables/Columns` | `resources/js/columns/status-badge.tsx` | `extensions["table.columns"]` |
The PHP class follows the same `App\Ui` default and separator escape hatch as
the definition generators; the React file and registry entry are keyed by the
resolved class name. Package mode (`--package`) is unaffected — it uses the
package's own PSR-4 namespace.
Publish the registry scaffold before running them:
```bash
php artisan vendor:publish --tag=lattice-js
```
All three accept:
| Option | Purpose |
| ------------ | ------------------------------------------------------------------------------------------------ |
| `--type=` | Override the derived type string. |
| `--package=` | Scaffold into a Composer [component package](/extending/component-packages/) instead of the app. |
| `--force` | Overwrite generated files that already exist. Existing registry entries are not duplicated. |
By default, `ColorPicker` becomes `field.color-picker`, `Rating` becomes `rating`, and `StatusBadge`
becomes `column.status-badge`. The commands run `lattice:typescript` after updating the registry so
the renderer props are narrowed immediately.
## Type generation
Run the TypeScript generator when custom Lattice classes gain or lose public props:
```bash
php artisan lattice:typescript
```
It scans `config('lattice.discover')`, reads the discovered component, field, and column classes, and
writes the declaration file configured at `config('lattice.typescript.output')`
(`resources/js/lattice/generated.d.ts` by default). The generated file augments the configured module
(`@lattice-php/core` by default), so `node.props` and `column.props` stay typed on the client.
`lattice:typescript` only needs `spatie/laravel-typescript-transformer` when your app defines custom
PHP wire types; it no-ops otherwise. Install it as a dev dependency in applications that generate
types for custom components.
## Discovery cache
Discovery scans the configured paths for Lattice attributes. Cache that manifest for production:
```bash
php artisan lattice:discover-cache
```
Clear it when the discovered classes or paths change:
```bash
php artisan lattice:discover-clear
```
Lattice also registers these with Laravel's optimization flow, so the cache command runs with
`php artisan optimize` and the clear command runs with `php artisan optimize:clear`.
## Assets & maintenance
`php artisan lattice:assets` publishes the prebuilt standalone assets into your public directory —
the [no-build installation](/introduction/no-build/) covers when and why.
`php artisan lattice:notifications:prune` deletes read [notifications](/components/notifications/)
older than the configured `lattice.notifications.prune_after_days` (unread ones are never pruned) —
schedule it daily.
## Common workflow
```bash
php artisan vendor:publish --tag=lattice-config
php artisan vendor:publish --tag=lattice-js
php artisan lattice:page Dashboard
php artisan lattice:form ProfileForm
php artisan lattice:field ColorPicker --type=color-picker
php artisan lattice:typescript
```
Use the [registry and types](/extending/registry-and-types/) page for the generated React registry,
and [configuration](/introduction/configuration/) for the `discover` and `typescript` settings these
commands read.
# Authorization
> Gate a definition, page, or component with can and authorize().
Lattice gates everything with the same two tools. **`can`** declares an ability — the same word as
Laravel's `can:` middleware and `$user->can()` — either subject-less or, with `on`, checked against a
specific record. **`authorize()`** holds the logic that needs the request or something `can`/`on` can't
express.
| | declare an ability | custom logic |
| --------------------------------------------------------------- | ------------------------------- | ---------------------------- |
| Definition — form, table, action, bulk action, fragment, layout | `#[AsTable(can: 'x', on: 'y')]` | `authorize()` |
| Page | `#[AsPage(can: 'x', on: 'y')]` | `authorize()` |
| Component, column, filter, row action | `->can('x', on: 'y')` | `->visible()` / `->hidden()` |
## Declaring `can`
Put the ability on the attribute — no method needed:
```php
#[AsTable('admin.users', can: 'admin.users.manage')]
class AdminUsersTable extends EloquentTableDefinition { /* … */ }
```
Pass an array when several must hold — every one has to pass:
```php
#[AsTable('admin.users', can: ['admin.access', 'admin.users.manage'])]
```
Pages take the same argument:
```php
#[AsPage(route: '/admin/users', can: 'admin.access')]
class AdminUsersPage extends Page { /* … */ }
```
And any component, column, filter, or row action takes it as a method:
```php
TextColumn::make('cost')->can('finance.costs');
Heading::make('Internal notes')->can(['support.access', 'support.notes']);
```
A `can` declaration is checked against `Gate::forUser($request->user())` and is **never widened** by
the custom logic beside it — an `authorize()` override can only narrow it further, and `->visible(true)`
cannot bring back a component whose `can` failed. That holds wherever the thing is reached from, which
includes a bulk action, gated by its own declaration _and_ its table's.
:::caution
A page's `can` (or `middleware`) does **not** protect the definitions rendered on it. Every definition
is reached through its own endpoint (`lattice/tables/{table}`, `lattice/actions/{action}`, …), which
runs the middleware in `config('lattice..middleware')` — `['web', 'auth']` by default — and
then the definition's own gate. Putting `can: 'admin.users.manage'` on a page gates who can _load_ the
page; it does not gate the table on it. Declare the ability on the definition too.
:::
## Declaring a gate subject with `on`
Add `on` when the ability needs a specific record rather than a subject-less check —
`Gate::check('update', $product)` rather than `Gate::check('manage-widgets')`. `on` names a
[context](/core/context/) key; its resolved value becomes the `Gate::check()` subject, so a gate closure
written for `can('update', $product)` works unchanged.
On a definition or a plain component, `on` names a context key exactly as `contextModel()` would read
it:
```php
#[AsAction('app.products.archive', can: 'update', on: 'product')]
class ArchiveProductAction extends ActionDefinition { /* … */ }
```
```php
Heading::make('Internal notes')->can('update', on: 'product');
```
On a page, `on` names a route parameter instead — a bound model as-is, or an unbound scalar resolved
through a [registered](/core/context/#registering-a-resolver) `Lattice::context()` resolver of the same
name:
```php
#[AsPage(route: '/products/{product}/edit', can: 'update', on: 'product')]
class ProductEditPage extends Page { /* … */ }
```
A missing subject — the key absent, or its resolver finding nothing — **denies outright**. It never
falls back to a subject-less check.
:::caution
A page's `on` middleware differs from a subject-less `can`. Laravel's own `can:{ability}` middleware
would hand an unbound route parameter to the gate as a raw scalar, blind to a registered resolver — so
when `on` is set, Lattice registers its own `AuthorizeGateSubject` middleware in its place. It resolves
the subject exactly as `toResponse()` and `callAction()` do — through the same
`GateSubjects::fromRoute()` — so the middleware and the page body can never disagree about who they
checked.
:::
`can` and `on` are inherited from a [base page](/core/pages/#shared-base-pages) the same way layout,
width, and middleware are: a concrete page declaring its own `can` replaces — rather than merges with —
an inherited ability, and the same holds for `on`.
## Writing `authorize()`
Abilities that need a subject — `can('view', $project)` — go in `authorize()`, where the sealed
context is available to resolve the record. It returns `true` by default, so a definition or page is
open until you say otherwise.
```php
use Illuminate\Http\Request;
public function authorize(Request $request): bool
{
return $request->user()?->can('update', $this->product()) ?? false;
}
```
`authorize()` is the only method you override. The framework never calls it directly — it composes
it with whatever `can` declared, so the two can't drift apart.
The gate runs on the definition's own endpoint before any work happens:
- An **action** or **bulk action** that fails never reaches `handle()`.
- A **form** is validated and handled only when authorized.
- A **table** or **fragment** that fails resolves to nothing rather than leaking data.
Because the same definition class owns both the rendered component and the endpoint that backs it,
the authorization lives in one place and can't be bypassed by calling the endpoint directly.
## Hidden at render time, not just at the endpoint
A component that fails its gate doesn't just 403 if you call its endpoint — it's hidden from the page
in the first place. Registries resolve a failed check to an unsealed, hidden component, and every
place that embeds definition-backed components (page schemas, table row actions, notification actions,
a form nested under an action) filters them out before serializing. The client never sees a trace of
it: no id, no endpoint, no signed reference. A plain component's `->can()` drops it the same way.
:::note
The endpoint's own gate still runs on every request — hiding at render time is defense in depth, not
a replacement for it. A forged or stale reference is still rejected. A plain component has no endpoint
of its own, so `->can()` is a render gate only; anything that loads data must be a definition.
:::
## Reading trusted context
A definition often needs the record it acts on. Pass it as [context](/actions/overview/#placing-an-action)
when placing the component, and read it back with a typed accessor — `contextModel()`, backed by a
resolver [registered](/core/context/#registering-a-resolver) once via `Lattice::context()`, or the
explicit form on the opt-in `Lattice\Core\Concerns\ResolvesContextModels` trait. `Definition::context()`
and its typed scalar siblings — `contextString()`/`contextStringOrNull()`,
`contextInt()`/`contextIntOrNull()` — are available on every definition without the trait. See
[Context](/core/context/) for registering resolvers, memoization, and how context inherits into a
definition's children, a page's frame, slots, and closure-built modals.
The context is sealed into the component's signed reference, so the value `authorize()` and `handle()`
read is the value the server issued — not something a client can change. See
[Security](/advanced/security/) for how that sealing works.
:::caution
Inside `authorize()` on a component that renders as part of a page, use the `OrNull` accessors
(`contextModelOrNull()`, `contextStringOrNull()`, `contextIntOrNull()`) or `hasContext()` instead of
their strict counterparts. At the endpoint, a `false` from `authorize()` is a 403 — but at render time an
unauthorized component is simply hidden, and a strict accessor's `abort(404)` would take the whole
page down with it instead.
:::
# Closure evaluation
> How Lattice resolves closure parameters by name, type, and the Laravel container.
Many Lattice APIs accept a closure where a static value would be too limiting: dynamic validation
rules, computed field values, dependent fields, searchable selects, row labels, and table filters all
use the same evaluator.
Closures are resolved through the `Lattice\Core\Facades\Evaluate` facade. Non-closure values pass
through unchanged, so an API can accept `Closure|T` and resolve both forms consistently.
## Resolution order
Each closure parameter is resolved in this order:
1. A named utility with the same parameter name.
2. A typed utility registered in the current `EvaluationContext`.
3. A typed utility whose object is an instance of the requested parent class or interface.
4. The Laravel container, for any resolvable class or interface.
5. The parameter default value, or `null` when the parameter allows it.
If none of those match, Lattice throws an exception that lists the named utilities available to that
closure.
Named utilities win before type resolution. This is why `fn ($state)` receives the named form state
even without a type — and `fn (FormData $state)` receives that same object because the name matches
first; the type annotation documents it rather than driving the resolution.
```php
TextInput::make('slug', 'Slug')
->value(fn (FormData $state) => $state->string('name')->slug());
```
:::note
`FormData::string()` returns a `Stringable`, not a plain `string`. Field values and prefills
normalize `Stringable` and enum instances to their scalar automatically (a backed enum becomes its
value, a pure enum its name), so returning one from a `value()` closure just works — call
`->toString()` only when a helper expects a real `string`. Because `Stringable` implements
`__toString()`, it always passes truthy checks like `?:`; compare `->toString() !== ''` or use
`->isNotEmpty()` instead when the value might be empty.
:::
## Field utilities
Field callbacks share a base context. It is used by dynamic rules, computed values, `dependsOn()`
callbacks, and searchable select resolvers.
| Utility | Resolves to |
| ----------------------- | ------------------------------------------------------------------------------------------------ |
| `$state` | The current `FormData` scope. For normal fields this is the form; for row hooks this is the row. |
| `$get($key, $default)` | A helper that reads from the current `FormData` scope. |
| `$value` | The current field's own value from the current scope. |
| `$component` | The live field instance. |
| `FormData $data` | The current `FormData` scope. |
| `Request $request` | The current request. |
| Any container type | A service resolved from Laravel's container. |
| The field class by type | The live field instance, when the type matches the concrete field or one of its parent classes. |
```php
TextInput::make('total', 'Total')
->dependsOn(
['qty', 'price'],
fn (TextInput $component, FormData $state) => $component
->value($state->float('qty') * $state->float('price')),
);
```
`$component` and a typed field parameter point at the same live object. Lattice never autowires
component classes from the container, so a mismatched component type is treated as unresolved instead
of constructing a fresh component.
## Hook-specific utilities
Some callbacks add more named utilities:
| Hook | Extra utilities |
| ---------------------------------- | -------------------------------------------------------------------------------------------------- |
| `Select::searchable()` | `$search`, the query string. |
| `Select::resolveSelectedUsing()` | `$values`, the selected value list, plus `$component`. |
| Repeater and builder row callbacks | `$row` for the current row and `$form` for the whole form. A typed `FormData` parameter is `$row`. |
| `ToggleFilter::query()` | A typed Eloquent `Builder` and `$value`, the submitted toggle state. |
| `TernaryFilter::queries()` | A typed Eloquent `Builder`. |
| `Lattice::extend()` | Named slot context, typed context objects, `$user`, `$slot`/typed `Slot`, and typed `Request`. |
```php
Select::make('author_id', 'Author')
->searchable(fn (string $search, Request $request) => User::query()
->where('team_id', $request->user()->current_team_id)
->where('name', 'like', "%{$search}%")
->limit(10)
->get()
->map(fn (User $user) => Select::option($user->name, (string) $user->id))
->all());
```
Inside row hooks, use the named `$form` utility when you need values outside the row:
```php
Repeater::make('lines')
->itemLabel(fn (FormData $row, FormData $form) => $row->string('name')->isNotEmpty()
? $row->string('name')
: $form->string('currency'));
```
`Stringable` always evaluates truthy — even when it wraps an empty string — so `?:` never falls
through to the second operand. Check `->isNotEmpty()` (or `->toString() !== ''`) explicitly instead.
Named slot factories also resolve any remaining class or interface from Laravel's container. A
context object is registered under its concrete type and can satisfy a parameter typed as that class,
a parent class, or an implemented interface.
## `handle()` parameter resolution
Form, action, and bulk-action endpoints validate the submission once, then invoke `handle()` through
the same evaluator, with a small, purpose-built context:
| Utility | Resolves to |
| ------------------ | -------------------------------------------------------- |
| `$data` | The validated, cast `FormData` for this submission. |
| `$request` | The current `Request`. |
| `$records` | The selected `Collection` of models — bulk actions only. |
| `FormData $…` | Same as `$data`, by type. |
| `Request $…` | Same as `$request`, by type. |
| `Collection $…` | Same as `$records`, by type — bulk actions only. |
| Any container type | A service resolved from Laravel's container. |
```php
public function handle(FormData $data): ActionResult { /* … */ }
public function handle(FormData $data, Request $request): ActionResult { /* … */ }
public function handle(Collection $records, FormData $data): ActionResult { /* … */ } // bulk only
```
`$data`, `$request`, and `$records` are reserved names — declare only the ones you need, in any
order. Outside a bulk action there is no selection to resolve, so a `Collection`-typed parameter
falls through to the container instead, which constructs an **empty** `Collection` rather than
raising an error — a `Collection $records` parameter on a non-bulk action silently receives nothing
useful.
## Server-side timing
Closure evaluation is server-side. A closure runs when Lattice renders, validates, submits, resolves a
dependent field, or handles a select/table round-trip.
For live client-side cross-field state, use the declarative condition API:
`visibleWhen()`, `requiredWhen()`, `disabledWhen()`, and `readOnlyWhen()`. Those conditions serialize
to the client and are re-checked on the server.
## Custom closure hooks
When adding a new Lattice extension point, accept `Closure|T` and resolve it at the moment the value is
needed:
```php
use Lattice\Core\Facades\Evaluate;
$resolved = Evaluate::resolve(
$value,
Evaluate::context()
->named('value', $currentValue)
->typed(Request::class, $request),
);
```
For form fields, start from the field's evaluation context so `$state`, `$get`, `$value`,
`$component`, typed `FormData`, typed `Request`, and typed component injection all stay consistent.
# Context
> Typed, memoized data threaded from where a component is placed to where it runs — registered once, inherited everywhere.
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.
## Registering a resolver
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:
```php
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](/core/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.
```php
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](#passing-models-as-context-values).
It resolves the model as `$value`, or by its own type:
```php
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).
## Reading it
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`.
```php
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();
}
}
```
:::caution
Inside a render-time `authorize()`, reach for `contextModelOrNull()` or `hasContext()` instead of
`contextModel()`. At the endpoint, a missing subject is a 404 — but at render time an unauthorized or
incomplete component is simply hidden, and a strict accessor's `abort(404)` would take the whole page
down with it. See [Authorization](/core/authorization/) for the same rule applied to `can`.
:::
`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:
```php
$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.
## Memoization
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()`](#preparing-the-request) instead.
## Preparing the request
`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.
```php
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.
## Inheritance
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.
## Passing models as context values
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:
```php
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.
## Frames
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:
```php
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.
:::note
Read context, not route parameters. `definition()` (and `authorize()`, `handle()`, …) runs on a page's
own render — inside its frame — and again on the definition's own signed endpoint, which carries no
route parameters of its own. `contextModel()` and `context()` work identically on both paths;
`$request->route()` only works on the first.
:::
# Fragments
> A self-contained piece of UI resolved on its own endpoint, so it can load and reload independently of the page.
import Mermaid from "@components/Mermaid.astro";
A fragment is a slice of UI that resolves on its own — separate from the page that hosts it. Because
it has its own endpoint, it can load lazily and be reloaded on its own, which makes it a good fit for
expensive panels, deferred content, or UI an [action](/actions/overview/) wants to refresh.
## How a fragment loads
The page ships only a placeholder. The fragment fetches its own schema from its endpoint when it
mounts, and the same endpoint lets an [action](/actions/overview/) reload it later without touching the
rest of the page:
>B: Open the page
B->>L: GET page
L-->>B: Page payload — fragment placeholder (id + ref)
Note over B: on mount
B->>L: GET fragment endpoint with signed ref
L->>L: authorize, run schema()
L-->>B: fragment nodes
B->>U: Swap placeholder for the rendered fragment
U->>B: Run an action returning reloadComponent(id)
B->>L: GET fragment endpoint again
L-->>B: fresh fragment nodes
B->>U: Re-render just the fragment`}
/>
## Defining a fragment
Extend `FragmentDefinition` and build its `schema()`, the same way a page does. The `#[AsFragment]`
attribute registers it.
```php
use Lattice\Core\Attributes\AsFragment;
use Lattice\Ui\Components\Text;
use Lattice\Core\PageSchema;
use Lattice\Fragments\FragmentDefinition;
#[AsFragment('app.two-factor-setup')]
class TwoFactorSetupFragment extends FragmentDefinition
{
public function schema(PageSchema $schema): PageSchema
{
return $schema->component(Text::make('Scan the QR code to finish setup.'));
}
}
```
## Rendering a fragment
Render it with `Fragment::lazy()`, passing the definition class. The page ships a placeholder and the
fragment fetches its own schema from its endpoint when it mounts.
```php
use Lattice\Fragments\Components\Fragment;
Fragment::lazy(TwoFactorSetupFragment::class);
```
## Reloading a fragment
A fragment is addressed by its id, so an action can refresh it with the
[`reload-component`](/actions/effects/#refreshing-what-changed) effect — re-running its `schema()`
without touching the rest of the page:
```php
return ActionResult::success()->reloadComponent('app.two-factor-setup');
```
Fragments honor [authorization](/core/authorization/) like any definition: an unauthorized fragment
resolves to nothing.
# Internationalization
> Translate everything with normal Laravel translations, and deliver the same strings to the client through an i18next backend.
Lattice apps are translated with **Laravel's normal translation system**. Because you describe your UI
in PHP, almost everything a user reads — page titles and headings, field labels and helper text, table
headers, action labels, validation messages — is produced on the server, so you translate it exactly
like any Laravel app: `lang/` files and `__()` / `trans()`. There is no Lattice-specific layer to learn
for it.
A smaller set of strings renders in the **browser** — Lattice's built-in chrome (the rich-editor
toolbar, table pagination, filter controls, the bulk bar, and the accessibility labels on menus,
toasts, tabs, and selects) and any custom React components you write. Lattice delivers the _same_
translations to the client through an [i18next](https://www.i18next.com/) backend, so you keep one set
of files in `lang/` and never duplicate strings in JavaScript.
Start in the backend; reach for the frontend bridge only for what actually renders in the browser.
## Backend: translate in PHP
Your definitions are PHP, so wrap the text you author in Laravel's translation helpers, exactly as you
would anywhere else:
```php
use Lattice\Ui\Components\Heading;
use Lattice\Form\Components\TextInput;
Heading::make(__('products.title'));
TextInput::make('name', __('products.fields.name'))
->helperText(__('products.fields.name_hint'))
->rules(['required']); // validation messages come from Laravel's validation translations
```
These render on the server and ship already-translated in the Inertia payload — nothing here touches
i18next. The active language is the app locale (`App::setLocale()`), the same as any Laravel request.
## Frontend: the same translations, over i18next
The strings that live in the browser read through i18next, with the English baked into each call site
as a fallback — so the UI is fully readable with **zero configuration**. To replace that English with
your translations, you point Lattice's i18next instance at a backend that serves your `lang/` files
(below). The instance is isolated from your app's own i18next setup (if any), so wiring it up never
touches your application's translations.
## The `lattice` namespace
Every built-in string lives in one i18next namespace, `lattice`, scoped into a few domain groups —
`form.*` (fields, the rich editor, file uploads), `table.*` (columns, pagination, filters, bulk
selection, row actions), `notifications.*` (the notification inbox), and `common.*` (shared chrome:
tabs, toasts, tooltips, menus). Keys read
like `form.editor.bold` or `table.pagination.next`. One namespace keeps Lattice's keys from
colliding with your app's, and means a single route translates everything Lattice renders.
## Zero config
There is nothing to set up to render in English. Each call site supplies its own default
(`t("form.editor.bold", "Bold")`), and the instance initializes itself the first time a component
reads a string. Apps that never opt into a backend never bundle one.
## Translating the built-in strings
To serve translations, install [`bambamboole/laravel-i18next`](https://github.com/bambamboole/laravel-i18next),
which exposes the routes the frontend reads from:
```bash
composer require bambamboole/laravel-i18next
```
Its routes register automatically. Enable them, and serve namespaces as nested i18next JSON:
```php
// config/i18next.php
return [
'routes' => ['enabled' => true],
'namespaces' => true,
'output' => 'nested',
// …see the laravel-i18next docs for the full config.
];
```
Lattice registers its own `lang/` directory under the `lattice` namespace, so its bundled English
and German translations are served at `/locales/{lng}/lattice.json` automatically — there is nothing
to copy or publish to get them.
Lattice reads whether the routes are serving (`i18next.routes.enabled`), whether missing keys are
reported back (`i18next.save_missing.enabled`), the supported locales from
`config('lattice.i18n.locales')`, and which locales to eagerly preload from
`config('lattice.i18n.preload_locales')`. It shares those values to the frontend as an Inertia once
prop.
Locales listed in `preload_locales` have their translations fetched once at startup (in the
background, so the first paint isn't blocked). Switching to a preloaded locale then resolves from the
store instead of an HTTP round-trip, which avoids a flash of the fallback language. Leave it empty to
load each locale lazily on first switch.
### Wire the frontend
Lattice shares an `i18n` block under `lattice.i18n`, and `createLatticeApp` wires the whole
frontend from it by default — pass your extra namespaces and you're done:
```tsx
// resources/js/app.tsx
import { createLatticeApp } from "@lattice-php/lattice";
createLatticeApp({
// …registry, sprite, pages…
i18n: { namespaces: ["lattice", "app"] },
});
```
When the backend reports i18n as enabled, the first render waits for the translation setup (no
flash of untranslated fallbacks), i18next's HTTP backend loads translations from laravel-i18next's
routes, `LocaleReload` re-fetches the page after `setLocale()` dispatches `lattice:locale-change`,
and every Inertia visit carries the `Accept-Language` header (Lattice's own form, action, table,
and fragment requests send it internally). When i18n is disabled — or the shared prop is absent —
the inline English stands and the i18next chunk is never loaded. Pass `i18n: false` to opt out
entirely.
Wiring `createInertiaApp` by hand instead? The building blocks are exported: run
`configureI18nFromPageProps(props.initialPage.props, { namespaces })` (from
`@lattice-php/ui/i18n`) before the first render, mount `` next to ``,
and set `defaults: { visitOptions: withVisitHeaders }`.
### Customize or add a locale
Lattice ships English and German and registers them automatically. To override a string or add a
new locale, publish the bundled files into your app and edit them:
```bash
php artisan vendor:publish --tag=lattice-translations
```
This copies Lattice's groups to `lang/vendor/lattice/{locale}/`, where Laravel resolves them ahead
of the package's own. Each domain is its own file — `form.php`, `table.php`, `notifications.php`, `common.php` — with the
nested keys Lattice uses:
```php
// lang/vendor/lattice/de/form.php
return [
'editor' => [
'bold' => 'Fett',
'heading-1' => 'Überschrift 1',
],
// …
];
// lang/vendor/lattice/de/table.php
return [
'pagination' => [
'next' => 'Weiter',
'showing' => 'Zeige :from-:to von :total',
],
// …
];
```
Interpolated values use Laravel's `:placeholder` syntax; laravel-i18next serves them as i18next
`{{placeholder}}` tokens.
## Using translations in your own components
The same instance is available to your [custom fields and columns](/extending/overview/). Inside a
component, use the `useT` hook with your own namespace:
```tsx
import { useT } from "@lattice-php/ui/i18n";
function SaveButton() {
const { t, locale, locales, setLocale } = useT("app"); // served from lang/{locale}/app.php
return ;
}
```
Passing `"lattice"` reads Lattice's namespace instead — the built-in chrome does exactly that.
Outside of a component — building a label in a helper, say — use `translate`:
```ts
import { translate } from "@lattice-php/ui/i18n";
translate("app", "save", "Save");
```
Both take the English fallback inline, so they are safe to call before any translations have loaded.
## `Translatable` and `rt()`
Some strings can't be translated at the point they're produced — a queued listener flashing a
callout, a [realtime toast](/core/realtime/) broadcast to many subscribers, or a notification
stored today and read tomorrow by someone in a different locale. `rt()` builds a `Translatable`: an
i18next key plus replacements, resolved through `t()` on the **client**, in the reader's locale, at
render time — instead of a finished string baked into the sender's locale:
```php
rt('billing:subscription-ends')->with(['plan' => 'Pro']);
```
`->with()` accepts scalars and `DateTimeInterface` instances. A date is serialized to an ISO 8601
string on the wire — the wire stays flat scalars, never a typed date marker — and formatted
client-side, in the reader's locale, by Lattice's `datetime` formatter (a superset of i18next's
built-in one), using its normal format syntax in the translation string:
```php
use Carbon\CarbonImmutable;
rt('billing:subscription-ends')->with([
'plan' => 'Pro',
'date' => CarbonImmutable::parse($subscription->ends_at),
]);
```
```php
// lang/en/billing.php
return ['subscription-ends' => 'Your {{plan}} plan ends on {{date, datetime(dateStyle: long)}}'];
// lang/de/billing.php
return ['subscription-ends' => 'Ihr {{plan}}-Plan endet am {{date, datetime(dateStyle: long)}}'];
```
Same stored payload, each reader gets their own date: an English reader sees "Your Pro plan ends on
March 6, 2026", a German reader sees "Ihr Pro-Plan endet am 6. März 2026". The full
`Intl.DateTimeFormat` option surface is available through the `datetime(...)` format — see
[i18next's formatting docs](https://www.i18next.com/translation-function/formatting) for the option
syntax. A replacement that isn't a valid date (or isn't a date at all) renders as its raw value
instead of throwing.
`rt()` is used by [callout and toast effects](/actions/effects/#deferred-translation-with-rt) and
[notifications](/components/notifications/#localized-notifications).
## Language detection
The frontend picks its initial language from `localStorage.locale`, the `locale` cookie, the
`` attribute, then `en`. To switch at runtime, call `setLocale()`:
```ts
import { setLocale } from "@lattice-php/ui/i18n";
setLocale("de");
```
This writes `localStorage.locale`, writes the `locale` cookie, updates ``, changes the
i18next language, and dispatches `lattice:locale-change`. The backend locale middleware reads a
`HasLocalePreference` user's preferred locale first, then the cookie, then the session locale, then
the request's `Accept-Language` header.
## Language switchers
For server-driven screens, make locale choices normal actions that return a `locale-change` effect:
```php
use Lattice\Actions\ActionDefinition;
use Lattice\Actions\ActionResult;
use Lattice\Actions\Components\Action;
use Lattice\Core\Attributes\AsAction;
#[AsAction('app.locale.set')]
class SetLocaleAction extends ActionDefinition
{
public function definition(Action $action): Action
{
return $action->label(__('language.switch'));
}
public function handle(): ActionResult
{
$locale = $this->context('locale');
$locales = config('lattice.i18n.locales', []);
return is_string($locale) && is_array($locales) && in_array($locale, $locales, true)
? ActionResult::success()->localeChange($locale)
: ActionResult::success();
}
}
```
Then compose the choices wherever your layout accepts components:
```php
use Lattice\Actions\Components\Action;
use Lattice\Actions\Components\ActionGroup;
use Lattice\Ui\Components\FloatingPanel;
use Lattice\Ui\Enums\Emphasis;
use Lattice\Ui\Enums\FloatingPlacement;
use Lattice\Ui\Enums\Orientation;
$locale = app()->getLocale();
FloatingPanel::make('locale-switcher-panel')
->label(__('language.label'))
->placement(FloatingPlacement::TopEnd)
->schema([
ActionGroup::make('locale-switcher')
->label(__('language.label'))
->inline(Orientation::Horizontal)
->actions([
Action::use(SetLocaleAction::class)
->key('locale-en')
->label(__('language.en'))
->emphasis($locale === 'en' ? Emphasis::Solid : Emphasis::Ghost)
->context(['locale' => 'en']),
Action::use(SetLocaleAction::class)
->key('locale-de')
->label(__('language.de'))
->emphasis($locale === 'de' ? Emphasis::Solid : Emphasis::Ghost)
->context(['locale' => 'de']),
]),
]);
```
For custom React surfaces, Lattice provides headless switcher helpers so your app controls the markup
and styling:
```tsx
import { LocaleSwitcher } from "@lattice-php/ui/i18n";
function LanguageSwitcher() {
return (
{({ options, setLocale }) => (
{options.map((option) => (
))}
)}
);
}
```
The labels resolve from `language.{locale}` in the given namespace, falling back to the locale code.
Use `useLocaleOptions()` directly when a hook fits your component better than a render prop.
## Reference
### `configureI18nFromPageProps(props, options?)`
Reads the shared `lattice.i18n` once prop from Inertia page props and calls `configureI18n`. Pass the
namespaces your React components need:
```ts
void configureI18nFromPageProps(props.initialPage.props, {
namespaces: ["lattice", "app"],
});
```
### `withVisitHeaders(href, options)`
Use as `createInertiaApp({ defaults: { visitOptions: withVisitHeaders } })`. It preserves the visit
options and adds the active `Accept-Language` header.
### `LocaleReload`
Listens for `lattice:locale-change` and visits the current URL with `{ preserveScroll: true,
preserveState: true }`. Preserving state means the visit only swaps in the re-localized props without
remounting the page, so table sort/filter, form input, scroll, and focus survive the switch. Override
either option if your app needs a different reload behavior.
### `useLocaleOptions(options?)`
Returns `{ locale, locales, options, setLocale }`, where `options` is a list of `{ value, label,
active }` objects built from the backend-supported locales.
### `enableBackend(options?)`
`configureI18n` calls this for you, but you can call it directly for full control over the backend.
It registers i18next's HTTP backend and is the opt-in import.
| Option | Default | Purpose |
| --------------- | ------------------------------ | ---------------------------------------------------------------------------- |
| `loadPath` | `/locales/{{lng}}/{{ns}}.json` | Route translations are fetched from. |
| `addPath` | `/locales/add/{{lng}}/{{ns}}` | Route missing keys are reported to. |
| `saveMissing` | `false` | Report keys with no translation back to the backend, which persists them. |
| `customHeaders` | — | Returns extra request headers, e.g. a CSRF token for the `saveMissing` POST. |
The defaults match laravel-i18next's namespaced routes; override them only behind a custom route
prefix.
### The shared `i18n` prop
The block Lattice shares as an Inertia once prop:
```ts
type I18nConfig = {
enabled: boolean; // are the translation routes serving?
saveMissing: boolean; // should missing keys be reported back?
locales: string[]; // supported locale codes from config('lattice.i18n.locales')
preloadLocales: string[]; // locales eagerly loaded at startup, from config('lattice.i18n.preload_locales')
timezone: string | null; // the user's preferredTimezone(), when the user model implements HasTimezonePreference
};
```
`configureI18n(config)` enables the backend only when `enabled` is true, forwards `saveMissing`,
stores `locales` for `useT`, `useLocaleOptions`, and `LocaleSwitcher`, and preloads `preloadLocales`
in the background. The load and add paths are fixed on the frontend, so they never travel in this
prop.
# Icons
> How Lattice renders icons from an SVG sprite, how to add your own, and how to keep icon names type-safe.
Lattice renders icons from a single **SVG sprite** built at your app's Vite step. Components reference
an icon by **name** (a string that comes from the server), and the sprite resolves it at render time —
so adding an icon is just dropping an SVG in a folder, with no per-icon imports.
The sprite is produced by Lattice's Vite helper, which wraps
[`@lattice-php/vite-svg-sprite`](https://github.com/lattice-php/vite-svg-sprite) and merges Lattice's
built-in icons with your own into one cached file.
## Setup
Add Lattice's helper to `vite.config.ts`. The helper includes Lattice's icons automatically. Later
directories win on name collisions, so `resources/icons` can override a built-in icon.
```ts
import { lattice } from "@lattice-php/lattice/vite";
export default defineConfig({
plugins: [
lattice({
icons: {
dirs: ["resources/icons"],
},
}),
laravel({/* ... */}),
// ...
],
});
```
Then pass the sprite into Lattice's `Provider`:
```tsx
///
import sprite from "virtual:svg-sprite";
import { Provider, registry } from "@lattice-php/lattice";
createInertiaApp({
setup({ el, App, props }) {
createRoot(el).render(
,
);
},
});
```
In production the sprite is emitted as a hashed asset and referenced with `