Pages
A page is the entry point of a Lattice screen. It extends Lattice\Http\Page, declares its
route with a #[AsPage] attribute, and builds its UI in render(). Lattice discovers the class,
registers a route for it, and renders it through Inertia — you write no controller and no Inertia page
component of your own.
use Lattice\Core\Attributes\AsPage;use Lattice\Ui\Components\Heading;use Lattice\Core\PageSchema;use Lattice\Http\Page;use Lattice\Table\Components\Table;
#[AsPage(route: '/products')]class ProductsPage extends Page{ public function title(): string { return 'Products'; }
public function render(PageSchema $schema): PageSchema { return $schema->schema([ Heading::make('Products'), Table::use(ProductsTable::class), ]); }}Building the UI
Section titled “Building the UI”render() receives a fresh PageSchema and returns it with its components attached. Pass the whole
tree at once with ->schema([...]), or append components one at a time with ->component():
public function render(PageSchema $schema): PageSchema{ return $schema ->component(Heading::make('Products')) ->component(Table::use(ProductsTable::class));}The components are the same building blocks used everywhere else — layout
primitives like Stack, content like Heading and Text, and the interactive
forms, tables, and actions that carry
their own endpoints.
Named extension slots
Section titled “Named extension slots”A page can expose part of its component tree to other modules without owning their components. Place
a named Slot wherever contributions should appear and pass any context their factories need:
use Lattice\Ui\Components\Tabs;use Lattice\Ui\Slot;
Tabs::make('project-settings-tabs')->schema([ Slot::make('project.settings.tabs')->context([ 'project' => $project, ]),]);Register each contribution from the module’s service provider:
use Lattice\Core\Facades\Lattice;use Lattice\Ui\Components\Tab;
Lattice::extend( 'project.settings.tabs', fn (Project $project, $user): Tab => Tab::make('api-tokens', 'API tokens') ->visible($user?->can('update', $project) ?? false) ->schema([ ApiTokensPanel::make($project), ]), priority: 20,);Each factory returns exactly one component. Lower priorities render first; contributions with the same
priority retain registration order. Return the component with ->visible(false) when it should not
render rather than returning null.
Slot context is available to the factory by parameter name. Object values also resolve by type, as the
Project $project parameter does above. Factories additionally receive $user, $slot (or a typed
Slot), a typed Request, and services from Laravel’s container. See
Closure evaluation for the complete rules.
An unregistered slot renders nothing. Each rendered slot receives fresh component instances, and the
Slot itself is expanded on the server before component visibility, tab selection, or
serialization—it never becomes a client-side node.
Route parameters
Section titled “Route parameters”render() is dispatched like a controller method, so route parameters and route-model binding resolve
straight into its signature alongside the PageSchema:
use Workbench\App\Models\Product;
#[AsPage(route: '/products/{product}/edit')]class ProductEditPage extends Page{ public function render(PageSchema $schema, Product $product): PageSchema { return $schema->schema([ Heading::make("Edit {$product->name}"), // … ]); }}Anything the container can resolve — a Request, a service, a bound model — can be type-hinted here
too.
The #[AsPage] attribute
Section titled “The #[AsPage] attribute”#[AsPage] declares how the page is routed and framed:
| Argument | Purpose |
|---|---|
route |
The URL path. Supports parameters (/products/{product}/edit). |
name |
The route name. Defaults to the route segments joined by dots (products.edit), falling back to the class name without its Page suffix. |
layout |
The layout the page renders into — a PageLayout or a registered layout key. Defaults to PageLayout::None (no shell). |
container |
How the content is framed — a PageContainer (Default or Centered). Defaults to PageContainer::Centered. |
middleware |
Extra middleware for the page’s route — a string or an array, merged after the lattice.pages.middleware config default (['web']). |
can |
Abilities the current user must pass before the page renders — a string or an array. See Authorization. |
use Lattice\Core\Enums\PageContainer;use Lattice\Core\Enums\PageLayout;
#[AsPage( route: '/products', name: 'products.index', layout: PageLayout::App, container: PageContainer::Default, middleware: 'auth',)]Shared base pages
Section titled “Shared base pages”layout, container, and middleware are inherited: a page that omits one of them takes the nearest
value set by a parent class. Put the shared framing on a base page once, and concrete pages declare
only their own route:
#[AsPage(layout: PageLayout::App, container: PageContainer::Default, middleware: 'auth')]abstract class AppPage extends Page {}
#[AsPage(route: '/products', name: 'products.index')]class ProductsPage extends AppPage {} // inherits the App layout, container, and middlewareLayout and container at request time
Section titled “Layout and container at request time”The attribute sets the layout and container statically. To decide them per request — a different shell
for a guest versus an authenticated user, say — override layout() or container() on the page.
Returning a non-null value (a PageLayout/PageContainer case or a registered key) takes precedence
over the attribute; returning null defers to it.
public function layout(): PageLayout|string|null{ return request()->user() ? PageLayout::App : PageLayout::Auth;}Discovery and registration
Section titled “Discovery and registration”Lattice scans the paths in config('lattice.discover') (your app/ directory by default) for classes
carrying #[AsPage] and registers a route for each one. Register pages that live outside those paths
explicitly:
use Lattice\Core\Facades\Lattice;
Lattice::pages([ ProductsPage::class, ProductEditPage::class,]);Discovery is cached alongside route:cache, so the filesystem scan does not run on production
requests.
Embedded pages
Section titled “Embedded pages”#[AsPage] means “this page owns a route” — its route argument is what Lattice needs to register one.
A page can also have no route at all and be rendered by returning it from your own controller instead;
Page implements Responsable, so returning an instance is enough:
use Illuminate\Http\Request;use Lattice\Http\Page;
class ProductEmbedController{ public function show(Request $request): Page { return new ProductEmbedPage($request->route('product')); }}The page itself needs no #[AsPage] attribute — a plain Page subclass works, since layout() and
container() method overrides take precedence over attribute metadata regardless of whether the
attribute is present:
use Lattice\Core\PageSchema;use Lattice\Core\Enums\PageLayout;
class ProductEmbedPage extends Page{ public function __construct(private readonly Product $product) {}
public function layout(): PageLayout { return PageLayout::App; }
public function render(PageSchema $schema): PageSchema { return $schema->component(Heading::make($this->product->name)); }}A route-less #[AsPage] class is valid too — useful when the shared metadata (layout, container,
middleware inheritance from a base page) is worth keeping even though the page has no route of its own.
Either way, Lattice never builds a route for it: discovery and Lattice::pages() both register it in the
page registry, but only entries with a route reach Route::get().
Title and breadcrumbs
Section titled “Title and breadcrumbs”title() sets the document title and breadcrumbs() the page’s trail; a layout’s
Breadcrumbs component renders whatever the active page provides.
breadcrumbs() returns Breadcrumb instances — build one directly with Breadcrumb::make(), or,
for a link to another page, Breadcrumb::toPage(), which resolves the label and href from the target
page class so the trail can’t drift out of sync with a renamed route:
use Lattice\Core\Breadcrumb;
public function breadcrumbs(): array{ return [ Breadcrumb::toPage(ProductsPage::class), Breadcrumb::make('Edit', ''), ];}Chain ->title() onto a Breadcrumb to override the label toPage() derived:
Breadcrumb::toPage(ProductsPage::class)->title(__('Products'));title() and breadcrumbs() take no parameters, so they can’t read the route or an injected model.
When a value depends on the request — a record’s name, say — set it on the PageSchema from
render() instead, which is dispatched with the same route parameters as any other controller
method. A value set on the schema wins over the corresponding Page method; leaving it unset falls
through to the method, and passing ->breadcrumbs([]) deliberately clears the trail rather than
falling through to it:
public function render(PageSchema $schema, Product $product): PageSchema{ return $schema ->title($product->name) ->breadcrumbs([ Breadcrumb::toPage(ProductsPage::class), Breadcrumb::toPage(self::class, ['product' => $product->getKey()])->title($product->name), ]) ->schema([ // … ]);}Authorization
Section titled “Authorization”Declare a subject-less ability with can on the attribute:
#[AsPage(route: '/products', can: 'products.view')]For anything that needs the request or a record, override authorize(); it returns true by default.
A request that fails either check is rejected before render() runs:
use Illuminate\Http\Request;
public function authorize(Request $request): bool{ return $request->user()?->can('viewAny', Product::class) ?? false;}These are the same two tools every Lattice definition carries — see Authorization for how they behave across forms, tables, and actions.