Skip to content

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),
]);
}
}

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.

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.

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.

#[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',
)]

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 middleware

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;
}

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.

#[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() 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([
// …
]);
}

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.