Skip to content

Layouts

A layout is the shell that wraps your pages — the sidebar, header, and chrome that stay put while the page inside them changes. Like everything else in Lattice it is a server-side definition that builds a component tree, with one Outlet marking where the active page renders.

Extend LayoutDefinition and build the shell in schema(). The #[AsLayout] attribute registers it under a key, and Outlet::make() marks where the page’s content appears:

use Illuminate\Http\Request;
use Lattice\Core\Attributes\AsLayout;
use Lattice\Ui\Components\Stack;
use Lattice\Ui\Enums\Width;
use Lattice\Core\PageSchema;
use Lattice\Ui\Components\Menu;
use Lattice\Ui\Components\MenuItem;
use Lattice\Layouts\Components\Outlet;
use Lattice\Ui\Components\Sidebar;
use Lattice\Layouts\LayoutDefinition;
#[AsLayout('app')]
final class AppLayout extends LayoutDefinition
{
public function schema(PageSchema $schema, Request $request): PageSchema
{
return $schema->schema([
Stack::make('app-shell')->direction('row')->schema([
Sidebar::make('app-sidebar')->collapsible()->items([
Menu::make('sidebar')->items([
MenuItem::fromPage(HomePage::class)->icon('house'),
MenuItem::fromPage(ProductsPage::class)->label('Products'),
]),
]),
Stack::make('app-main')->width(Width::Fill)->schema([
Outlet::make(),
]),
]),
]);
}
}

schema() receives the Request, so the shell can adapt to the current user — highlighting the active section, showing an avatar, or hiding links a visitor can’t reach.

A layout’s schema must contain exactly one Outlet. It carries no markup of its own; it is the seam where Lattice drops the active page’s component tree. Everything around it — sidebar, breadcrumbs, header — is shared chrome rendered once and left in place as the page changes.

Named Slot extension points use the same server-side schema mechanism in layouts. They are useful when a module needs to contribute navigation or chrome without replacing the application layout:

use Lattice\Ui\Slot;
Stack::make('app-shell')->schema([
Sidebar::make('app-sidebar')->items([
Menu::make('navigation')->items([
MenuItem::fromPage(HomePage::class),
Slot::make('app.sidebar.navigation'),
]),
]),
Stack::make('app-main')->schema([
Outlet::make(),
]),
]);

Slot and Outlet can therefore appear in the same layout tree, but they solve different boundaries:

Slot Outlet
Resolved by PHP on the server The React layout renderer
Filled with Components registered through Lattice::extend() The active page’s complete schema
Serialized itself No—the registered components replace it before the wire payload is built Yes—it remains an outlet node in the layout wire schema
Allowed occurrences Any number of named slots Exactly one per layout

An empty named slot disappears without affecting the Outlet or the page tree inserted there.

Topbar is a horizontal bar for chrome that sits above the page — a logo, search, settings menu, or appearance switcher. Call ->sticky() to keep it pinned to the top of the viewport as the page scrolls, and ->items([...]) to fill it. A sticky topbar also claims its height as the page’s sticky offset (--lt-sticky-offset), which sticky stacks and vertical tab rails pin beneath:

use Lattice\Ui\Components\Topbar;
Topbar::make('app-topbar')->sticky()->items([
Menu::make('topbar-settings')->items([
MenuItem::make('Settings', 'settings'),
]),
]);

Callouts marks where flashed and action-emitted callouts render inside the shell — the persistent banners an action or a redirect can raise. Place it once, typically between the header bar and the Outlet:

use Lattice\Ui\Components\Callouts;
Stack::make('app-main')->width(Width::Fill)->schema([
Callouts::make(),
Outlet::make(),
]);

Without a Callouts slot, callout effects have nowhere to render.

A page selects its layout in its #[AsPage] attribute, not with a method. Pass a PageLayout for the common shells, or a string matching a #[AsLayout] key:

use Lattice\Core\Enums\PageWidth;
use Lattice\Core\Enums\PageLayout;
#[AsPage(route: '/products', layout: PageLayout::App, width: PageWidth::Full)]
class ProductsPage extends Page {}

PageLayout::App and PageLayout::Auth are conventional keys (app, auth) for the two shells most apps need — a signed-in application frame and a bare authentication screen. PageLayout::None opts out entirely: the page renders with no shell, which is what auth and error screens usually want. A custom key works the same way — register the layout with #[AsLayout('marketing')] and reference it with layout: 'marketing'.

Because the layout is set on the attribute, a base page can pick it once for a whole section and concrete pages inherit it.

Layouts are discovered from the paths in config('lattice.discover') just like pages and the other definitions. Register a layout that lives elsewhere explicitly:

use Lattice\Core\Facades\Lattice;
Lattice::layouts([AppLayout::class]);

The shell is built from the same components as a page, plus a set made for navigation chrome — Sidebar, Topbar, Menu, MenuItem, Dropdown, and Breadcrumbs. Those are covered in Navigation.

The chrome itself is plain React underneath: Sidebar, SidebarFooter, Topbar, and Breadcrumbs are exported from @lattice-php/ui for custom pages and component packages that want the same shell without the wire nodes — see Navigation → Client-side chrome.