lattice-php/lattice
Lattice is a Laravel/PHP package for building clean, maintainable, data-driven interfaces with reusable components and a fluent API. It streamlines common UI patterns so you can ship structured admin screens and workflows faster without repetitive boilerplate.
Lattice is a server-driven UI layer for Laravel + React: you describe pages, tables, forms, and actions in PHP, and the React client renders them. The practical entry point is not the package root but the skills documentation (skills/lattice-actions, skills/lattice-closures, and the referenced lattice-tables), because that is where the day-to-day conventions live.
Minimal path to first value:
Outlet::make() — this is where component trees render.Callouts::make() slot between the header and the outlet if you plan to use callout effects.A good first action is an archive/delete/send flow:
#[AsAction('app.products.archive')]
class ArchiveProductAction extends ActionDefinition
{
public function definition(Action $action): Action
{
return $action
->label('Archive')
->variant(ButtonVariant::Destructive)
->confirm('Archive product?', 'This hides it from the catalogue.');
}
public function handle(Request $request): ActionResult
{
Product::findOrFail($this->context('product_id'))
->update(['status' => 'archived']);
return ActionResult::success()
->toast('Product archived.')
->reloadComponent('app.products');
}
}
Then attach it to a table row with Action::use(ArchiveProductAction::class)->context(['product_id' => $row['id']]).
Look first at:
ActionDefinition / BulkActionDefinition for server-side interactions.#[AsAction], #[AsBulkAction], and table attributes for discovery.The package is conceptually strong for admin panels, internal tools, and CRUD-heavy back offices where the team is PHP-first and wants to avoid duplicating validation and business rules in React. With a small ecosystem (62 stars, no listed dependents), treat it as a framework you will need to learn deeply rather than a drop-in UI kit.
Actions as the primary interaction primitive. The strongest pattern is to treat every non-trivial click as an ActionDefinition: it owns authorization, validation, mutation, and the UI effects that follow. Do not scatter redirect/toast logic across controllers. An action returns a pipeline of effects, executed in order:
return ActionResult::success()
->toast('Saved.')
->reloadComponent('app.products')
->closeModal();
This makes the post-click experience declarative and testable server-side.
Signed row context instead of trusting the request. For row actions, pass identifiers through ->context([...]) and read them in handle() with $this->context('product_id'). The reference is signed, so it is safe to place record IDs in the component tree. For repeated lookups, register a key once with Lattice::context('product', Product::class) or a closure resolver, then use contextModel('product'). This gives you memoization per request and 404s on missing records, and registered keys cascade into child components and forms.
CRUD flow with forms in modals. The productive pattern is:
Action::use(EditProductAction::class).->form([...]) or ->lazyForm()->form([...]) to render a modal form.handle() calls $this->validate($request), mutates, then returns success()->toast(...)->reloadComponent('app.products')->closeModal().For per-record prefill, lazyForm() is the one to reach for — it loads the row into the form when the modal opens.
Bulk actions over table selections. Use BulkActionDefinition for selection-based operations. The selected records arrive as a Collection, resolved by the table's data source; for an EloquentTableDefinition they are models, so you can update them directly. Return a count in the success payload and a toast, then reload the table component.
Authorization belongs on the action. Override authorize(Request $request): bool for custom gates, or declare subject abilities on the attribute:
#[AsAction('app.products.archive', can: 'update', on: 'product')]
Prefer the attribute form for standard policy checks, because a missing subject denies outright rather than silently falling back to a subject-less check.
Flash effects outside actions. Controllers, listeners, and middleware can deliver effects with the next Inertia response:
Effects::flash(
Effects::toast('Settings saved.', Variant::Success),
Callout::make('Export queued.', Variant::Info)->title('Export')
);
return redirect('/dashboard');
This replaces the older CreatesToastMessages trait pattern ($this->toast(...)), so plan migrations accordingly.
Closures for dependent and computed fields. The closure evaluator is the key to keeping forms dynamic without client-side logic. Use named parameters deliberately:
TextInput::make('total', 'Total')->dependsOn(
['qty', 'price'],
fn (TextInput $field, FormData $state) => $field->value(
$state->float('qty') * $state->float('price')
),
);
Available utilities include $state/FormData, $get, $value, $component, Request $request, plus hook-specific $search, $values, $row, and $form. Select::searchable() uses $search; repeater/builder row hooks use $row and $form; filter closures get a typed Eloquent builder.
Component composition. Action::use(...) is itself a component, so actions can appear anywhere in a page tree, not just tables. Use ActionGroup::make('row')->actions([...]) to group related triggers behind one button, and Link::make('Edit')->href(...) for plain navigation.
Layout discipline. Place Callouts::make() in the layout schema between the header and Outlet::make(). If the layout lacks that slot, callout effects are silently dropped — no error, no banner. Establish the layout early so effects have somewhere to land.
#[AsAction] and #[AsBulkAction] live in Lattice\Core\Attributes; Action::use() and BulkAction::use() are components in Lattice\Actions\Components. Mixing them up produces confusing discovery or rendering failures.#[AsAction('id')] is not discovered and has no endpoint. If a click 404s, check the attribute before debugging routing. The same applies to bulk actions.reloadComponent() takes the target component/table id, not the action id. This is easy to get wrong because both are string identifiers. Use the #[AsTable]/component id that renders the data you want refreshed.$this->context($key) reads the signed, trusted copy. Reading request input bypasses the signature guarantee and reintroduces tampering risk.ActionResult::failure('...') returns HTTP 422, keeps confirm dialogs and modals open, and auto-adds an error toast. A success() with an error toast still reads as success to the client and will close the modal.->unique($key) a callout is a one-off event that stays until dismissed. With a key it is a projection of server state: re-emit it on every request where it still applies and retract it with ->retractCallout($key) or Callout::retract($key) when it no longer does.fn ($state) and fn (FormData $state) both receive the same object. Name parameters after the utility you want. An unmatched, non-nullable parameter throws an exception listing the available utilities — read that exception before guessing.can/on. If the context key is absent or its resolver finds nothing, the action is denied. Make sure the context key is always present on the component that triggers the action.EloquentTableDefinition; type-hint defensively or verify the data source.reloadComponent() → confirm layout has Callouts::make() if effects are missing → inspect the closure exception for parameter-resolution failures.ActionResult, custom callout configuration (title, dismissible, link, action), custom context resolvers, and layout slots. These are the places to invest when the built-ins stop fitting.How can I help you explore Laravel packages today?