Laravel Packages

Lattice: Describe Inertia UIs in PHP

Published
Lattice: Describe Inertia UIs in PHP image

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 Lattice\Lattice\Attributes\AsPage;
use Lattice\Lattice\Core\Components\Card;
use Lattice\Lattice\Core\Components\Grid;
use Lattice\Lattice\Core\Components\Heading;
use Lattice\Lattice\Core\Components\Stack;
use Lattice\Lattice\Core\Components\Text;
use Lattice\Lattice\Core\Enums\Gap;
use Lattice\Lattice\Core\PageSchema;
use Lattice\Lattice\Http\Page 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 Illuminate\Http\Request;
use Lattice\Lattice\Attributes\AsForm;
use Lattice\Lattice\Forms\Components\Form as FormComponent;
use Lattice\Lattice\Forms\Components\TextInput;
use Lattice\Lattice\Forms\FormDefinition;
use Symfony\Component\HttpFoundation\Response;
 
#[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 Illuminate\Database\Eloquent\Builder;
use Lattice\Lattice\Attributes\AsTable;
use Lattice\Lattice\Tables\Columns\BooleanColumn;
use Lattice\Lattice\Tables\Columns\NumberColumn;
use Lattice\Lattice\Tables\Columns\TextColumn;
use Lattice\Lattice\Tables\EloquentTableDefinition;
use Lattice\Lattice\Tables\TableQuery;
 
#[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 Illuminate\Http\Request;
use Lattice\Lattice\Actions\ActionDefinition;
use Lattice\Lattice\Actions\ActionResult;
use Lattice\Lattice\Actions\Components\Action;
use Lattice\Lattice\Attributes\AsAction;
use Lattice\Lattice\Core\Enums\ButtonVariant;
use Lattice\Lattice\Core\Enums\Variant;
 
#[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.

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Sponsored

serpapi logo
SerpApi

The Web Search API for Your LLM and AI Applications

Visit SerpApi

The latest

View all →
CPX: The Composer Package Executor for PHP image

CPX: The Composer Package Executor for PHP

Read article
Laravel AI SDK Adds Human-in-the-Loop Tool Approval image

Laravel AI SDK Adds Human-in-the-Loop Tool Approval

Read article
Pest 5 Released With Test Impact Analysis, Agent Verification, and Evals image

Pest 5 Released With Test Impact Analysis, Agent Verification, and Evals

Read article
Queue-SQL: Run Mass Deletes and Updates Across Parallel Queue Jobs image

Queue-SQL: Run Mass Deletes and Updates Across Parallel Queue Jobs

Read article
Blade Formatting in Laravel Pint image

Blade Formatting in Laravel Pint

Read article
Inertia DevTools Is Now on the Chrome Web Store image

Inertia DevTools Is Now on the Chrome Web Store

Read article