Home / Laravel / Lattice: Describe Inertia UIs in PHP

Lattice: Describe Inertia UIs in PHP

Lattice is a server-driven UI framework for Laravel that lets you describe a screen — its pages, forms, tables, actions, and menus — in PHP and have it render as real React components over Inertia. Lattice makes the server the single source of truth for what a screen is, leaving the client with one job — rendering it.

A page is a PHP class that builds a tree of component definitions. Lattice serializes that tree to a typed payload, ships it over Inertia as a normal page visit, and a single React component resolves each node against a component registry to draw it.

What you get out of that model:

  • Pages as classes, routed automatically. A #[AsPage] attribute registers the route; Lattice scans your configured paths and wires it up without a manual route entry.
  • Forms backed by Laravel validation. Fields are declared in PHP, validated with standard Laravel rules, and optionally validated live through Precognition.
  • Eloquent-backed tables. Columns, sorting, filtering, and pagination are defined on a table class that returns a query builder.
  • Server-side actions that return effects. An action runs your PHP on click and hands back instructions — a toast, a redirect, a component refresh — for the client to dispatch.

Pages

A page extends the base Page class, carries an #[AsPage] attribute for its route, and builds its UI in render(). The component tree is assembled with fluent PHP — Stack, Grid, Heading, Card, and so on — rather than JSX:

use LatticeLatticeAttributesAsPage;
use LatticeLatticeCoreComponentsCard;
use LatticeLatticeCoreComponentsGrid;
use LatticeLatticeCoreComponentsHeading;
use LatticeLatticeCoreComponentsStack;
use LatticeLatticeCoreComponentsText;
use LatticeLatticeCoreEnumsGap;
use LatticeLatticeCorePageSchema;
use LatticeLatticeHttpPage as BasePage;
 
#[AsPage(route: '/dashboard', middleware: ['web'])]
final class DashboardPage extends BasePage
{
public function title(): string
{
return 'Dashboard';
}
 
public function render(PageSchema $schema): PageSchema
{
return $schema->schema([
Stack::make('dashboard')
->gap(Gap::Large)
->schema([
Heading::make('Dashboard'),
Text::make('Everything below is described in PHP and rendered as React.'),
Grid::make('stats')
->columns(2)
->schema([
Card::make('Orders', '128 this week.'),
Card::make('Revenue', '$4,210 this week.'),
]),
]),
]);
}
}

Route parameters resolve straight into the render() signature with Laravel’s route-model binding, and an authorize() method gates access before the page renders:

#[AsPage(route: '/products/{product}/edit')]
class ProductEditPage extends Page
{
public function authorize(Request $request): bool
{
return $request->user()?->can('update', Product::class) ?? false;
}
 
public function render(PageSchema $schema, Product $product): PageSchema
{
return $schema->schema([
Heading::make("Edit {$product->name}"),
]);
}
}

Forms

A form is a class extending FormDefinition that declares its fields and handles the submission. Lattice renders the React inputs, validates the request against your Laravel rules, and runs handle() on a successful submit:

use IlluminateHttpRequest;
use LatticeLatticeAttributesAsForm;
use LatticeLatticeFormsComponentsForm as FormComponent;
use LatticeLatticeFormsComponentsTextInput;
use LatticeLatticeFormsFormDefinition;
use SymfonyComponentHttpFoundationResponse;
 
#[AsForm('app.profile.form')]
class ProfileForm extends FormDefinition
{
public function definition(FormComponent $form, Request $request): FormComponent
{
return $form->schema([
TextInput::make('name', 'Name')->rules(['required', 'string', 'max:255']),
TextInput::make('email', 'Email')->email()->rules(['required', 'email']),
]);
}
 
public function handle(Request $request): Response
{
$validated = $this->validate($request);
$request->user()->update($validated);
 
return redirect('/profile');
}
}

Dropping the form onto a page is a single call, with fluent configuration for the HTTP method, submit label, and the data it’s filled with. Adding ->precognitive(500) opts into live validation through Laravel Precognition:

Form::use(ProfileForm::class)
->method(HttpMethod::Patch)
->submitLabel('Save changes')
->precognitive(500)
->fill([
'name' => $user->name,
'email' => $user->email,
]);

Tables

Tables extend EloquentTableDefinition. You declare the columns and return a query builder; sorting, filtering, and pagination are handled for you based on which columns you mark as sortable() or filterable():

use IlluminateDatabaseEloquentBuilder;
use LatticeLatticeAttributesAsTable;
use LatticeLatticeTablesColumnsBooleanColumn;
use LatticeLatticeTablesColumnsNumberColumn;
use LatticeLatticeTablesColumnsTextColumn;
use LatticeLatticeTablesEloquentTableDefinition;
use LatticeLatticeTablesTableQuery;
 
#[AsTable('app.products')]
class ProductsTable extends EloquentTableDefinition
{
public function columns(): array
{
return [
TextColumn::make('name')->sortable()->filterable(),
NumberColumn::make('price')->sortable()->filterable(),
BooleanColumn::make('featured'),
TextColumn::make('updated_at')->date('Y-m-d')->sortable(),
];
}
 
public function builder(TableQuery $query): Builder
{
return Product::query();
}
}

Rendering it on a page reuses the same ::use() pattern as forms, so a products page composes its heading and table from the two classes:

$schema->schema([
Heading::make('Products'),
Table::use(ProductsTable::class),
]);

Actions and client effects

Actions are where the round trip shows up. An action extends ActionDefinition, describes its button in definition(), and runs server-side in handle(). Rather than returning a view, it returns an ActionResult carrying effects — instructions the client dispatches, such as a toast and a component reload:

use IlluminateHttpRequest;
use LatticeLatticeActionsActionDefinition;
use LatticeLatticeActionsActionResult;
use LatticeLatticeActionsComponentsAction;
use LatticeLatticeAttributesAsAction;
use LatticeLatticeCoreEnumsButtonVariant;
use LatticeLatticeCoreEnumsVariant;
 
#[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 = $this->product($request);
$product->update(['status' => 'archived']);
 
return ActionResult::success()
->toast(Variant::Success, 'Product archived.')
->reloadComponent('app.products');
}
}

Attached to a table row, the action carries the row’s context to the server so handle() knows which record it’s acting on:

Action::use(ArchiveProductAction::class)
->context(['product_id' => $row['id']]);

Learn More

📕 Installation, component reference, and theming options are found in the documentation.

💻 The source is available on GitHub.

Source: https://laravel-news.com

Tagged:

Leave a Reply

Your email address will not be published. Required fields are marked *