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.