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 `; } ``` 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 ``; in dev it's inlined into the page, so it works regardless of where the page is served from. ## Adding icons Drop any SVG into a folder the plugin scans (e.g. `resources/icons/spark.svg`) and reference it by its filename: ```php MenuItem::make('Spark')->icon('spark'); ``` Icons inherit their colour via `currentColor` and their size via the `size-*` utility on the element, so a single SVG adapts to wherever it's used. To pull icons from a package instead of downloading them by hand, [vendor them](#vendoring-icons-from-a-package). ## Vendoring icons from a package Rather than hand-download SVGs, you can **vendor** a named set from an icon package: the plugin copies just the icons you list into your project and commits them. You ship only the icons you use — not a whole library — and the set is reproducible from the config. This is how Lattice sources its own icons from [`lucide-static`](https://www.npmjs.com/package/lucide-static). Install the source package as a dev dependency: ```bash npm install -D lucide-static ``` Then list the icons you want under `include`: ```ts lattice({ icons: { dirs: ["resources/icons"], include: [ { from: "lucide-static/icons", // a package's icon folder, or any local directory names: ["rocket", "sparkles", "wand-sparkles"], outDir: "resources/icons/lucide", }, ], }, }); ``` Each build copies `rocket.svg`, `sparkles.svg`, and `wand-sparkles.svg` out of the package into `resources/icons/lucide` and folds them into the sprite. Reference them by name like any other icon: ```php MenuItem::make('Launch')->icon('rocket'); ``` - **`from`** — a folder of SVGs: a package's icon directory resolved from `node_modules` (e.g. `lucide-static/icons`), or a path to a local directory. - **`names`** — the filenames to copy, without `.svg`. A name missing from the source **fails the build**, so a typo surfaces immediately. - **`outDir`** — where the SVGs are written. It joins the sprite automatically; you don't also list it under `dirs`. The copy is **idempotent**: it writes only files whose content changed, so re-running the build is a no-op once synced, and it never touches anything else in the folder — vendored and hand-authored icons can share a directory. Commit the copied SVGs; the source package is then only needed at build time, so anyone installing _your_ package gets the icons without it. :::note Dropping an icon from `names` leaves its committed SVG in place — vendoring never deletes files. Remove the stale SVG by hand when you no longer want it in the sprite. ::: ## Referencing icons Anywhere a component takes an icon you can pass a plain string or a backed enum: ```php use Lattice\Ui\Enums\Icon; // by name Action::make('app.send')->icon('send'); // or via the curated enum of Lattice's built-in icons Action::make('app.send')->icon(Icon::Send); ``` `Lattice\Ui\Enums\Icon` covers Lattice's own icon set. For your full set (Lattice's plus your own), generate an enum — see below. ## As a component `Icon` is also a component, so a standalone icon can go anywhere in a schema. It renders through the same renderer as `->icon()`, with structured `size`/`color` plus a raw `class` escape hatch: ```php use Lattice\Core\Color; use Lattice\Ui\Components\Icon; use Lattice\Ui\Enums\Size; Stack::make()->schema([ Icon::make('house'), // size defaults to Md Icon::make('circle-check')->size(Size::Lg)->color(Color::success()), Icon::make('spark')->class('opacity-70'), ]); ``` `size` defaults to `Size::Md`; `color` is optional and inherits `currentColor` when unset. Sizes resolve to themeable tokens (`--lt-icon-xs` … `--lt-icon-xl`). ## Type-safe icon names The plugin can generate a type module and/or a PHP enum from the built sprite, so icon names stay autocompletable. Both files are committed and regenerated idempotently on each build. **TypeScript** — Lattice emits `resources/js/types/sprite-icons.ts` by default. Override `dts` when you want a different generated file: ```ts lattice({ icons: { dirs: ["resources/icons"], dts: { file: "resources/js/sprite-icons.ts", augmentModule: "@lattice-php/lattice", augmentInterface: "KnownIcons", }, }, }); ``` `dts` merges over the defaults, so a partial override like `dts: { indent: "\t" }` keeps the default file/augment targets and only changes the indentation used in the generated file — handy for matching your formatter's style. If the formatter still fights with the generated output, add `resources/js/types/sprite-icons.ts` to its ignore list; it's a generated file and shouldn't be rewritten. **PHP** — `phpEnum` emits a backed enum covering your full sprite: ```ts lattice({ icons: { dirs: ["resources/icons"], phpEnum: { file: "app/Support/Icon.php", namespace: "App\\Support", enum: "Icon" }, }, }); ``` ```php use App\Support\Icon; MenuItem::make('Home')->icon(Icon::House); ``` Re-run the build (or dev server) after adding icons to refresh the generated files. ## The sprite outside Vite Storybook stories, a design-system export, prerender scripts, and tests render the same components without a dev server or an emitted asset. `buildLatticeSprite()` builds the sprite the plugin would serve — Lattice's icons, every installed component package's icons, and your `icons.dirs` — and returns an inline `SpriteValue` for `SpriteProvider`: ```ts import { buildLatticeSprite } from "@lattice-php/lattice/vite"; const sprite = buildLatticeSprite({ icons: { dirs: ["resources/icons"] } }); // { href: "", ids: ["check", "spark", …], source: "" } ``` It takes the same options as `lattice()`, so the sprite stays identical to the one your app ships. ## Custom rendering Server-driven icons resolve through a stack of renderer functions, so you can override how a name renders — for example to pull from an icon-component library — by wrapping part of the tree in `IconRendererProvider`: ```tsx import { IconRendererProvider } from "@lattice-php/lattice"; /* a node, or null to fall through */}> ; ``` A renderer that returns `null` falls through to the next one, ending at the sprite. This is rarely needed — dropping an SVG in a folder is the usual path. # Layouts > The app shell a page renders into — a server-side definition with an outlet that marks where the page's content goes. A layout is the shell that wraps your pages — the sidebar, header, and chrome that stay put while the page inside them changes. Like everything else in Lattice it is a server-side definition that builds a component tree, with one `Outlet` marking where the active [page](/core/pages/) renders. ## Defining a layout Extend `LayoutDefinition` and build the shell in `schema()`. The `#[AsLayout]` attribute registers it under a key, and `Outlet::make()` marks where the page's content appears: ```php use Illuminate\Http\Request; use Lattice\Core\Attributes\AsLayout; use Lattice\Ui\Components\Stack; use Lattice\Ui\Enums\Width; use Lattice\Core\PageSchema; use Lattice\Ui\Components\Menu; use Lattice\Ui\Components\MenuItem; use Lattice\Layouts\Components\Outlet; use Lattice\Ui\Components\Sidebar; use Lattice\Layouts\LayoutDefinition; #[AsLayout('app')] final class AppLayout extends LayoutDefinition { public function schema(PageSchema $schema, Request $request): PageSchema { return $schema->schema([ Stack::make('app-shell')->direction(Orientation::Horizontal)->schema([ Sidebar::make('app-sidebar')->collapsible()->items([ Menu::make('sidebar')->items([ MenuItem::fromPage(HomePage::class)->icon('house'), MenuItem::fromPage(ProductsPage::class)->label('Products'), ]), ]), Stack::make('app-main')->width(Width::Fill)->schema([ Outlet::make(), ]), ]), ]); } } ``` `schema()` receives the `Request`, so the shell can adapt to the current user — highlighting the active section, showing an avatar, or hiding links a visitor can't reach. ## The outlet A layout's schema must contain **exactly one** `Outlet`. It carries no markup of its own; it is the seam where Lattice drops the active page's component tree. Everything around it — sidebar, breadcrumbs, header — is shared chrome rendered once and left in place as the page changes. ## Named layout slots Named [`Slot` extension points](/core/pages/#named-extension-slots) use the same server-side schema mechanism in layouts. They are useful when a module needs to contribute navigation or chrome without replacing the application layout: ```php use Lattice\Ui\Slot; Stack::make('app-shell')->schema([ Sidebar::make('app-sidebar')->items([ Menu::make('navigation')->items([ MenuItem::fromPage(HomePage::class), Slot::make('app.sidebar.navigation'), ]), ]), Stack::make('app-main')->schema([ Outlet::make(), ]), ]); ``` `Slot` and `Outlet` can therefore appear in the same layout tree, but they solve different boundaries: | | `Slot` | `Outlet` | | ------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------- | | Resolved by | PHP on the server | The React layout renderer | | Filled with | Components registered through `Lattice::extend()` | The active page's complete schema | | Serialized itself | No—the registered components replace it before the wire payload is built | Yes—it remains an `outlet` node in the layout wire schema | | Allowed occurrences | Any number of named slots | Exactly one per layout | An empty named slot disappears without affecting the `Outlet` or the page tree inserted there. ## The header bar `Topbar` is a horizontal bar for chrome that sits above the page — a logo, search, settings menu, or appearance switcher. Call `->sticky()` to keep it pinned to the top of the viewport as the page scrolls, and `->items([...])` to fill it. A sticky topbar also claims its height as the page's sticky offset (`--lt-sticky-offset`), which sticky stacks and vertical tab rails pin beneath: ```php use Lattice\Ui\Components\Topbar; Topbar::make('app-topbar')->sticky()->items([ Menu::make('topbar-settings')->items([ MenuItem::make('Settings', 'settings'), ]), ]); ``` ## Callouts `Callouts` marks where flashed and action-emitted [callouts](/actions/effects/#callouts) render inside the shell — the persistent banners an action or a redirect can raise. Place it once, typically between the header bar and the `Outlet`: ```php use Lattice\Ui\Components\Callouts; Stack::make('app-main')->width(Width::Fill)->schema([ Callouts::make(), Outlet::make(), ]); ``` Without a `Callouts` slot, callout effects have nowhere to render. ## Choosing a layout A page selects its layout in its [`#[AsPage]` attribute](/core/pages/#the-aspage-attribute), not with a method. Pass a [`PageLayout`](/advanced/enums/#pages) for the common shells, or a string matching a `#[AsLayout]` key: ```php use Lattice\Core\Enums\PageWidth; use Lattice\Core\Enums\PageLayout; #[AsPage(route: '/products', layout: PageLayout::App, width: PageWidth::Full)] class ProductsPage extends Page {} ``` `PageLayout::App` and `PageLayout::Auth` are conventional keys (`app`, `auth`) for the two shells most apps need — a signed-in application frame and a bare authentication screen. `PageLayout::None` opts out entirely: the page renders with no shell, which is what auth and error screens usually want. A custom key works the same way — register the layout with `#[AsLayout('marketing')]` and reference it with `layout: 'marketing'`. Because the layout is set on the attribute, a [base page](/core/pages/#shared-base-pages) can pick it once for a whole section and concrete pages inherit it. ## Registration Layouts are discovered from the paths in `config('lattice.discover')` just like pages and the other definitions. Register a layout that lives elsewhere explicitly: ```php use Lattice\Core\Facades\Lattice; Lattice::layouts([AppLayout::class]); ``` ## What goes inside a layout The shell is built from the same components as a page, plus a set made for navigation chrome — `Sidebar`, `Topbar`, `Menu`, `MenuItem`, `Dropdown`, and `Breadcrumbs`. Those are covered in [Navigation](/core/navigation/). The chrome itself is plain React underneath: `Sidebar`, `SidebarFooter`, `Topbar`, and `Breadcrumbs` are exported from `@lattice-php/ui` for custom pages and component packages that want the same shell without the wire nodes — see [Navigation → Client-side chrome](/core/navigation/#client-side-chrome). # Navigation > The menus, dropdowns, and breadcrumbs you compose inside a layout to move around the app. Navigation chrome lives inside a [layout](/core/layouts/) — the sidebars, menus, dropdowns, and breadcrumbs that wrap your pages. These are server-driven components built from PHP, so a renamed route or a changed permission updates the navigation with no client work. This page covers those components; see [Layouts](/core/layouts/) for the shell they sit in. ## Menus The sidebar's links are `Menu` and `MenuItem` components: - `Sidebar::make()->collapsible()->items([...])` — the shell sidebar; `collapsible()` remembers its open state. Below the `md` breakpoint it becomes an off-canvas drawer instead of consuming layout width. - The sidebar renders no toggle button itself. Place one wherever you like (typically the `Topbar`) with a `Button` that fires the `toggle-sidebar` effect on the client — see below. - `Menu::make()->items([...])` — a list of menu items. - `MenuItem::make($label)->href($url)->icon($icon)` — a link. Nest a group with `->children([...])`. - `MenuItem::fromPage(SomePage::class)` — builds an item that links to a page's route automatically, so the URL stays in sync with the page. Override the label with `->label()`. ```php MenuItem::fromPage(ProductsPage::class)->label('Products')->icon(Icon::Table); ``` Because menu items reference pages by class, navigation can't drift out of sync with the pages it links to — a renamed route updates the link with no extra work. A menu item can submit with a non-GET method — useful for a logout link — by setting `->method()`: ```php use Lattice\Ui\Enums\HttpMethod; MenuItem::make('Log out')->href(route('logout', absolute: false))->icon('log-out')->method(HttpMethod::Post); ``` ## Toggling the sidebar A `Button` can dispatch effects on the client when clicked — no request to the server. The `toggle-sidebar` effect collapses the rail on desktop and opens the off-canvas drawer on mobile, so the toggle button can live anywhere (here, in the `Topbar`): ```php use Lattice\Ui\Components\Button; use Lattice\Ui\Enums\Emphasis; use Lattice\Facades\Effects; Button::make('Toggle sidebar', 'sidebar-toggle') ->icon('panel-left') ->emphasis(Emphasis::Ghost) ->effects(Effects::toggleSidebar('app-sidebar')); ``` `->effects()` accepts any effect, giving a button instant client-side behavior (open a modal, show a toast, reset a form) without a round-trip. ## Dropdowns `Dropdown` renders a composed trigger that reveals its `MenuItem`s in a popover — for grouping actions without nesting them in the sidebar tree: ```php use Lattice\Ui\Components\Icon; use Lattice\Ui\Components\Text; use Lattice\Ui\Enums\Placement; use Lattice\Ui\Components\Dropdown; use Lattice\Ui\Components\MenuItem; Dropdown::make('account-menu') ->placement(Placement::Bottom) ->trigger([ Icon::make('settings'), Text::make('Account'), ]) ->items([ MenuItem::fromPage(SettingsPage::class)->label('Settings'), MenuItem::make('Log out')->href(route('logout', absolute: false))->method(HttpMethod::Post), ]); ``` ## Raw blocks `RawBlock` renders trusted server HTML. Use it for small layout-specific fragments such as an avatar, team glyph, or badge when a dedicated Lattice component would be too specific: ```php use Lattice\Ui\Components\RawBlock; RawBlock::make('avatar')->blade('components.avatar', [ 'name' => $user->name, 'src' => $user->avatar, ]); ``` Use `->html()` when the markup is already available: ```php RawBlock::make('initials')->html('AL'); ``` ## User dropdown Build user menus from the same dropdown shell. The avatar, identity text, and menu placement are all server-driven, so there is no dedicated frontend component: ```php use Lattice\Ui\Components\RawBlock; use Lattice\Ui\Components\Stack; use Lattice\Ui\Components\Text; use Lattice\Ui\Enums\Placement; use Lattice\Ui\Components\Dropdown; use Lattice\Ui\Components\MenuItem; $user = $request->user(); Dropdown::make('user-menu') ->placement(Placement::Top) ->trigger([ Stack::make('user-menu-trigger')->direction(Orientation::Horizontal)->schema([ RawBlock::make('avatar')->blade('components.avatar', [ 'name' => $user->name, 'src' => $user->avatar, ]), Stack::make('user-menu-identity')->schema([ Text::make($user->name), Text::make($user->email), ])->hideWhenCollapsed(), ]), ]) ->items([ MenuItem::fromPage(SettingsPage::class)->label('Settings'), MenuItem::make('Log out')->href(route('logout', absolute: false))->method(HttpMethod::Post), ]); ``` ## Breadcrumbs `Breadcrumbs::make()` renders the current page's breadcrumb trail. Drop it once in your layout (a header bar is the usual spot) and every page fills it in: when the layout serializes, the component picks up whatever the active page returned from `Page::breadcrumbs()` (or set through `$schema->breadcrumbs()`) and sends the items down with the node: ```php use Lattice\Ui\Components\Breadcrumbs; Stack::make('app-main')->width(Width::Fill)->schema([ Breadcrumbs::make(), Outlet::make(), ]); ``` Pass `->items([...])` with `Breadcrumb` values to render a fixed trail instead of the page's — an empty array renders nothing, regardless of the page. ## Pinning a sidebar footer To keep navigation at the top of the sidebar and, say, a user menu pinned to the bottom, pass the bottom components to `->footer([...])`: ```php use Lattice\Ui\Enums\Placement; use Lattice\Ui\Components\RawBlock; use Lattice\Ui\Components\Text; use Lattice\Ui\Components\Dropdown; use Lattice\Ui\Components\Menu; use Lattice\Ui\Components\MenuItem; use Lattice\Ui\Components\Sidebar; Sidebar::make('app-sidebar')->collapsible() ->items([ Menu::make('sidebar')->items([ MenuItem::fromPage(HomePage::class)->icon('house'), ]), ]) ->footer([ Dropdown::make('user-menu') ->placement(Placement::Top) ->trigger([ RawBlock::make('avatar')->blade('components.avatar', ['name' => $user->name]), Text::make($user->name)->hideWhenCollapsed(), ]) ->items([ MenuItem::make('Log out')->href(route('logout', absolute: false))->method(HttpMethod::Post), ]), ]); ``` Give dropdowns in the footer `Placement::Top` so they open upward. ## Client-side chrome The React components behind `Sidebar`, `Topbar`, `Breadcrumbs`, `Menu`, `MenuItem`, and `Dropdown` are exported from `@lattice-php/ui` for custom pages and component packages. They take plain props instead of wire nodes: the Lattice adapters add the toggle-sidebar event, the remembered collapse state, the page's breadcrumb trail, and the active-item detection on top of them. ```tsx import { Breadcrumbs, Dropdown, Menu, MenuItem, Sidebar, SidebarFooter, Topbar, } from "@lattice-php/ui"; } /> {user.name}}>
``` `Sidebar` collapses the desktop rail to icons while `collapsed` is true (children read it through `useCollapsed()`), and renders the mobile drawer with a backdrop while `open` is true — closing it on backdrop click and Escape through `onOpenChange`. `MenuItem` renders a link with `href`, a button with `onClick`, a section header with neither, and a collapsible group when it has children (a flyout while the sidebar is collapsed); `open`/`onOpenChange` control the group, `defaultOpen` seeds it. `Dropdown` is a popover with menu semantics that closes on every navigation. `Breadcrumbs` links every item with an `href` and marks the last one as the current page. Links go through the active `NavigationProvider`, so they become Inertia visits inside a Lattice app. The adapter also exposes the location: `useNavigation().currentUrl` is the current path (the Lattice runtime seeds it from the Inertia page and tracks visits; without a provider it falls back to `window.location.pathname`), and `useNavigation().onNavigate(listener)` subscribes to completed navigations and returns the unsubscribe — the sidebar drawer and dropdowns close through it. Standalone consumers can supply both on their own adapter. # Pages > The entry point of a Lattice screen — a PHP class that builds a component tree and renders through Inertia. A page is the entry point of a Lattice screen. It extends `Lattice\Http\Page`, declares its route with a `#[AsPage]` attribute, and builds its UI in `render()`. Lattice discovers the class, registers a route for it, and renders it through Inertia — you write no controller and no Inertia page component of your own. ```php use Lattice\Core\Attributes\AsPage; use Lattice\Ui\Components\Heading; use Lattice\Core\PageSchema; use Lattice\Http\Page; use Lattice\Table\Components\Table; #[AsPage(route: '/products')] class ProductsPage extends Page { public function title(): string { return 'Products'; } public function render(PageSchema $schema): PageSchema { return $schema->schema([ Heading::make('Products'), Table::use(ProductsTable::class), ]); } } ``` ## Building the UI `render()` receives a fresh `PageSchema` and returns it with its components attached. Pass the whole tree at once with `->schema([...])`, or append components one at a time with `->component()`: ```php public function render(PageSchema $schema): PageSchema { return $schema ->component(Heading::make('Products')) ->component(Table::use(ProductsTable::class)); } ``` The components are the same [building blocks](/components/overview/) used everywhere else — layout primitives like `Stack`, content like `Heading` and `Text`, and the interactive [forms](/forms/overview/), [tables](/tables/overview/), and [actions](/actions/overview/) that carry their own endpoints. ## Named extension slots A page can expose part of its component tree to other modules without owning their components. Place a named `Slot` wherever contributions should appear and pass any context their factories need: ```php use Lattice\Ui\Components\Tabs; use Lattice\Ui\Slot; Tabs::make('project-settings-tabs')->schema([ Slot::make('project.settings.tabs')->context([ 'project' => $project, ]), ]); ``` Register each contribution from the module's service provider: ```php use Lattice\Core\Facades\Lattice; use Lattice\Ui\Components\Tab; Lattice::extend( 'project.settings.tabs', fn (Project $project, $user): Tab => Tab::make('api-tokens', 'API tokens') ->visible($user?->can('update', $project) ?? false) ->schema([ ApiTokensPanel::make($project), ]), priority: 20, ); ``` Each factory returns exactly one component. Lower priorities render first; contributions with the same priority retain registration order. Return the component with `->visible(false)` when it should not render rather than returning `null`. Slot context is available to the factory by parameter name. Object values also resolve by type, as the `Project $project` parameter does above. Factories additionally receive `$user`, `$slot` (or a typed `Slot`), a typed `Request`, and services from Laravel's container. See [Closure evaluation](/core/closure-evaluation/#hook-specific-utilities) for the complete rules. An unregistered slot renders nothing. Each rendered slot receives fresh component instances, and the `Slot` itself is expanded on the server before component visibility, tab selection, or serialization—it never becomes a client-side node. ## Route parameters `render()` is dispatched like a controller method, so route parameters and route-model binding resolve straight into its signature alongside the `PageSchema`: ```php use Workbench\App\Models\Product; #[AsPage(route: '/products/{product}/edit')] class ProductEditPage extends Page { public function render(PageSchema $schema, Product $product): PageSchema { return $schema->schema([ Heading::make("Edit {$product->name}"), // … ]); } } ``` Anything the container can resolve — a `Request`, a service, a bound model — can be type-hinted here too. ## Context Before `render()` runs, a page opens a [context](/core/context/) frame from its own route parameters, by convention: a bound model parameter seeds the key whose resolver was registered for its class, whatever the parameter itself is named, and a scalar parameter seeds the key sharing its own name when that name is registered. `render(Product $product)` above seeds the context key `product` because `Lattice::context('product', Product::class)` registered `Product` — not because the parameter happens to be named `product`. Every component the page builds — directly, through a layout, or nested inside a definition — inherits that frame, so a table or an action placed on the page can read `contextModel('product')` without it being threaded through by hand. Extend or override the frame explicitly with `PageSchema::context()`, chained **before** `->schema()` so the components it builds see the extended frame: ```php public function render(PageSchema $schema, Product $product): PageSchema { return $schema ->context(['product' => $product]) ->schema([ Table::use(ProductReviewsTable::class), ]); } ``` See [Context](/core/context/#frames) for how the frame reaches slots, layouts, and closure-built modals too. ## The `#[AsPage]` attribute `#[AsPage]` declares how the page is routed and framed: | Argument | Purpose | | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `route` | The URL path. Supports parameters (`/products/{product}/edit`). | | `name` | The route name. Defaults to the route segments joined by dots (`products.edit`), falling back to the class name without its `Page` suffix. | | `layout` | The [layout](/core/layouts/) the page renders into — a [`PageLayout`](/advanced/enums/#pages) or a registered layout key. Defaults to `PageLayout::None` (no shell). | | `width` | The measure the content is capped to, centred in the layout slot — a [`PageWidth`](/advanced/enums/#pages) (`Full`, `Large`, `Medium`, `Small`). Defaults to `PageWidth::Full`. | | `middleware` | Extra middleware for the page's route — a string or an array, merged after the `lattice.pages.middleware` config default (`['web']`). | | `can` | Abilities the current user must pass before the page renders — a string or an array. See [Authorization](/core/authorization/). | | `endpoints` | An [endpoint area](/introduction/configuration/#endpoint-areas) registered with `Lattice::endpoints()` — the page's components call back into that area's routes and middleware. | ```php use Lattice\Core\Enums\PageWidth; use Lattice\Core\Enums\PageLayout; #[AsPage( route: '/products', name: 'products.index', layout: PageLayout::App, width: PageWidth::Medium, middleware: 'auth', )] ``` :::caution Pages are **not authenticated by default** — the default stack is `['web']` only. Add `auth` via the attribute (or a shared base page), or gate access with [`authorize()`](#authorization), for any page that must not be public. ::: ## Shared base pages `layout`, `width`, `middleware`, and `endpoints` are inherited: a page that omits one of them takes the nearest value set by a parent class. Put the shared framing on a base page once, and concrete pages declare only their own route: ```php #[AsPage(layout: PageLayout::App, width: PageWidth::Full, middleware: 'auth')] abstract class AppPage extends Page {} #[AsPage(route: '/products', name: 'products.index')] class ProductsPage extends AppPage {} // inherits the App layout, width, and middleware ``` ## Layout and width at request time The attribute sets the layout and width statically. To decide them per request — a different shell for a guest versus an authenticated user, say — override `layout()` or `width()` on the page. Returning a non-null value (a `PageLayout`/`PageWidth` case or a registered key) takes precedence over the attribute; returning `null` defers to it. ```php public function layout(): PageLayout|string|null { return request()->user() ? PageLayout::App : PageLayout::Auth; } ``` ## Discovery and registration Lattice scans the paths in `config('lattice.discover')` (your `app/` directory by default) for classes carrying `#[AsPage]` and registers a route for each one. Register pages that live outside those paths explicitly: ```php use Lattice\Core\Facades\Lattice; Lattice::pages([ ProductsPage::class, ProductEditPage::class, ]); ``` Discovery is cached alongside `route:cache`, so the filesystem scan does not run on production requests. ## Embedded pages `#[AsPage]` means "this page owns a route" — its `route` argument is what Lattice needs to register one. A page can also have no route at all and be rendered by returning it from your own controller instead; `Page` implements `Responsable`, so returning an instance is enough: ```php use Illuminate\Http\Request; use Lattice\Http\Page; class ProductEmbedController { public function show(Request $request): Page { return new ProductEmbedPage($request->route('product')); } } ``` The page itself needs no `#[AsPage]` attribute — a plain `Page` subclass works, since `layout()` and `width()` method overrides take precedence over attribute metadata regardless of whether the attribute is present: ```php use Lattice\Core\PageSchema; use Lattice\Core\Enums\PageLayout; class ProductEmbedPage extends Page { public function __construct(private readonly Product $product) {} public function layout(): PageLayout { return PageLayout::App; } public function render(PageSchema $schema): PageSchema { return $schema->component(Heading::make($this->product->name)); } } ``` A route-less `#[AsPage]` class is valid too — useful when the shared metadata (layout, width, middleware inheritance from a base page) is worth keeping even though the page has no route of its own. Either way, Lattice never builds a route for it: discovery and `Lattice::pages()` both register it in the page registry, but only entries with a `route` reach `Route::get()`. ## Title and breadcrumbs `title()` sets the document title and `breadcrumbs()` the page's trail; a layout's [`Breadcrumbs`](/core/navigation/#breadcrumbs) component renders whatever the active page provides. `breadcrumbs()` returns `Breadcrumb` instances — build one directly with `Breadcrumb::make()`, or, for a link to another page, `Breadcrumb::toPage()`, which resolves the label and href from the target page class so the trail can't drift out of sync with a renamed route: ```php use Lattice\Core\Breadcrumb; public function breadcrumbs(): array { return [ Breadcrumb::toPage(ProductsPage::class), Breadcrumb::make('Edit', ''), ]; } ``` Chain `->title()` onto a `Breadcrumb` to override the label `toPage()` derived: ```php Breadcrumb::toPage(ProductsPage::class)->title(__('Products')); ``` `title()` and `breadcrumbs()` take no parameters, so they can't read the route or an injected model. When a value depends on the request — a record's name, say — set it on the `PageSchema` from `render()` instead, which is dispatched with the same route parameters as any other controller method. A value set on the schema wins over the corresponding `Page` method; leaving it unset falls through to the method, and passing `->breadcrumbs([])` deliberately clears the trail rather than falling through to it: ```php public function render(PageSchema $schema, Product $product): PageSchema { return $schema ->title($product->name) ->breadcrumbs([ Breadcrumb::toPage(ProductsPage::class), Breadcrumb::toPage(self::class, ['product' => $product->getKey()])->title($product->name), ]) ->schema([ // … ]); } ``` ## Authorization Declare a subject-less ability with `can` on the attribute: ```php #[AsPage(route: '/products', can: 'products.view')] ``` For anything that needs the request or a record, override `authorize()`; it returns `true` by default. A request that fails either check is rejected before `render()` runs: ```php use Illuminate\Http\Request; public function authorize(Request $request): bool { return $request->user()?->can('viewAny', Product::class) ?? false; } ``` These are the same two tools every Lattice definition carries — see [Authorization](/core/authorization/) for how they behave across forms, tables, and actions. # Realtime > Declare websocket listeners on a page that run client effects when a broadcast event arrives. A page can subscribe to broadcast events and react to them on the client — show a toast, raise a [callout](/actions/effects/#callouts), or reload the page — without writing any JavaScript. You declare the listeners on the server; Lattice wires up the websocket subscription and runs the effects when an event arrives. It is built on [Laravel Echo](https://laravel.com/docs/broadcasting) and a broadcasting backend such as [Reverb](https://laravel.com/docs/reverb). ## Declaring listeners Override `listeners()` on a [page](/core/pages/) and return one or more `Listen` declarations. Each names a channel, the broadcast event(s) to react to, and the effects to dispatch: ```php use Lattice\Realtime\Listen; protected function listeners(): array { return [ Listen::channel('orders') ->on('.OrderShipped') ->toast('An order just shipped'), ]; } ``` When an `OrderShipped` event broadcasts on the `orders` channel, every connected client viewing the page shows the toast. The leading dot (`.OrderShipped`) matches a broadcast name as-is; drop it to use Laravel's namespaced event class convention. ## Channels Choose the channel type with the matching constructor: ```php Listen::channel('orders'); // public Listen::private('orders.42'); // private — requires channel authorization Listen::presence('room.42'); // presence ``` Private and presence channels go through Laravel's [channel authorization](https://laravel.com/docs/broadcasting#authorizing-channels), so only authorized users subscribe. ## Effects Listener effects are the broadcast-safe subset of [action effects](/actions/effects/) — they run with no request context, so they can't redirect or open a modal: | Effect | What it does | | ------------------------------ | -------------------------------------------------------------------------------------------------- | | `->toast($message, $variant?)` | Shows a [toast](/actions/toasts/). | | `->callout($callout)` | Raises a persistent [callout](/actions/effects/#callouts) (needs a `Callouts` slot in the layout). | | `->reloadPage()` | Reloads the current page's props — the simplest way to pull fresh data. | Chain several on one listener, and declare several listeners per page: ```php Listen::private('orders.'.$request->user()->id) ->on(['.OrderShipped', '.OrderDelivered']) ->toast('Your order was updated') ->reloadPage(); ``` ## Client setup Lattice mounts the listeners from the page payload for you — there is nothing to render. You only need Echo configured once in your app entry point: ```ts import { configureEcho } from "@laravel/echo-react"; configureEcho({ broadcaster: "reverb" /* …your Reverb/Pusher config */ }); ``` If a page declares listeners but Echo isn't configured, Lattice logs a warning and renders nothing — the page still works, it just won't receive realtime updates. :::caution Realtime can be turned off globally with the `lattice.realtime.enabled` [config flag](/introduction/configuration/#realtime). When disabled, listeners are not serialized to the client. ::: # Component packages > Ship a custom component — PHP and its React renderer — as a Composer package, with no npm publish. import Info from "@components/Info.astro"; import Warning from "@components/Warning.astro"; The [extension points](/extending/overview/) assume a component lives in your own app. To _distribute_ one — a signature pad, a chart widget, a domain-specific field — you would normally also publish an npm package for its React renderer, and keep it in lockstep with the PHP side. Lattice removes that second release pipeline: a component package can ship its React source for Vite consumers and a precompiled module for [no-build apps](/introduction/no-build/) inside the same Composer release. A plain `composer require` is enough. ## What a package ships Four source pieces — or let the generator write them. Point `--package` at a directory that has no `composer.json` yet and Lattice scaffolds the package on first use (the name and PSR-4 namespace are derived from the folder — `packages/acme-signature` → `acme/signature`, `Acme\Signature\`), then creates the component inside it: ```bash php artisan lattice:component Signature --package=packages/acme-signature ``` **1. A `composer.json` declaring the Lattice entry points.** The generator writes `plugin` and `discover`; add `standalone` after configuring the separate precompile described below: ```json { "name": "acme/signature", "autoload": { "psr-4": { "Acme\\Signature\\": "src/" } }, "extra": { "lattice": { "plugin": "resources/js/plugin.ts", "css": "resources/css/signature.css", "icons": "resources/icons", "standalone": "dist/plugin.js", "discover": ["src"] } } } ``` **2. The PHP component, carrying its wire `type`:** ```php use Lattice\Core\Attributes\AsComponent; use Lattice\Ui\Components\Component; #[AsComponent('signature')] final class Signature extends Component { public string $label = 'Sign here'; public static function make(?string $key = null): static { return new self($key); } public function label(string $label): static { $this->label = $label; return $this; } } ``` **3. The React renderer for that type:** ```tsx import type { RendererComponent } from "@lattice-php/core/types"; const Signature: RendererComponent<"signature"> = ({ node }) => (
{String(node.props?.label ?? "Sign here")}
); export default Signature; ``` **4. The plugin entry that registers it:** ```ts import { lazyComponent, type Plugin } from "@lattice-php/lattice/runtime"; export default { name: "acme-signature", components: { signature: lazyComponent(() => import("./signature")), }, } satisfies Plugin; ``` The optional `dist/plugin.js` is a single precompiled ESM file. Build it with `react`, `react-dom`, `react/jsx-runtime`, and `@lattice-php/lattice/runtime` left as external imports; the standalone host maps those specifiers to its own React and Lattice instances. Bundle every other dependency and inline dynamic imports so the Composer package does not need to publish additional chunks, and define `process.env.NODE_ENV` away — the file runs in the browser as-is. ## How it reaches the app Each `extra.lattice` key is read by one side of the stack: - **`plugin`** — the `lattice()` Vite plugin scans `vendor/composer/installed.json`, and for every package that declares it, grants Vite filesystem access to the package directory and exposes its plugin under the virtual module `virtual:lattice/plugins`. The package's TSX compiles straight into the consumer's bundle: no separate build step, one shared React instance, full tree-shaking. - **`css`** — the plugin aliases it as `@//css` (`lattice-php/signature-example` → `@lattice-php/signature-example/css`) and wires it, along with an `@source` for the package's JS, into `virtual:lattice/css` — the consumer's existing `@import '@lattice-php/lattice/css'` picks it up with no per-package import of their own. See [Styling](#styling). - **`icons`** — a directory of `.svg` files the plugin merges into the app's icon sprite. See [Icons](#icons). - **`standalone`** — `php artisan lattice:assets` copies the precompiled module into `public/vendor/lattice/plugins` and adds its versioned URL to the standalone boot config. - **`discover`** — Lattice's PHP discovery merges these roots into `lattice.discover`, so the package's `#[AsComponent]` classes are picked up by `php artisan lattice:typescript` (which types `node.props` for the package's components) and by definition discovery for any forms, tables, or pages the package also ships. A package that owns full screens (an auth UI, say) does not need to register a route for each one — its controllers can return a `Page` directly instead; see [Embedded pages](/core/pages/#embedded-pages). ## Installing one For the consumer, it is a single dependency: ```bash composer require acme/lattice-signature ``` If you followed the [installation guide](/introduction/installation/), the discovered plugins are already registered — the standard bootstrap passes `virtual:lattice/plugins` to `createLatticeApp`, so an installed package registers itself with no further wiring: ```ts import plugins from "virtual:lattice/plugins"; createLatticeApp({ plugins }); ``` (Or merge them onto the registry yourself with `extendRegistry(registry, ...plugins)`.) Then use the component like any built-in: ```php Signature::make('signature')->label('Sign the contract'); ``` The `lattice()` Vite plugin runs `php artisan lattice:typescript` for you when the dev server starts, so the `ComponentProps` augmentation picks up a newly installed package automatically. Run it by hand (`php artisan lattice:typescript`) for CI and editor tooling, which don't have a dev server refreshing types in the background. Source-plugin discovery requires Lattice's `lattice()` Vite plugin. No-build apps instead require the package's `standalone` entry and run `php artisan lattice:assets` after installation. ## Verifying your package is discovered `php artisan about` has a `Lattice` section listing the configured discover paths, the discovery roots and JS plugin each installed component package contributes, and whether the discovery manifest is cached. Run it after `composer require`-ing a package to confirm it was picked up without inspecting `installed.json` by hand. ## Testing your package A package's own test suite (a Testbench workbench, typically) never appears in `vendor/composer/installed.json` — Composer treats it as the ROOT project while its tests run, not an installed dependency. Both discovery sides account for this: PHP discovery also reads the package's own `composer.json` for `extra.lattice.discover`, and the Vite plugin also reads it for `extra.lattice.plugin`. The same `composer.json` from [What a package ships](#what-a-package-ships) is enough — no pushing config into the workbench app, and no difference between how the package discovers itself and how a consumer discovers it once installed. This isn't specific to a testbench workbench, either — any composer ROOT (including a real app) can declare `extra.lattice.discover`/`plugin` directly in its own `composer.json`, and it is picked up the same way. ## Styling Use Lattice's `lt-` design tokens — `bg-lt-surface`, `text-lt-fg`, `border-lt-border`, `rounded-lt-sm` — in a package component. They ship pre-compiled in `@lattice-php/lattice/css`, so the component is themed correctly with no extra Tailwind configuration in the consuming app. A package that needs more than tokens ships its own stylesheet. Create `resources/css/.css` starting with an `@source` directive pointing at the package's JS: ```css @source "../js"; .acme-signature-pad { border-color: var(--lt-border); } ``` Declare it as `extra.lattice.css`: ```json "extra": { "lattice": { "css": "resources/css/signature.css" } } ``` The consumer needs nothing further: the `lattice()` Vite plugin discovers the package the same way it discovers `plugin`, and folds its `@source`/`@import` into `virtual:lattice/css`, which `@lattice-php/lattice/css` (and `@lattice-php/ui/css`) end with. A consumer's existing ```css @import "@lattice-php/lattice/css"; ``` is enough — no per-package `@import`, and no `@source` pointing at the package's `vendor/` path, to add by hand. (Aliasing `@acme/signature/css` still works for a consumer who wants to import the package's stylesheet on its own, e.g. to control ordering explicitly.) The `@source` line in the package's own stylesheet still matters for the package's own workbench — its own dev server builds the package directly, outside the discovery flow above — and documents the scan boundary for readers of the package's source. See `packages/signature-example/resources/css/signature-example.css` for the reference implementation. Standalone (no-build) consumers do not process `@source`; package css support there is deferred. ## Icons `extra.lattice.icons` names a directory of `.svg` files that the `lattice()` Vite plugin merges into the app's icon sprite: ```json "extra": { "lattice": { "icons": "resources/icons" } } ``` The sprite is a single flat namespace, merged in order — the built-in `ui` icons first, then every discovered package (in composer order), then the app's own icon dirs — and later entries win on a name collision. Prefix icon names with the package name (`signature-example-pen`) to avoid colliding with another package; an app can still deliberately override a package icon by shipping one of the same name in its own icon dir. Render an icon like any built-in one: ```tsx import { Icon } from "@lattice-php/ui/icons"; ; ``` The generated `KnownIcons` TypeScript augmentation includes package icons automatically, so `name` stays typed in the consuming app. ## Translations A package component translates like a built-in one: declare an i18next namespace on the plugin and read keys with `useT`, passing the English default inline at the call site: ```ts import { lazyComponent, type Plugin } from "@lattice-php/lattice/runtime"; export default { name: "acme-signature", components: { signature: lazyComponent(() => import("./signature")), }, i18n: { namespace: "acme-signature", }, } satisfies Plugin; ``` ```tsx import { useT } from "@lattice-php/lattice/runtime"; const Signature: RendererComponent<"signature"> = ({ node }) => { const { t } = useT("acme-signature"); return (
{typeof node.props?.label === "string" ? node.props.label : t("placeholder", "Sign here")}
); }; ``` `createLatticeApp` merges every plugin's namespace into the [i18n bootstrap](/core/i18n/), so when the app enables the translation backend the namespace loads from `/locales/{lng}/acme-signature.json` like any other — serve the package's lang files by registering them in the package's service provider: ```php use Lattice\Core\Facades\Lattice; Lattice::translations('acme-signature', __DIR__.'/../lang'); ``` Do not use Laravel's `loadTranslationsFrom()` here: it registers the namespace in a deferred callback the i18next JSON route never triggers, so the translations silently fail to appear. `Lattice::translations()` registers on the translation loader directly, which both the translator and the route read. The inline defaults are the no-backend fallback: apps that never enable the translation backend render them as-is, and a loaded translation for the key always wins. ## Drag and drop A package that needs drag-and-drop — reordering a list, moving cards between columns — imports the primitives from `@lattice-php/lattice/dnd` (Atlassian's `pragmatic-drag-and-drop`, re-exported by core) instead of depending on the library directly: ```ts import { draggable, attachTreeItemInstruction, announce, type Edge, } from "@lattice-php/lattice/dnd"; ``` A Composer package has no way to deliver npm dependencies into the consumer's bundle, so core owns the dependency and republishes it under its export map, the same way core owns i18next behind `.../i18n` above. ## Testing the package Extend `Lattice\Support\Testing\PackageTestCase` instead of hand-writing a Testbench `TestCase`: it boots Inertia and Lattice around the package's own providers on an in-memory sqlite app, applies the package's config overrides before boot, pulls in the [Lattice component assertions](/testing/overview/), and wires the conventional `workbench/` view and migration paths when they exist: ```php use Lattice\Support\Testing\PackageTestCase; abstract class TestCase extends PackageTestCase { protected function packageProviders(): array { return [AcmeSignatureServiceProvider::class]; } protected function packageConfig(): array { return ['lattice.discover' => [__DIR__.'/../workbench/app']]; } } ``` For Pest browser suites, extend `PackageBrowserTestCase` instead — it additionally fails fast with an actionable message when the workbench Vite build is missing or a stale dev-server marker is left behind, widens Playwright's timeout for CI runners, and keeps the browser's connections to the test server alive (see [Browser tests](/testing/overview/#browser-tests)). See [Registry and types](/extending/registry-and-types/) for how the `type` string couples the two sides, and how generated types keep `node.props` sound. A package that adds a rich-editor node follows the same PHP-class-plus-client-definition shape, plus server-side seams of its own for rendering, sanitizing, and validating that node — see [Server-side extensions](/forms/fields/rich-editor/#server-side-extensions). # Custom columns > Scaffold a custom table column cell renderer with a PHP class and a React cell component. This walkthrough adds a `StatusBadge` column that renders a coloured pill based on a row's status value. ## 1. Publish the JS scaffold If you have not done this yet: ```bash php artisan vendor:publish --tag=lattice-js ``` This writes a single `resources/js/registry.ts` if it does not already exist — the one place custom fields, components, and columns are registered. ## 2. Generate the column ```bash php artisan lattice:column StatusBadge ``` This creates: - `app/Tables/Columns/StatusBadge.php` — the PHP column class. - `resources/js/columns/status-badge.tsx` — the React cell renderer stub. - An entry under `extensions["table.columns"]` in `resources/js/registry.ts` wiring them together. - Runs `lattice:typescript` to refresh the generated types file. The PHP attribute receives the short identifier `status-badge`; the wire type is `column.status-badge`. Pass `--type=` to override it. ## 3. The generated PHP class A column reflects its **public** properties into its wire props — exactly like a component — so there is no `toData()` and no separate props class to maintain. ```php |null */ public ?array $colorMap = null; /** @param array $colorMap */ public function colorMap(array $colorMap): static { $this->colorMap = $colorMap === [] ? null : $colorMap; return $this; } } ``` `colorMap` is public, so it lands in the column's `props`; declaring it nullable keeps the wire shape honest (it is `null` until set). Internal state a cell never reads — filter flags, cached lookups — stays `protected` so reflection leaves it off the wire. ## 4. The generated React cell renderer ```tsx import type { ColumnCellComponent } from "@lattice-php/lattice"; export const StatusBadgeCell: ColumnCellComponent = ({ value }) => { return {String(value ?? "")}; }; ``` A `ColumnCellComponent` receives `{ column, props, row, value }`: - `value` — the raw cell value (the column's key resolved from the row). - `row` — the full row data object. - `props` — the column's props sent from PHP. They are a loose bag by default; type the cell as `ColumnCellComponent<"column.status-badge">` to narrow them to your column's props (see below). - `column` — the full serialized column descriptor (key, label, type, nested columns). Replace the stub body with real UI: ```tsx import type { ColumnCellComponent } from "@lattice-php/lattice"; const colorClasses: Record = { active: "bg-green-100 text-green-800", archived: "bg-red-100 text-red-800", draft: "bg-gray-100 text-gray-800", }; export const StatusBadgeCell: ColumnCellComponent = ({ props, value }) => { const label = String(value ?? ""); const map = (props?.colorMap as Record | undefined) ?? colorClasses; const classes = map[label] ?? "bg-gray-100 text-gray-800"; return ( {label} ); }; ``` To drop the cast, type the cell as `ColumnCellComponent<"column.status-badge">`. After `lattice:typescript`, `props` is narrowed to your column's generated props: ```tsx import type { ColumnCellComponent } from "@lattice-php/lattice"; export const StatusBadgeCell: ColumnCellComponent<"column.status-badge"> = ({ props, value }) => { const map = props.colorMap ?? colorClasses; // typed, no cast // ... }; ``` The generator registers column cells **bare** (`"column.status-badge": StatusBadgeCell`). The optional `columnCell()` helper is a type-narrowing identity wrapper — `columnCell(StatusBadgeCell)` — for when you want the registry entry itself type-checked against the column's props; it changes nothing at runtime. ## 5. The registry entry The generator appended an entry to the `table.columns` extension registry in `resources/js/registry.ts`: ```ts import { extendRegistry, registry as packageRegistry } from "@lattice-php/lattice"; import type { Plugin } from "@lattice-php/lattice"; import { StatusBadgeCell } from "./columns/status-badge"; export const registry = extendRegistry(packageRegistry, { name: "app", components: {}, extensions: { "table.columns": { "column.status-badge": StatusBadgeCell, }, }, } satisfies Plugin); ``` ## 6. Wire the registry in app.tsx `registry.ts` already merges your column onto the built-in registry. Pass its exported `registry` to `createLatticeApp` — the same one-call bootstrap from installation, now made aware of your custom cells: ```tsx import "../css/app.css"; import { createLatticeApp } from "@lattice-php/lattice"; import plugins from "virtual:lattice/plugins"; import sprite from "virtual:svg-sprite"; import { registry } from "./registry"; createLatticeApp({ registry, plugins, sprite, pages: import.meta.glob("./Pages/**/*.tsx"), }); ``` Passing `registry` is what makes your cell render. Without it, `createLatticeApp` falls back to the built-in registry, your custom type has no cell, and the column silently falls back to the built-in text cell — the value renders as plain text instead of through your component. Custom fields, components, and columns all live in this same `registry.ts` — there is no second registry to merge. ## 7. Generate TypeScript types ```bash php artisan lattice:typescript ``` This augments `ColumnProps` in `@lattice-php/core`: ```ts declare module "@lattice-php/core" { interface ColumnProps { "column.status-badge": { colorMap: Record | null; }; } } ``` `column.props` is now narrowed in the cell renderer, eliminating the cast. ## 8. Use the column in a table ```php use App\Tables\Columns\StatusBadge; use Illuminate\Database\Eloquent\Builder; use Lattice\Table\Attributes\AsTable; use Lattice\Table\Columns\Column; use Lattice\Table\Columns\TextColumn; use Lattice\Table\Sources\Eloquent\EloquentTableDefinition; use Lattice\Table\TableQuery; /** * @extends EloquentTableDefinition<\App\Models\User> */ #[AsTable('app.users')] final class UsersTable extends EloquentTableDefinition { /** * @return array */ public function columns(): array { return [ TextColumn::make('name')->label('Name')->sortable(), StatusBadge::make('status')->label('Status') ->colorMap([ 'active' => 'bg-green-100 text-green-800', 'archived' => 'bg-red-100 text-red-800', 'draft' => 'bg-gray-100 text-gray-800', ]), ]; } /** * @return Builder<\App\Models\User> */ public function builder(TableQuery $query): Builder { return \App\Models\User::query()->select(['id', 'name', 'status']); } } ``` # Custom fields > Scaffold a custom form field with a PHP class and a React renderer. This walkthrough adds a `ColorPicker` field — a native `` — to a Lattice form. ## 1. Publish the JS scaffold If you have not done this yet, publish the registration file the generators expect: ```bash php artisan vendor:publish --tag=lattice-js ``` This writes a single `resources/js/registry.ts` if it does not already exist — the one place custom fields, components, and columns are registered. ## 2. Generate the field ```bash php artisan lattice:field ColorPicker ``` This creates: - `app/Forms/Fields/ColorPicker.php` — the PHP class. - `resources/js/fields/color-picker.tsx` — the React renderer stub. - An entry under `components` in `resources/js/registry.ts` wiring them together. - Runs `lattice:typescript` to refresh the generated types file. The PHP attribute receives the short identifier `color-picker`; the wire type is `field.color-picker`. Pass `--type=` to override it. ## 3. The generated PHP class ```php swatches = $swatches; return $this; } } ``` ## 4. The generated React renderer ```tsx import type { RendererComponent } from "@lattice-php/lattice"; export const ColorPickerComponent: RendererComponent<"field.color-picker"> = ({ node }) => { // Render the field.color-picker field. Field state is available on node.props. return
; }; ``` Replace the stub body with real UI: ```tsx import type { RendererComponent } from "@lattice-php/lattice"; export const ColorPickerComponent: RendererComponent<"field.color-picker"> = ({ node }) => { return ( ); }; ``` `node.props` contains all the serialized field data — name, value, label, required, and any extra properties you added to the PHP class. To give a custom control the same label, required marker, helper text, tooltip, and error frame the built-in fields use, wrap it in `FormField` from `@lattice-php/ui`. It passes the `id` and `aria-*` wiring for the control through a render prop; pass `bare` to keep only a visually hidden label, as fields inside a repeater table cell do: ```tsx import { FormField } from "@lattice-php/ui"; {(controlProps) => } ; ``` ## 5. The registry entry The generator appended an entry to `resources/js/registry.ts`, wrapping the renderer in `eagerComponent`: ```ts import { eagerComponent, extendRegistry, registry as packageRegistry } from "@lattice-php/lattice"; import type { Plugin } from "@lattice-php/lattice"; import { ColorPickerComponent } from "./fields/color-picker"; export const registry = extendRegistry(packageRegistry, { name: "app", components: { "field.color-picker": eagerComponent(ColorPickerComponent), }, } satisfies Plugin); ``` The plugin object accepts any number of component type keys. Use `lazyComponent` instead for a code-split renderer — its loader must resolve to a module with a `default` export: ```ts "field.color-picker": lazyComponent(async () => ({ default: (await import("./fields/color-picker")).ColorPickerComponent, })), ``` ## 6. Wire the registry in app.tsx `registry.ts` already merges your plugin onto the built-in registry. Pass its exported `registry` to `createLatticeApp` — the same one-call bootstrap from installation, now made aware of your custom components: ```tsx import "../css/app.css"; import { createLatticeApp } from "@lattice-php/lattice"; import plugins from "virtual:lattice/plugins"; import sprite from "virtual:svg-sprite"; import { registry } from "./registry"; createLatticeApp({ registry, plugins, sprite, pages: import.meta.glob("./Pages/**/*.tsx"), }); ``` Passing `registry` is what makes your field render. Without it, `createLatticeApp` falls back to the built-in registry, your custom type has no renderer, and the node renders a muted missing-component placeholder instead of your field (Lattice also logs a `[lattice] No component registered…` warning in development to flag exactly this). ## 7. Generate TypeScript types ```bash php artisan lattice:typescript ``` This writes `resources/js/lattice/generated.d.ts`, which augments the `ComponentProps` interface in `@lattice-php/core`: ```ts declare module "@lattice-php/core" { interface ComponentProps { "field.color-picker": { swatches: string | null; }; } } ``` After running this command, `node.props.swatches` is typed in your renderer. ## 8. Use the field in a form ```php use App\Forms\Fields\ColorPicker; use Illuminate\Http\Request; use Lattice\Form\Attributes\AsForm; use Lattice\Form\Components\Form as FormComponent; use Lattice\Form\FormData; use Lattice\Form\FormDefinition; use Symfony\Component\HttpFoundation\Response; #[AsForm('app.brand-settings')] final class BrandSettingsForm extends FormDefinition { public function definition(FormComponent $form, Request $request): FormComponent { return $form->schema([ ColorPicker::make('brand_color', 'Brand color') ->swatches('#ff0000,#00ff00,#0000ff') ->value('#6366f1'), ]); } public function handle(FormData $data): Response { // persist $data->string('brand_color') … return redirect()->back(); } } ``` The field serializes to a node with `type: "field.color-picker"` and the renderer picks it up automatically. # Extending Lattice > Add custom components, form fields, and table columns to a Lattice application. Lattice ships with a built-in set of components, fields, and columns. When your application needs something the built-ins do not cover, you extend the registry with your own types. ## The mental model Every client extension in Lattice carries a `type` string. The PHP class declares it once via an attribute, and the client uses that type to find the matching implementation. Use `#[AsField]` for form fields, `#[AsComponent]` for regular UI components, and `#[AsColumn]` for table columns. ```php use Lattice\Form\Attributes\AsField; use Lattice\Form\Components\Field; #[AsField(type: 'color-picker')] class ColorPickerField extends Field {} ``` On the React side, a matching renderer is registered under the same type key: ```tsx import type { RendererComponent } from "@lattice-php/lattice"; export const ColorPickerComponent: RendererComponent<"field.color-picker"> = ({ node }) => { return ; }; ``` That string — `"field.color-picker"` — is the only coupling between the PHP class and the React component. ## Three extension points | Kind | PHP base class | Registry | | ------------ | --------------------------------- | ----------------------------- | | Form field | `Lattice\Form\Components\Field` | `components` | | UI component | `Lattice\Ui\Components\Component` | `components` | | Table column | `Lattice\Table\Columns\Column` | `extensions["table.columns"]` | All three register in one plugin object. Form fields and UI components are complete nodes, so the core renderer resolves them through `components`. A table column only contributes a cell renderer to the table feature, so it goes in `extensions["table.columns"]`. ## Generators scaffold both sides The `lattice:field`, `lattice:component`, and `lattice:column` commands generate the PHP class, the `.tsx` renderer (under `resources/js/fields/`, `components/`, or `columns/`), and append the registration entry to `resources/js/registry.ts` — so you get a working pair to build on: ```bash php artisan lattice:field ColorPicker php artisan lattice:component Rating php artisan lattice:column StatusBadge ``` Each command accepts `--type=` to override the derived type string. See [Artisan commands](/core/artisan-commands/) for the full CLI reference. ## Type generation After adding custom props to a PHP class, run: ```bash php artisan lattice:typescript ``` This scans the paths listed in `config/lattice.php` under `discover`, reads public properties, and writes `resources/js/lattice/generated.d.ts`. That file augments `ComponentProps` (for fields and components) and `ColumnProps` (for columns) in the `@lattice-php/core` module, giving you typed `node.props` and `column.props` in the renderer. Without running `lattice:typescript` the props fall back to a loose `Record` — the renderer still works, types are just not narrowed. ## Where to go next - [Custom fields](/extending/custom-fields/) — end-to-end walkthrough for a `ColorPicker` form field. - [Custom columns](/extending/custom-columns/) — end-to-end walkthrough for a `StatusBadge` table column. - [Registry and types](/extending/registry-and-types/) — the full React API and the TypeScript augmentation system. # Registry and types > The React registry API and the TypeScript augmentation system for custom Lattice components. ## The JS scaffold Before registering custom components or columns, publish the scaffold file: ```bash php artisan vendor:publish --tag=lattice-js ``` This writes a single `resources/js/registry.ts`. It defines an app plugin with empty component and table-column registries and merges it onto the built-in registry with `extendRegistry`, exporting the result as `registry`: ```ts import { extendRegistry, registry as packageRegistry } from "@lattice-php/lattice"; import type { Plugin } from "@lattice-php/lattice"; export const registry = extendRegistry(packageRegistry, { name: "app", components: {}, // custom fields and UI components extensions: { "table.columns": {}, // custom column cells }, } satisfies Plugin); ``` The generators (`lattice:field`, `lattice:component`, `lattice:column`) append their entries to this file automatically — fields and components under `components`, columns under `extensions["table.columns"]`. You only need to publish once, and you pass the exported `registry` to `Provider`. ## Components and extensions `components` contains complete wire nodes. They have `type`, `props`, and optional children, and the core renderer owns their lifecycle. Form fields belong here because they are full nodes rendered in the form tree. `extensions` contains named registries owned by a feature. A table column contributes a cell renderer to `table.columns`; a rich-editor extension contributes Tiptap behavior to `form.rich-editor`. Core only merges these registries—the table or editor decides how to use them. ## Node registry API The node registry maps type strings to `RendererComponent` functions. Imports come from `@lattice-php/lattice`. ### Plugin objects A plugin is a plain object that bundles one or more component registrations. Use `satisfies Plugin` to check its shape without changing the inferred component keys: ```ts import { eagerComponent } from "@lattice-php/lattice"; import type { Plugin } from "@lattice-php/lattice"; import { ColorPickerComponent } from "./fields/color-picker"; import { RatingComponent } from "./components/rating"; export const appPlugin = { name: "app", components: { "field.color-picker": eagerComponent(ColorPickerComponent), rating: eagerComponent(RatingComponent), }, } satisfies Plugin; ``` ### Loading precompiled plugins `loadPluginModules` imports and validates precompiled plugin URLs. It is a core API, so both regular apps and the standalone build can load plugins before creating the app: ```ts import { createLatticeApp, loadPluginModules } from "@lattice-php/lattice"; const plugins = await loadPluginModules(["/vendor/acme/plugin.js"]); createLatticeApp({ plugins }); ``` ### extendRegistry Merges a plugin into an existing registry, returning a new registry without mutating the original. The published `resources/js/registry.ts` already calls it for you — this is the pattern it uses: ```ts import { extendRegistry, registry as packageRegistry } from "@lattice-php/lattice"; import type { Plugin } from "@lattice-php/lattice"; export const registry = extendRegistry(packageRegistry, { name: "app", components: {}, extensions: { "table.columns": {}, }, } satisfies Plugin); ``` `packageRegistry` is Lattice's built-in registry. Pass the extended `registry` to `Provider`. Call `extendRegistry` again yourself only if you keep additional plugins in their own files. The built-in `registry` is a single flat map of eager components; the few heavy ones (the rich editor, chart, and date inputs) code-split their dependency from inside the component, so you never choose between an eager and a lazy variant. See [Bundle size](/advanced/bundle-size/) for the details. ### createRegistry Creates a registry from scratch (no built-ins). Only use this if you want to replace the entire built-in component set: ```ts import { createRegistry } from "@lattice-php/lattice"; const minimalRegistry = createRegistry(appPlugin); ``` ### eagerComponent / lazyComponent Components can be registered eagerly (imported at module load time) or lazily (code-split on first render): ```ts import { eagerComponent, lazyComponent } from "@lattice-php/lattice"; import type { Plugin } from "@lattice-php/lattice"; import { RatingComponent } from "./components/rating"; export const appPlugin = { name: "app", components: { // Eager — bundled with the entry point. rating: eagerComponent(RatingComponent), // Lazy — splits into a separate chunk loaded on demand. "field.color-picker": lazyComponent(async () => ({ default: (await import("./fields/color-picker")).ColorPickerComponent, })), }, } satisfies Plugin; ``` ### Provider and the registry `Provider` supplies the registry to every Lattice component below it in the tree: ```tsx import { Provider } from "@lattice-php/lattice"; createRoot(el).render( , ); ``` A custom renderer receives its already-rendered child nodes as `children`. When you need the active component registry directly, use `useComponentRegistry`: ```ts import { useComponentRegistry } from "@lattice-php/lattice"; const components = useComponentRegistry(); ``` ## Column-cell registry API The column-cell registry maps type strings to `ColumnCellComponent` functions. ### Column plugins Column cell renderers use the same plugin object as components. They go in the named `table.columns` extension registry in `resources/js/registry.ts` (registered bare — `columnCell()` is optional, see below): ```ts import { extendRegistry, registry as packageRegistry } from "@lattice-php/lattice"; import type { Plugin } from "@lattice-php/lattice"; import { StatusBadgeCell } from "./columns/status-badge"; export const registry = extendRegistry(packageRegistry, { name: "app", components: {}, extensions: { "table.columns": { "column.status-badge": StatusBadgeCell, }, }, } satisfies Plugin); ``` The same exported `registry` carries both your components and your column cells — there is no second registry to merge. ### useColumnRegistry Returns the current column registry from inside any component rendered by Lattice: ```ts import { useColumnRegistry } from "@lattice-php/lattice"; const columnRegistry = useColumnRegistry(); ``` ## TypeScript augmentation ### The augmentable interfaces `@lattice-php/core` exports the shared wire-type interfaces: - `ComponentProps` — maps a type string to its props shape for fields and UI components. - `ColumnProps` — maps a type string to its props shape for column cells. - `FilterProps` — maps a filter control to its props shape. - `EffectProps` — maps an effect type to its props shape. `EditorExtensionProps` remains in `@lattice-php/lattice` because it belongs to the rich editor. All of them use TypeScript's declaration merging. You can augment them manually or let `lattice:typescript` do it. ### php artisan lattice:typescript Run this command whenever your PHP classes gain or lose public properties. It discovers `#[AsComponent]` components, `#[AsColumn]` columns, `#[AsFilter]` filters, `#[AsEffect]` effects, and `#[AsEditorExtension]` rich-editor extensions: ```bash php artisan lattice:typescript ``` It scans the paths listed under `discover` in `config/lattice.php`: ```php // config/lattice.php 'discover' => [ base_path('app'), ], ``` And writes an augmentation file to the path configured under `typescript.output` (default: `resources/js/lattice/generated.d.ts`): ```ts // This file is generated by `php artisan lattice:typescript`. Do not edit. declare module "@lattice-php/core" { interface ComponentProps { "field.color-picker": { swatches: string | null; }; } interface ColumnProps { "column.status-badge": { colorMap: Record | null; }; } } export {}; ``` Without this file, `node.props` and `column.props` fall back to `Record`. The renderers still work — types are just not narrowed. See [Artisan commands](/core/artisan-commands/) for the full command reference. ### Augmenting manually If you prefer not to run the generator, augment the interfaces directly in any `.d.ts` file included in your `tsconfig.json`: ```ts import "@lattice-php/core"; declare module "@lattice-php/core" { interface ComponentProps { "field.color-picker": { swatches: string | null; }; } } ``` # Conditional fields > Show, require, or disable fields based on other fields, and compute values from the form data. import ComponentExample from "@components/ComponentExample.astro"; import Info from "@components/Info.astro"; Fields can react to the rest of the form. A condition compares another field's value and toggles this field's visibility, required, read-only, or disabled state. Conditions are evaluated on the client as the user types, and the same rules are re-checked on the [server](/advanced/security/), so they cannot be bypassed. Each method names the field to watch, an optional operator, and the value to compare against. Open the **Tree** tab on the examples below to see the `conditions` that are serialized to the client. ## Visible when `->visibleWhen()` hides the field until the condition matches. Switch the account type below to **Individual** and the VAT ID field disappears. options([ Choice::option('Business', 'business'), Choice::option('Individual', 'individual'), ]); TextInput::make('vat', 'VAT ID') ->visibleWhen('type', 'business')`} fixture="field.visible-when" values={{ type: "business", vat: "" }} /> ## Required when `->requiredWhen()` makes the field required only while the condition matches. With **Germany** selected the VAT ID is required; pick another country and it becomes optional. options([ Choice::option('Germany', 'DE'), Choice::option('Austria', 'AT'), Choice::option('United States', 'US'), ]); TextInput::make('vat', 'VAT ID') ->requiredWhen('country', 'DE')`} fixture="field.required-when" values={{ country: "DE", vat: "" }} /> ## Read-only and disabled when `->readOnlyWhen()` and `->disabledWhen()` toggle the read-only and disabled states the same way. ```php TextInput::make('coupon', 'Coupon') ->readOnlyWhen('plan', 'enterprise'); TextInput::make('coupon', 'Coupon') ->disabledWhen('billing', 'invoice'); ``` ## Operators With two arguments, the condition checks equality. Pass an array to match any of several values, or pass an operator as the middle argument for other comparisons. ```php TextInput::make('vat', 'VAT ID') ->visibleWhen('country', ['DE', 'AT', 'CH']); TextInput::make('guardian', 'Guardian name') ->requiredWhen('age', '<', 18); ``` The operator accepts a comparison string or an [`Op`](/advanced/enums/#operators) case (`Lattice\Core\Enums\Op`): | Condition | String | `Op` case | | ---------- | -------------------------------------- | ------------------------------------------------ | | equal | `=`, `==` | `Op::Equals` | | not equal | `!=`, `<>` | `Op::NotEquals` | | greater | `>`, `>=` | `Op::GreaterThan`, `Op::GreaterThanOrEqual` | | less | `<`, `<=` | `Op::LessThan`, `Op::LessThanOrEqual` | | substring | `contains`, `starts_with`, `ends_with` | `Op::Contains`, `Op::StartsWith`, `Op::EndsWith` | | membership | `in`, `not_in` | `Op::In`, `Op::NotIn` | | presence | — (use the `Op` case) | `Op::Empty`, `Op::Filled` | | dates | `before`, `after` | `Op::Before`, `Op::After` | For presence checks, pass the `Op` case — `->visibleWhen('coupon', Op::Filled)`. A bare `'empty'` or `'filled'` string with no third argument is treated as a value to compare against, not an operator. ## Server and client agreement Conditions are evaluated twice: on the client as the user types, and again on the server so they cannot be bypassed. For that to be safe, both sides must coerce values identically. The contract is: - **Equality against a boolean** coerces the value the way PHP's `filter_var(…, FILTER_VALIDATE_BOOLEAN)` does — `1`, `true`, `on`, and `yes` are true, anything else is false. Against a non-boolean, values compare as strings. - **Numeric comparisons** (`>`, `>=`, `<`, `<=`) coerce both sides to numbers, so use them on numeric fields. - **`contains` / `starts_with` / `ends_with`** compare as strings. - **`empty` / `filled`** treat `null` and the empty string as empty. - **`before` / `after`** parse both sides as dates; a value that cannot be parsed never matches. Both evaluators are tested against one shared truth table, so the server and the client cannot drift apart. ## Computed values A field's value can be derived from the form data instead of typed. Pass a closure to `->value()` to compute it from the current `FormData`. The closure runs on the server whenever the inputs change. Closure parameters use [Lattice's closure evaluation](/core/closure-evaluation/) utilities. ```php TextInput::make('total', 'Total') ->value(fn (FormData $data) => $data->float('qty') * $data->float('price')); ``` A computed value is **locked** by default: the closure result overwrites whatever the user typed on every resolve and on submit, so the field reads as derived. `->dependsOn()` reacts to other fields changing, receiving the field itself in the closure. It can change anything about the field — its options, props, visibility, or live value via `$field->value(...)` — but it never locks the field the way `->value(Closure)` does: a value set from inside a `dependsOn` callback ships to the client on the next resolve so the UI stays in sync, but a user who then types into the field wins on submit. ```php TextInput::make('total', 'Total') ->dependsOn( ['qty', 'price'], fn (TextInput $field, FormData $data) => $field->value($data->float('qty') * $data->float('price')), ); ``` To make a computed value that reacts to specific fields **and** stays authoritative, use `->value()` alone — it already re-evaluates on every resolve, so naming dependencies is only needed for `dependsOn`'s other effects (changing options, visibility, or other props). ## Editable defaults Pass `editable: true` to compute a **suggestion** instead — the value is applied as a default the user can then override by typing. Once a field is manually edited it stops tracking the computed value. ```php TextInput::make('price', 'Price') ->value(fn (FormData $data) => priceFor($data->get('product')), editable: true); ``` Two lists control when the suggestion re-applies: - `resetOn` — naming a field here **clears a manual override** when that field changes, so the suggestion takes over again. - `refreshOn` — naming a field here **recomputes** the suggestion when that field changes, but only while the user has not overridden it. ```php TextInput::make('price', 'Price') ->value(fn (FormData $data) => priceFor($data->get('product')), editable: true, resetOn: ['product']); ``` Inside a [repeater or builder](/forms/fields/repeater/) row, a bare dependency name is row-relative; prefix it with `@` (e.g. `@customer`) to watch a form-level field instead. ### Deciding nothing Returning `null` is a decision: it clears the field. When a resolver has nothing to suggest — no product picked yet, no price on file — return `Resolution::Keep` instead and the field keeps whatever is in it, including what the user typed. ```php use Lattice\Form\Resolution; TextInput::make('price', 'Price') ->value( fn (FormData $data) => priceFor($data->get('product')) ?? Resolution::Keep, editable: true, resetOn: ['product'], ); ``` `Resolution::Keep` works the same way in an authoritative `->value()` resolver: the field keeps its submitted value and stays non-authoritative for that pass, so validation does not overwrite it. # Builder > A polymorphic repeatable field — typed rows built from consumer-defined row templates, each with its own schema. import ComponentExample from "@components/ComponentExample.astro"; The builder renders a repeatable list of rows where each row can be a different type. You define the available row templates up front, and every template carries its own schema of fields. A row records which template it uses through an automatic hidden `type` discriminator, so the field submits an array of row objects — each tagged with its `type` and keyed by that template's field names. Create one with `Builder::make()` and list the templates with `->templates()`. templates([ RowTemplate::make('text')->label('Text')->schema([ Textarea::make('content', 'Content')->required(), ]), RowTemplate::make('product')->label('Product line')->schema([ TextInput::make('product', 'Product')->required(), TextInput::make('qty', 'Qty')->rules(['numeric']), TextInput::make('price', 'Price')->rules(['numeric']), ]), ]) ->minItems(1) ->addLabel('Add block')`} fixture="builder.basic" values={{ items: [] }} /> ## Row templates `->templates()` takes the list of row types the builder offers. Each is a `RowTemplate::make($type)` where `$type` is the discriminator stored on every row of that kind. Give it a human label with `->label()` and its row schema with `->schema()`; the schema is a normal array of fields, so each child keeps its own label, placeholder, default value, and rules. ```php RowTemplate::make('product') ->label('Product line') ->schema([ TextInput::make('product', 'Product')->required(), TextInput::make('qty', 'Qty')->rules(['numeric']), ]); ``` The example above submits as an array of typed rows: ```php ['items' => [ ['type' => 'text', 'rowId' => '9f3c…', 'content' => 'Thanks for your order.'], ['type' => 'product', 'rowId' => 'a2b1…', 'product' => 'Widget', 'qty' => '3', 'price' => '9.99'], ]] ``` Every row carries a stable UUID under the reserved `rowId` key: rows filled from stored data keep theirs, new rows get one when they are added, and it survives validation into your `handle()` data — so rows stay identifiable across saves (`collect($data['items'])->keyBy('rowId')` gives a map view). Row schemas must not declare their own `rowId` field. ## Adding rows Each builder row is inserted through an add menu listing every template by its label. Picking one appends an empty row of that type. `->addLabel()` sets the text on the button that opens the menu (default "Add"). ```php Builder::make('items', 'Line items') ->templates([RowTemplate::make('text')->schema([Textarea::make('content')])]) ->addLabel('Add block'); ``` ## Row counts `->minItems()` and `->maxItems()` bound how many rows the form accepts; both are enforced as array-level validation rules independent of which template each row uses. ```php Builder::make('items', 'Line items') ->templates([RowTemplate::make('text')->schema([Textarea::make('content')])]) ->minItems(1) ->maxItems(20); ``` ## Reordering Rows are reorderable by default through up/down controls. Pass `->reorderable(false)` to fix the order. ```php Builder::make('items', 'Line items') ->templates([RowTemplate::make('text')->schema([Textarea::make('content')])]) ->reorderable(false); ``` ## Table layout Rows stack by default — each row is a full block of stacked fields. Call `->table()` to lay rows out as a table instead. The columns come from the first template's schema, a shared header of those field labels sits above the rows, and each cell renders the input alone (the field's own label moves up into the header). A row of any other type spans the full width below the header rather than fitting the columns. ```php Builder::make('items', 'Line items') ->templates([ RowTemplate::make('product')->label('Product line')->schema([ TextInput::make('product', 'Product'), TextInput::make('qty', 'Qty')->rules(['numeric']), ]), RowTemplate::make('text')->label('Text')->schema([ Textarea::make('content', 'Content'), ]), ]) ->table(); ``` Reorder controls (↑↓) sit on the left of each row and the row actions on the right; once a row carries more than one action they collapse into a ⋯ menu. Reordering slides each row to its new position, and the table scrolls horizontally on narrow screens. The slide animation is skipped when the viewer prefers reduced motion. ## Row actions Like the repeater, the builder takes `->rowActions([RowAction::duplicate(), RowAction::remove()])` to declare the per-row menu — the built-in **Duplicate** and **Remove** actions, customisable with `->label()`/`->icon()`/`->danger()`. See [Repeater → Row actions](/forms/fields/repeater/#row-actions) for the full rundown. ## Validation Each row is validated against the schema of its own template: a child marked `->required()` is required only in rows of that type, and an error points at the offending row. The hidden `type` discriminator is validated too — a row whose `type` is not one of the declared templates is rejected. The array-level `minItems`/`maxItems` rules validate the number of rows independently of the per-row rules. See [Validation](/forms/validation/) for how field rules are resolved. ## Common options `Builder` shares label, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Checkbox > A single on/off toggle for boolean input. import ComponentExample from "@components/ComponentExample.astro"; The checkbox captures a single boolean. The label sits next to the box and describes what checking it means. Create one with `Checkbox::make()`. ## Requiring consent A checkbox is often used for a terms-of-service agreement that must be ticked. Use Laravel's `accepted` rule rather than `required`, which only checks that the field is present: ```php Checkbox::make('terms', 'I accept the terms and conditions') ->rules(['accepted']); ``` ## Common options `Checkbox` shares label, default value, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Checkbox group > A visible list of checkboxes for picking several values at once, optionally split into sections. import ComponentExample from "@components/ComponentExample.astro"; A checkbox group shows every option at once and submits the checked ones as an array. Reach for it over a [multiple Select](/forms/fields/select/) when the reader should see the whole catalog — permissions, API scopes, notification channels — rather than open a dropdown. Create one with `CheckboxGroup::make()` and pass the options with `->options()`. options([ CheckboxGroup::option('Product updates', 'product'), CheckboxGroup::option('Security alerts', 'security'), CheckboxGroup::option('Weekly digest', 'digest'), ])`} fixture="checkbox-group.basic" values={{ notifications: [] }} /> The field submits a list of the checked values (`['product', 'digest']`). Nothing checked submits an empty array, so `handle()` always receives an array. Values are constrained to the configured options, the way a [Choice](/forms/fields/choice/) constrains its single value — a submit carrying an unknown value fails validation instead of reaching your handler. ## Options from an enum `->enum()` builds the options from a backed enum. Pass the enum class for every case, or an array of cases for a subset. ```php CheckboxGroup::make('channels', 'Channels')->enum(Channel::class); ``` ## Descriptions and tooltips An option carries more than its label: `description` renders a muted second line beneath it, and `tooltip` adds an info popover next to it. Both are optional and read well as named arguments. ```php CheckboxGroup::option( 'Manage orders', 'order:manage', description: 'Create, edit, and cancel orders.', tooltip: 'Includes issuing refunds.', ); ``` ## Sections Options that carry a `group` label are bucketed into sections in first-seen order; ungrouped options stay above them. `->collapsible()` turns each section into a collapsible panel — pass `collapsed: true` to start them all closed, which keeps a long catalog scannable. columns(2) ->bulkToggleable() ->collapsible() ->options([ CheckboxGroup::option('View orders', 'order:view', description: 'order:view', group: 'Sales'), CheckboxGroup::option('Manage orders', 'order:manage', description: 'order:manage', group: 'Sales'), CheckboxGroup::option('View invoices', 'invoice:view', description: 'invoice:view', group: 'Accounting'), CheckboxGroup::option('Manage invoices', 'invoice:manage', description: 'invoice:manage', group: 'Accounting'), ])`} fixture="checkbox-group.groups" values={{ permissions: ["order:view"] }} /> ## Columns `->columns()` lays the checkboxes out in a grid. A bare count applies from the `md` breakpoint up and falls back to one column below it; a map sets each breakpoint explicitly. ```php CheckboxGroup::make('permissions')->columns(['default' => 1, 'md' => 2, 'xl' => 3]); ``` ## Selecting everything `->bulkToggleable()` adds a select-all checkbox above the list, and one per section. Each reflects what is currently checked below it: filled when everything is, mixed when only some of it is. ## Common options `CheckboxGroup` shares label, default value, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). `->required()` demands at least one checked box. For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Choice > A set of inline options for picking one value, rendered as radio-style controls. import ComponentExample from "@components/ComponentExample.astro"; Choice presents a small set of options inline, so every option is visible at once. Use it instead of a [Select](/forms/fields/select/) when there are only a handful of choices. Create one with `Choice::make()` and pass the options with `->options()`. options([ Choice::option('Free', 'free'), Choice::option('Pro', 'pro'), Choice::option('Enterprise', 'enterprise'), ])`} fixture="choice.basic" values={{ plan: "free" }} /> ## Options from an enum `->enum()` builds the options from a backed enum. Pass the enum class for every case, or an array of cases for a subset. Labels come from the enum's `HasLabel` contract when it implements one, otherwise the case name is humanized. ```php Choice::make('plan', 'Plan')->enum(Plan::class); ``` ## Common options `Choice` shares label, default value, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Color picker > Palette swatches plus free color selection, stored as a hex string. import ComponentExample from "@components/ComponentExample.astro"; import Info from "@components/Info.astro"; `ColorPicker::make($name, $label)` renders a compact trigger — a color dot plus the current hex value — that opens a picker in a popover: a saturation/hue area for free colors, a swatch palette, and a hex input. The value is a lowercase hex string like `#3b82f6`. placeholder('Pick a color') ->rules(['nullable', 'hex_color']);`} fixture="color-picker.basic" values={{ color: "#3b82f6" }} /> ## Palette The field ships a nine-color default palette. `->palette([...])` replaces it with your own swatches: ```php ColorPicker::make('color', 'Brand color') ->palette(['#0ea5e9', '#6366f1', '#f43f5e']); ``` Swatches preselect a color with one click; the saturation area and hex input remain available for any other color. ## Validation The submitted value is a plain string, so validate it like one — Laravel's `hex_color` rule matches exactly what the picker produces: ```php ColorPicker::make('color', 'Tag color')->rules(['required', 'hex_color']); ``` The client normalizes everything it commits to lowercase `#rrggbb` — three-digit input like `#f53` is expanded, and invalid text is never committed. ## Common options `ColorPicker` shares label, default value, required, disabled, read-only, and visibility options with every field — see [fields overview](/forms/fields/overview/). # Date input > A date picker with optional minimum and maximum bounds. import ComponentExample from "@components/ComponentExample.astro"; The date input collects a calendar date and returns a `Y-m-d` string. Create one with `DateInput::make()`. max('2026-01-01')`} fixture="date-input.basic" values={{ birthday: "" }} /> ## Minimum and maximum `->min()` and `->max()` bound the selectable range. Pass an ISO `Y-m-d` date string. They constrain the picker in the browser; pair them with `date` validation rules to enforce the range on the server. ```php DateInput::make('starts_at', 'Start date') ->min('2026-01-01') ->max('2026-12-31') ->rules(['date', 'after_or_equal:2026-01-01']); ``` ## Common options `DateInput` shares label, default value, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Date time input > A timezone-aware date and time picker. import ComponentExample from "@components/ComponentExample.astro"; The date time input collects a calendar date and time in the active frontend timezone. Validation returns a `CarbonImmutable`. ## Timezone behavior The picker uses the authenticated user's timezone when the user model implements `HasTimezonePreference` (`preferredTimezone()`), falling back to the browser timezone. Submitted values include that timezone, so validation returns a `CarbonImmutable` in the user's timezone by default. ```php DateTimeInput::make('starts_at', 'Starts at'); ``` To store the value in the app timezone, opt in: ```php DateTimeInput::make('starts_at', 'Starts at') ->convertTimeZone(); ``` To convert to a specific timezone, pass it explicitly: ```php DateTimeInput::make('starts_at', 'Starts at') ->convertTimeZone('UTC'); ``` ## Common options `DateTimeInput` shares label, default value, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # File upload > Upload files and images to a filesystem disk, with optional direct-to-S3 signed uploads. import ComponentExample from "@components/ComponentExample.astro"; import Info from "@components/Info.astro"; import Warning from "@components/Warning.astro"; The file upload field submits the chosen files with the form — `handle()` receives `UploadedFile` instances for your code to store — or, in signed mode, uploads them straight to storage and submits temporary keys. It supports single or multiple files, image-only mode, size and count limits, and direct-to-storage signed uploads. Create one with `FileUpload::make()`. image() ->maxSize(2048)`} fixture="file-upload.basic" values={{ avatar: null }} /> ## Disk `->disk()` chooses the filesystem disk the field reads and writes. Without it the field uses the `lattice.files.disk` config value (defaulting to `public`). ```php FileUpload::make('document')->disk('s3'); ``` ## Images `->image()` restricts the picker to images and renders an image-aware preview. It also defaults the accepted types to `image/*` unless you set them explicitly. ```php FileUpload::make('avatar')->image(); ``` ## Accepted types and size `->acceptedFileTypes()` takes a list of MIME types or extensions for the file picker's `accept` attribute. `->maxSize()` caps each file's size in **kilobytes**. ```php FileUpload::make('attachment') ->acceptedFileTypes(['application/pdf', 'image/png']) ->maxSize(5120); ``` ## Multiple files `->multiple()` lets the field accept more than one file; it then submits an array of paths. `->maxFiles()` caps how many the form accepts. ```php FileUpload::make('gallery', 'Gallery') ->image() ->multiple() ->maxFiles(8); ``` ## Signed (direct-to-storage) uploads By default files are uploaded through your form endpoint. `->signedUpload()` switches to **direct uploads**: the client asks the form endpoint for a short-lived signed URL, uploads straight to the disk (for example S3), and submits the resulting object key. This keeps large files off your PHP process. The disk must support temporary upload URLs. ```php FileUpload::make('document') ->disk('s3') ->signedUpload(); ``` Signed-URL lifetime comes from `lattice.files.url_ttl` (minutes, default `5`) and temporary objects are keyed under `lattice.files.temp_prefix` (default `tmp`). ## Reading the value in `handle()` A submitted file arrives as an `UploadedFile` (or an array of them for `->multiple()`); store it however you like: ```php public function handle(FormData $data): Response { if (($data['avatar'] ?? null) instanceof UploadedFile) { $data['avatar'] = $data['avatar']->store('avatars', 'public'); } // persist $data['avatar'] ... } ``` ### Finalizing a signed upload A signed upload arrives as a **temporary** object key under `lattice.files.temp_prefix`, not the final path — temporary objects are cleaned up, so you must promote the key out of the temp prefix before persisting it. Call `finalizeSignedUploads()` on the same field, passing the submitted keys and a closure that returns each file's destination path. It moves the object and returns the finalized `disk`, `path`, `name`, `mime_type`, and `size` for each file: ```php $finalized = FileUpload::make('document')->disk('s3')->signedUpload() ->finalizeSignedUploads( (array) $data['document'], fn (string $key, array $meta): string => "documents/".auth()->id().".{$meta['extension']}", ); foreach ($finalized as $upload) { // $upload['disk'], $upload['path'], $upload['name'], $upload['mime_type'], $upload['size'] } ``` `finalizeSignedUploads()` throws if the destination stays inside the temporary prefix — return a path outside `lattice.files.temp_prefix`. Storing the raw temporary key would persist a file that gets swept away. ## Removing existing files When a form is bound to a record, stored paths render as existing files the user can remove. Each existing file carries a **sealed token**, and the client posts the tokens of removed files under `{name}__removed[]`. Resolve them server-side with the static `FileUpload::removed()` helper, which unseals each token (skipping any forged, expired, or mismatched one) and returns the trusted disk paths to delete: ```php foreach (FileUpload::removed($request, 'avatar') as $path) { Storage::disk('public')->delete($path); } ``` `FileUpload::removed()` only returns paths whose token the server itself sealed for this field, so a client cannot forge a path to delete. Always delete through it rather than trusting raw request input. See [Security](/advanced/security/) for how sealed references work. ## Common options `FileUpload` shares label, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Hidden input > A field that carries a value with the form without rendering a visible control. The hidden input submits a value with the form without showing anything to the user. Use it to carry a fixed value — a source tag, a tenant id, a redirect target — through the request. Create one with `HiddenInput::make()` and set the value with `->value()`. ```php HiddenInput::make('source')->value('marketing-site'); ``` Because it renders no control, a hidden input has no live preview. It still serializes into the form schema and submits like any other field. :::caution A hidden input is part of the request payload, so a user can read and change its value. Never trust it for authorization or anything security-sensitive — validate it on the server like any other input. ::: ## Common options `HiddenInput` shares the base field options — see [Fields](/forms/fields/overview/). It is most often paired with `->value()`. For validation, see [Validation](/forms/validation/). # Number input > A numeric field with optional min, max, and step, or a slider variant. import ComponentExample from "@components/ComponentExample.astro"; The number input collects a numeric value. Create one with `NumberInput::make()`. min(0) ->max(120)`} fixture="number-input.basic" values={{ age: "" }} /> ## Min, max, and step `->min()` and `->max()` bound the value; `->step()` sets the increment used by the spinner controls and accepts decimals for fractional values such as prices. These constrain the browser control; add matching validation rules to enforce them on the server. ```php NumberInput::make('unit_price', 'Unit price') ->min(0) ->step(0.01) ->rules(['numeric', 'min:0']); ``` ## Slider `->slider()` renders the field as a range slider instead of a spinner. Pair it with `->min()` and `->max()` so the track has defined bounds. slider() ->min(0) ->max(10)`} fixture="number-input.slider" values={{ satisfaction: 5 }} /> ## Affixes `->prefix()` and `->suffix()` add a currency symbol, unit, or icon to the input. See [affixes on Text input](/forms/fields/text-input/#affixes) for the full rules. Affixes apply to the spinner variant only — they are not rendered in [slider](#slider) mode. ```php NumberInput::make('price', 'Price')->prefix('$')->suffix('USD'); ``` ## Common options `NumberInput` shares label, default value, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Fields > The common options shared by every form field — label, default value, required, disabled, read-only, and visibility. import ComponentExample from "@components/ComponentExample.astro"; Fields are the building blocks of a form. Lattice ships [Text input](/forms/fields/text-input/), [Textarea](/forms/fields/textarea/), [Select](/forms/fields/select/), [Choice](/forms/fields/choice/), [Checkbox](/forms/fields/checkbox/), [Checkbox group](/forms/fields/checkbox-group/), [Toggle](/forms/fields/toggle/), [Date input](/forms/fields/date-input/), [Time input](/forms/fields/time-input/), [Date time input](/forms/fields/date-time-input/), [Number input](/forms/fields/number-input/), [Password input](/forms/fields/password-input/), [Hidden input](/forms/fields/hidden-input/), [File upload](/forms/fields/file-upload/), [Rich editor](/forms/fields/rich-editor/), [Repeater](/forms/fields/repeater/), and [Builder](/forms/fields/builder/) — and you can add your own. Each field type has its own page for the options unique to it; this page covers the options every field shares. The submit button is not a field: the form renders it for you from `->submitLabel()`. See [Overview](/forms/overview/) for how to customize or replace it. For validation and conditional behavior — which also apply to every field — see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). The options below extend the base `Field` class, so they work the same on every field. The examples use `TextInput`, but the methods are identical everywhere. ## Label `make()` takes the field name and an optional label. Pass the label as the second argument, or set it later with `->label()`. Omit it and the field renders without a visible label. ```php TextInput::make('name', 'Team name'); TextInput::make('name')->label('Team name'); ``` ## Default value `->value()` seeds the field with a starting value. On a form bound to a record, the record's value wins; otherwise this default is used. value('Acme Inc')`} fixture="field.default-value" values={{ name: "Acme Inc" }} /> ## Helper text `->helperText()` adds a line of descriptive text beneath the field; `->hint()` is an alias that reads more naturally for short tips. It renders muted, below the input and above any validation message. helperText('Used in the public URL for your team.')`} fixture="field.helper-text" values={{ slug: "" }} /> ## Label action `->labelAction()` renders a component at the end of the field's label row — typically a link, such as a "Forgot password?" link beside a password field: ```php use Lattice\Ui\Components\Link; PasswordInput::make('password', 'Password') ->labelAction(Link::make('Forgot password?')->href(route('password.request'))); ``` Any component works in the slot, so a badge or a button carrying an action fits the same way. Fields without a label row — `HiddenInput`, `Checkbox`, and fields rendered inside a repeater's table layout — drop the label action. ## Required `->required()` marks the field as required, adding the indicator and a `required` validation rule. required()`} fixture="field.required" values={{ name: "" }} /> ## Disabled `->disabled()` renders the field non-interactive. A disabled field is not submitted with the form. value('Acme Inc') ->disabled()`} fixture="field.disabled" values={{ name: "Acme Inc" }} /> ## Read-only `->readOnly()` shows the value but prevents editing. Like a disabled field, a read-only field's typed value is not trusted on submit — Lattice drops user input for locked fields and only submits a server-set `->value()` (as in the example below). value('acme-inc') ->readOnly()`} fixture="field.read-only" values={{ slug: "acme-inc" }} /> ## Hidden and visible `->hidden()` drops the field from the rendered form entirely — it is absent from the payload and its validation is skipped, exactly like every other Lattice component's shared render gate. `->visible()` is the inverse and reads more naturally when toggling on a condition. Both accept a boolean or a closure resolved once per request with [Lattice's closure evaluation](/core/closure-evaluation/) utilities. ```php TextInput::make('internal_note', 'Internal note')->hidden(); TextInput::make('internal_note', 'Internal note')->visible($user->isAdmin()); TextInput::make('internal_note', 'Internal note')->visible(fn ($user) => $user?->isAdmin() ?? false); ``` This is a one-time server decision, not a reactive one — for a field that should show or hide as the user fills in the rest of the form, use [`->visibleWhen()`](/forms/conditional-fields/#visible-when) instead. When the reason is permission rather than state, reach for `->can()` instead of a `->visible()` closure. It takes the same abilities as a definition's `can` attribute argument, and `->visible()` cannot widen it: ```php TextInput::make('salary', 'Salary')->can('payroll.view'); ``` See [Authorization](/core/authorization/) for how `can` behaves across pages, definitions, and components. To show or hide a field based on another field's value, see [Conditional fields](/forms/conditional-fields/). # Password input > A masked field for passwords, with optional confirmation and a label action. import ComponentExample from "@components/ComponentExample.astro"; The password input masks its value as the user types. Create one with `PasswordInput::make()`. placeholder('Your password')`} fixture="password-input.basic" values={{ password: "" }} /> ## Confirmation `->needsConfirmation()` adds a second field the user must re-type to confirm. It is named `_confirmation`, which matches Laravel's `confirmed` rule, so pair the two: needsConfirmation() ->rules(['required', 'min:8', 'confirmed'])`} fixture="password-input.confirmation" values={{ password: "", password_confirmation: "" }} /> Pass a custom label and placeholder to override the defaults: ```php PasswordInput::make('password', 'Password') ->needsConfirmation('Repeat password', 'Type it again'); ``` ## Label action `->labelAction()` renders a component next to the label, such as a "Forgot password?" link on a sign-in form. It is available on [every field](/forms/fields/overview/#label-action). ```php use Lattice\Ui\Components\Link; PasswordInput::make('password', 'Password') ->labelAction(Link::make('Forgot password?')->href(route('password.request'))); ``` ## Autocomplete `->autoComplete()` sets the HTML `autocomplete` attribute so password managers behave correctly. Use `current-password` on sign-in and `new-password` on registration or password-change forms. ```php PasswordInput::make('password', 'Password')->autoComplete('new-password'); ``` ## Affixes `->prefix()` and `->suffix()` add an icon or text adornment to the input — the affix sits outside the show/hide toggle. See [affixes on Text input](/forms/fields/text-input/#affixes) for the full rules. ```php PasswordInput::make('token', 'API token')->prefix(Affix::icon('key')); ``` ## Common options `PasswordInput` shares label, default value, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Pattern input > A pattern of free text and typed token chips, like a document-numbering template. import ComponentExample from "@components/ComponentExample.astro"; import Info from "@components/Info.astro"; The pattern input lets the user build a template out of free text and inline token chips — a document-numbering pattern is the typical case: literal text like `RE-` mixed with tokens like a sequential number, the year, or the month. Tokens are inserted from a menu, render as chips inline with the surrounding text, and — when a token declares its own config schema — are clickable to configure. Create one with `PatternInput::make()` and list the available tokens with `->tokens()`. tokens([ PatternToken::make('NUMBER') ->label('Sequential number') ->configurable([ Choice::make('padding', 'Padding')->options([4 => '4', 5 => '5', 6 => '6'])->value(4), ]), PatternToken::make('YYYY')->label('Year (4-digit)'), PatternToken::make('MM')->label('Month'), ]) ->requiredTokens(['NUMBER'])`} fixture="pattern-input.basic" values={{ pattern: [ { type: "text", value: "RE-" }, { type: "token", token: "NUMBER", config: { padding: "4" } }, { type: "text", value: "-" }, { type: "token", token: "YYYY", config: {} }, ], }} /> ## Stored value The field submits an ordered array of segments — never a raw editor document. Each segment is either literal text or a placed token: ```php ['pattern' => [ ['type' => 'text', 'value' => 'RE-'], ['type' => 'token', 'token' => 'NUMBER', 'config' => ['padding' => '4']], ['type' => 'text', 'value' => '-'], ['type' => 'token', 'token' => 'YYYY', 'config' => []], ]] ``` Reading this array is application logic — the field itself has no opinion on how a pattern like this turns into an actual document number. On a [multiline](#multiline) field, line breaks appear as `\n` inside text segments; single-line fields reject values containing `\n`. On the wire the client submits the segments as one JSON-encoded string — deliberately, so Laravel's `TrimStrings` middleware cannot strip leading/trailing whitespace (or multiline `\n` boundaries) from individual text segments. Server-side casting always hands your `handle()` the decoded array, but a `->dependsOn()` closure sees the raw wire value — run it through `PatternSegments::decode()` before reading segments. ## Tokens `->tokens()` takes the list of token types the pattern offers. Each is a `PatternToken::make($name)` where `$name` is the value stored on every placed segment of that kind. Give it a human label with `->label()` (defaults to a title-cased version of the name) and, if it needs configuration, a schema of fields with `->configurable()` — a normal array of fields, exactly like a `Builder` row's schema: ```php PatternToken::make('NUMBER') ->label('Sequential number') ->configurable([ Choice::make('padding', 'Padding')->options([4 => '4', 5 => '5', 6 => '6'])->value(4), ]); ``` A token without `->configurable()` (like `YYYY` and `MM` above) renders as a plain chip with nothing to click. Each token type can only be placed once per pattern — the insert menu hides a token once it's already in use. The field ships with no token types of its own. `NUMBER`, `YYYY`, and `MM` above are just names — give them whatever meaning your application needs; the field only enforces that a placed token is one you declared, and validates its `config` against the schema you gave it. ## Required tokens `->requiredTokens()` lists token names that must appear somewhere in the pattern for it to be valid: ```php PatternInput::make('pattern') ->tokens([PatternToken::make('NUMBER'), PatternToken::make('YYYY')]) ->requiredTokens(['NUMBER']); ``` A submitted pattern missing a required token fails validation, alongside an unknown token name or the same token placed twice. ## Separator `->separator()` sets the text inserted between a newly-placed chip and whatever's already there when a token is added from the menu — a convenience for the common case of dash- or slash-separated patterns, not a stored or validated part of the value: ```php PatternInput::make('pattern')->separator('-'); ``` ## Multiline `->multiline()` lets Enter start a new line — the pattern becomes a small block of text with chips, for content like an address footer built from variables. Line breaks are stored as `\n` inside text segments, so the wire format is unchanged. `->rows()` sets the minimum visible height in text rows (defaults to 3): ```php PatternInput::make('footer') ->tokens([...]) ->multiline() ->rows(5); ``` ## Common options `PatternInput` shares label, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Repeater > A repeatable nested schema — rows of fields that submit as an array of row objects. import ComponentExample from "@components/ComponentExample.astro"; The repeater renders a repeatable group of fields. Each row is its own copy of the schema, and the field submits an array of row objects — one object per row, keyed by the child field names. Create one with `Repeater::make()` and define the row template with `->schema()`. schema([ TextInput::make('name', 'Name')->required(), TextInput::make('qty', 'Qty')->rules(['numeric']), ]) ->minItems(1) ->maxItems(5) ->reorderable() ->addLabel('Add line') ->defaultItems(1)`} fixture="repeater.basic" values={{ items: [] }} /> ## Schema `->schema()` takes the array of fields that make up a single row. Each child is a normal field, so it keeps its own label, placeholder, default value, and rules. The example above submits as: ```php ['items' => [ ['rowId' => '9f3c…', 'name' => 'Widget', 'qty' => '3'], ['rowId' => 'a2b1…', 'name' => 'Gadget', 'qty' => '1'], ]] ``` Every row carries a stable UUID under the reserved `rowId` key: rows filled from stored data keep theirs, new rows get one when they are added, and it survives validation into your `handle()` data. Row schemas must not declare their own `rowId` field. ## Row counts `->minItems()` and `->maxItems()` bound how many rows the form accepts; both are enforced as array-level validation rules. `->defaultItems()` sets how many empty rows a fresh form starts with (default `1`). ```php Repeater::make('items', 'Line items') ->schema([TextInput::make('name', 'Name')]) ->minItems(1) ->maxItems(5) ->defaultItems(2); ``` ## Reordering Rows are reorderable by default through up/down controls. Pass `->reorderable(false)` to fix the order. ```php Repeater::make('items', 'Line items') ->schema([TextInput::make('name', 'Name')]) ->reorderable(false); ``` ## Table layout Rows stack by default — each row is a full block of stacked fields. Call `->table()` to lay rows out as a table instead: the schema fields become columns, a shared header of the field labels sits above the rows, and each cell renders the input alone (the field's own label moves up into the header). ```php Repeater::make('items', 'Line items') ->schema([ TextInput::make('name', 'Name'), TextInput::make('qty', 'Qty')->rules(['numeric']), ]) ->table(); ``` Reorder controls (↑↓) sit on the left of each row and the [row actions](#row-actions) on the right; once a row carries more than one action they collapse into a ⋯ menu. Reordering slides each row to its new position, and the table scrolls horizontally on narrow screens. The slide animation is skipped when the viewer prefers reduced motion. Call `->resizableColumns()` to let the user drag column borders to resize them; pass `showIndicator: true` to surface a drag handle on each border. ```php Repeater::make('items', 'Line items') ->schema([ TextInput::make('name', 'Name'), TextInput::make('qty', 'Qty')->rules(['numeric']), ]) ->table() ->resizableColumns(showIndicator: true); ``` ## Grid layout `->grid()` lays whole rows out side by side in a grid instead of stacking them — for rows that are columns of the final output, like the boxes of a document footer. Pass the column count (defaults to 2); rows wrap into the next line once a grid row is full: ```php Repeater::make('footer_boxes', 'Footer boxes') ->schema([ Textarea::make('content', 'Content'), ]) ->grid(2) ->maxItems(4); ``` ## Row actions Every row carries an action menu. By default it holds just **Remove** (hidden while the row is at `minItems`). Pass `->rowActions()` to declare the menu yourself, including the built-in **Duplicate** that clones a row in place. A single action renders inline; two or more collapse into a ⋯ menu. The same menu renders in both the stacked and table layouts — reorder controls stay separate. schema([ TextInput::make('name', 'Name'), TextInput::make('qty', 'Qty')->rules(['numeric']), ]) ->rowActions([ RowAction::duplicate(), RowAction::remove()->label('Delete'), ]);`} fixture="repeater.row-actions" values={{ items: [{ name: "Widget", qty: "3" }] }} /> `RowAction::duplicate()` and `RowAction::remove()` are the built-ins; chain `->label()`, `->icon()`, or `->danger()` to customise each. Remove is hidden automatically while the row sits at `minItems`. Pass an empty array — `->rowActions([])` — to drop the menu entirely. ## Labels `->addLabel()` sets the text on the button that appends a row (default "Add"). `->itemLabel()` sets a per-row heading shown above each row's fields. ```php Repeater::make('items', 'Line items') ->schema([TextInput::make('name', 'Name')]) ->addLabel('Add line') ->itemLabel('Line'); ``` ## Validation Each child field's rules are registered per row — `items.{index}.field` — so a child marked `->required()` is required in each row and an error points at the offending row. The array-level `minItems`/`maxItems` rules validate the number of rows independently of the per-row rules. See [Validation](/forms/validation/) for how field rules are resolved. A rule that names another field — `after`, `after_or_equal`, `before`, `before_or_equal`, `same`, `different`, `gt`, `gte`, `lt`, `lte`, `date_equals`, `required_if`, `required_unless`, `required_with`, `required_with_all`, `required_without`, `required_without_all`, `prohibited_if`, `prohibited_unless`, `exclude_if`, `exclude_unless`, `in_array`, `accepted_if`, or `declined_if` — resolves that bare reference against the **same row** when it matches a sibling field's name, exactly like row-scoped conditions. `->rules(['after_or_equal:valid_from'])` on a `valid_to` field compares against that row's own `valid_from`, and an error for row 1 lands on `items.1.valid_to`, never on a top-level field. ## Row-scoped conditions Conditional rules on a child field resolve against the **other fields in the same row** first, falling back to form-level values. So `visibleWhen`, `requiredWhen`, `readOnlyWhen`, and `disabledWhen` on a row field compare against its siblings, and each row evaluates independently — the same client-side and on the server. See [Conditional fields](/forms/conditional-fields/) for the condition DSL. ```php Repeater::make('items', 'Line items') ->schema([ Select::make('type', 'Type')->options(['Fixed' => 'fixed', 'Hourly' => 'hourly']), NumberInput::make('hours', 'Hours') ->visibleWhen('type', 'hourly') ->requiredWhen('type', 'hourly'), ]); ``` ## Common options `Repeater` shares label, required, disabled, read-only, and visibility options with every field — see [Fields](/forms/fields/overview/). For validation and conditional behavior, see [Validation](/forms/validation/) and [Conditional fields](/forms/conditional-fields/). # Rich editor > A formatted-text editor that stores a structured document, not raw HTML. import ComponentExample from "@components/ComponentExample.astro"; import Info from "@components/Info.astro"; import Warning from "@components/Warning.astro"; The rich editor lets the user write formatted text — headings, lists, links, tables, and more. It stores a structured TipTap document rather than raw HTML, which keeps the stored value safe to render. Create one with `RichEditor::make()`. placeholder('Write your article…')`} fixture="rich-editor.basic" values={{ article: "" }} /> ## Stored value The field submits a JSON document. Before it reaches `handle()`, Lattice decodes it and strips every node and mark the field's [active extensions](#extensions) don't allow — the client editor constrains what an honest user can produce, but the submitted JSON is client-controlled, so the server enforces the configured set the same way a `Choice` field validates its options. You receive a clean document array you can store as-is and render later by wrapping it in `RichContent`: ```php use Lattice\Form\RichContent; $html = RichContent::make($document)->toHtml(); ``` `toHtml()` validates the document against the editor's schema (unknown nodes are stripped) and sanitizes the output, so it is safe to render directly. `toText()` returns a plain-text version. ## Placeholder `->placeholder()` sets the muted hint shown while the editor is empty. ## Toolbar `->withoutToolbar()` hides the formatting toolbar. Combined with the [slash menu](#slash-menu-and-plus-button) this gives a clean, Notion-like writing surface where blocks are inserted through `/` and the plus button: ```php RichEditor::make('notes')->withoutToolbar(); ``` ## Extensions Which editor features are active — and their toolbar order — is controlled from PHP. Every feature is an extension class under `Lattice\Form\RichEditor\Extensions`; a field without configuration ships the full [default set](#default-extensions). `->extensions()` replaces it: extensions([ Bold::make(), Italic::make(), Heading::make()->levels(2, 3), Link::make()->protocols('https', 'mailto'), ])`} fixture="rich-editor.extensions" values={{ summary: "" }} /> The array order defines the toolbar order. `->withExtensions()` adds to the active set (or reconfigures an extension already in it, keeping its position), and `->withoutExtensions()` subtracts by class or wire type: ```php RichEditor::make('body') ->withExtensions(Heading::make()->levels(1, 2)) // reconfigure the default heading ->withoutExtensions(Details::class, 'emoji'); // drop by class-string or wire type ``` To change the default set for every editor in the app, register a resolver in a service provider: ```php RichEditor::defaultExtensionsUsing(fn (): array => [ Bold::make(), Italic::make(), Link::make(), ]); ``` The editor always keeps paragraphs, hard breaks, undo/redo and drag cursors active — extensions only control the optional features. The placeholder stays a field prop, not an extension. ### Default extensions | Extension | Wire type | Configuration | | ---------------- | ----------------- | --------------------------------------------------------------------------------------------- | | `Bold` | `bold` | — | | `Italic` | `italic` | — | | `Strike` | `strike` | — | | `Underline` | `underline` | — | | `Highlight` | `highlight` | — | | `Code` | `code` | — | | `Heading` | `heading` | `->levels(1, 2, 3)` — allowed levels, 1–6 (default all six) | | `BulletList` | `bullet-list` | — | | `OrderedList` | `ordered-list` | — | | `Blockquote` | `blockquote` | — | | `CodeBlock` | `code-block` | — | | `HorizontalRule` | `horizontal-rule` | — | | `TextAlign` | `text-align` | `->alignments('left', 'right')` — subset of left/center/right/justify (default all) | | `Link` | `link` | `->protocols('https', 'mailto')` (default http/https/mailto), `->openOnClick()` (default off) | | `Table` | `table` | Insert defaults: `->rows(3)`, `->cols(3)`, `->withHeaderRow()` | | `Details` | `details` | — | | `Emoji` | `emoji` | `->emojis('🍕', '🌮')` — the picker set (default 16 common emoji) | | `SlashMenu` | `slash-menu` | — | Configured extensions serialize their props onto the wire — `Heading::make()->levels(2, 3)` becomes `{"type": "heading", "props": {"levels": [2, 3]}}` — and the client reads them when it assembles the editor. ### Slash menu and plus button With the default set, typing `/` at the start of a line or after a space opens a block menu at the caret — type to filter, pick with the arrow keys and `Enter`, dismiss with `Escape` (the typed `/` stays in the text, like in Notion). On an empty line a plus button appears left of the caret and opens the same menu. The menu lists the block commands of the active extensions — headings at their configured levels, lists, blockquote, code block, horizontal rule, table and details. Remove the feature per field with `->withoutExtensions(SlashMenu::class)` or app-wide via `defaultExtensionsUsing()`. Extensions contribute their own entries through `commands` in their client definition: ```tsx mention: { commands: () => [ { icon: "at-sign", key: "mention", label: "Mention", keywords: ["person", "user"], run: (editor) => editor.chain().focus().insertContent("@").run(), }, ], }, ``` The menu deletes the typed `/query` before it invokes `run`, so a command inserts exactly what a toolbar button would. `key` doubles as the translation key (`form.editor.{key}`) and filtering matches the translated label, the `label` fallback, the `key` and the `keywords`. An optional `isAvailable: (editor) => boolean` hides a command where it can't run. ### Custom extensions An extension is a pair: a PHP class that declares the wire type (and any typed configuration), and a client definition that maps that type to [Tiptap](https://tiptap.dev) behavior and toolbar items. On the PHP side, extend `EditorExtension` and register the class in a service provider: ```php use Lattice\Form\RichEditor\Attributes\AsEditorExtension; use Lattice\Form\RichEditor\EditorExtension; use Lattice\Form\RichEditor\EditorExtensionRegistry; #[AsEditorExtension('mention')] class Mention extends EditorExtension { /** * @var list */ public array $triggers = ['@']; public function triggers(string ...$triggers): static { $this->triggers = array_values($triggers); return $this; } } // AppServiceProvider::boot() app(EditorExtensionRegistry::class)->register(Mention::class); ``` Public typed properties become the extension's `props` on the wire, exactly like a component. Once registered, `Mention::make()->triggers('@', '#')` works in `->extensions()`, and so does the plain string `'mention'` (it instantiates the class with its defaults). An extension that adds its own document nodes must also declare their schema type names via the protected `$serverTypes` property (e.g. `['mention']`) — submitted nodes of types no active extension declares are stripped server-side. Toolbar-only extensions that insert plain text, like the built-in emoji picker, don't need this. On the client, add a definition for the same wire type to the app plugin before boot: ```tsx import type { Plugin } from "@lattice-php/lattice"; import type { RichEditorExtensionRegistry } from "@lattice-php/form/rich-editor"; import { Mention } from "./tiptap/mention"; export const appPlugin = { name: "app", extensions: { "form.rich-editor": { mention: { extensions: (props) => [Mention.configure({ triggers: props.triggers ?? ["@"] })], toolbar: () => [ { icon: "at-sign", key: "mention", label: "Mention", isActive: (editor) => editor.isActive("mention"), run: (editor) => editor.chain().focus().insertContent("@").run(), }, ], }, } satisfies RichEditorExtensionRegistry, }, } satisfies Plugin; ``` A definition can contribute four things, all optional: `extensions` (Tiptap instances), `starterKit` (options merged into the one shared StarterKit — how the built-in marks re-enable features), `toolbar` (buttons, or a `component` for custom controls like the heading dropdown; `ToolbarIconButton` is exported from the same entry point so custom controls match the built-in styling), and `commands` (entries for the [slash menu](#slash-menu-and-plus-button)). Toolbar contributions from different extensions are separated automatically; definitions sharing a `group` render side by side. For an extension that only exists client-side, skip the PHP class entirely: pass its wire type as a string — `->extensions([Bold::make(), 'mention'])` — and it serializes as `{"type": "mention", "props": {}}`. Unknown types the client has no definition for are skipped (with a console warning in dev). See [Registry and types](/extending/registry-and-types/) for how generated types and the `EditorExtensionProps` augmentation give `props` a concrete shape on the client. ## Server-side extensions An extension that adds its own document nodes — not just toolbar behavior — needs a server-side counterpart too: something has to teach `RichContent` how to render the node, decide what's safe to keep in a stored document, and validate any references it holds. `EditorExtension` exposes five seams for that, each with a no-op default so a toolbar-only extension can ignore all of them: - **`serverExtensions()`** — contributes tiptap-php schema classes, so `RichContent` can parse the node and render it to HTML via their `renderHTML()`. - **`prepareDocument()`** — transforms a document once on its way out of the server (prefill and display), the place to batch-resolve stored references into human-readable attrs. - **`ephemeralAttributes()`** — declares which node attrs are outbound-only display data, not part of the canonical stored document. - **`configureSanitizer()`** — extends the HTML sanitizer with the elements/attrs the extension's `renderHTML()` emits. - **`validateDocument()`** — validates the extension's nodes in a submitted document (e.g. that a referenced id still exists); returned messages become field errors. Take a `callout` extension — a `{id, tone}` node that renders as an `