Layouts & menus in Lattice: the shell is a schema too
Part 6 of the Building Lattice series. Part 5 covered actions and effects.
A page never renders alone — it sits inside a shell: a sidebar, a topbar, breadcrumbs, maybe a settings menu. In Lattice that shell is described the same way a page is: a PHP definition that serializes to a node tree. The only new idea is a slot where the active page gets dropped in.
Two things both called “layout”
Worth untangling up front, because the names collide. There’s the PageLayout
enum — App, Auth, None — which is just a selector a page sets to pick
which shell it wants:
#[AsPage(route: '/dashboard', layout: PageLayout::App)]
class HomePage extends Page { /* ... */ }
And there’s the actual content of that shell: a LayoutDefinition marked with
#[AsLayout]. The enum says “wrap me in the app shell”; the definition is the app
shell.
A layout is a schema with an outlet
#[AsLayout('app')]
class AppLayout extends LayoutDefinition
{
public function schema(PageSchema $schema, Request $request): PageSchema
{
return $schema->schema([
Stack::make('app-shell')->direction('row')->gap(Gap::None)->schema([
$this->sidebar(),
Stack::make('app-main')->schema([
Topbar::make('app-topbar')->sticky(),
Breadcrumbs::make(),
Outlet::make(),
]),
]),
]);
}
}
Outlet::make() is the whole trick — it marks the spot where the active page
renders inside the shell. Everything around it (sidebar, topbar, breadcrumbs) is
declared once and shared across every page that selects this layout.
Menus that resolve their own routes
Navigation is built from Menu and MenuItem components. The nice part is that a
menu item can point at a page class and figure out the rest itself:
Sidebar::make('app-sidebar')->collapsible()->items([
Menu::make('sidebar')->items([
MenuItem::fromPage(HomePage::class)->prefix(Affix::icon('house')),
MenuItem::make('Catalogue')->prefix(Affix::icon('package'))->children([
MenuItem::fromPage(ProductsPage::class),
MenuItem::fromPage(GroupsPage::class),
]),
]),
]);
MenuItem::fromPage(ProductsPage::class) resolves the href from the page’s
registered route and even defaults the label from the class name — no
hardcoded URLs that rot when a route changes. (Sidebar is ->collapsible();
Topbar is ->sticky(); both take ->items([...]).)
A constraint I made on purpose
A menu item is either a link or a section that holds a submenu — never both.
Calling ->href() on an item that already has ->children() throws, and so does
the reverse:
MenuItem::make('Settings')->children([ /* ... */ ]); // ok: a header
MenuItem::fromPage(SettingsPage::class); // ok: a link
// MenuItem::fromPage(...)->children([...]) // throws
I could have allowed a clickable parent that also expands, but that pattern is ambiguous on touch devices and quietly inconsistent in practice. Making it impossible at the API level means the React side never has to guess what a click on a parent means. Active state, by contrast, isn’t computed in PHP at all — React compares the current path to each item’s href (exact match) and marks the match. The server describes the menu; the client decides what’s “here”.
Chrome is interactive too — via effects
Remember effects from last time? They’re not just for action handlers. A topbar button toggles the sidebar with one:
Button::make('toggle-sidebar')
->icon(Icon::PanelLeft)
->effects(Effect::toggleSidebar('app-sidebar'));
No client state wired by hand, no useState for the sidebar — the button
declares the effect, the sidebar component listens for it. The same dispatch
mechanism that powers toasts and redirects also drives the chrome.
So a layout is, once more, the same idea wearing a different hat: a PHP definition, serialized, rendered by React, made interactive through effects. The shell isn’t a special case — it’s just a schema with a hole in it.
Next: fragments — pulling pieces of a page out into reusable, lazily-loaded chunks.