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.