Page View (NoerdPage)
A *-page component hosts everything AROUND a record's form: the page chrome (header, footer, tabs), the Relation Box, the widget sidebar and — optionally — an embedded slim *-detail component that renders the form itself. The counterpart trait to NoerdList/NoerdDetail is NoerdPage.
NoerdDetail composes NoerdPage (use NoerdPage), so a detail is "a page plus the model-form concerns". The split:
| Concern | Trait | Component |
|---|---|---|
| Chrome, tabs, modal lifecycle, quick-create, list interplay | NoerdPage | {entity}-page.blade.php |
| Form fields, validation, persistence of ONE model | NoerdDetail | {entity}-detail.blade.php |
Tenant-singleton settings screens are neither pages nor details — they use the NoerdSettingsPage trait and a settings/{name}.yml, see Settings Pages.
Details are pure model forms. Their YAML (details/{entity}-detail.yml, mandatory) contains only title, description, theme, quickCreate, tabs, fields, actions, lists and positions (see Detail Properties). widgets: and relations: do NOT belong in a detail YAML — they are page concerns. A detail opened standalone (e.g. from a relation field) therefore renders just the form, without widgets or relation box.
The optional page YAML
A page MAY ship a YAML at app-configs/{app}/pages/{entity}-page.yml (module copy at app-modules/{module}/app-configs/{app}/pages/; both copies must be kept in sync). A missing page YAML is a normal state — hand-built pages define their layout in the component itself (StaticConfigHelper::getPageFields() is silent on miss).
title: Warehouse
detail: inventory::warehouse-detail
quickCreate: true
relations:
- label: Items
heroicon: cube
relation: items
route: inventory.items
component: inventory::items-list
arguments:
warehouseId: $modelId
widgets:
- title: Stock Movements
route: inventory.stock-movements
component: inventory::stock-movements-list
columns:
- item_name
- quantity
arguments:
warehouseId: $modelId| Property | Description |
|---|---|
title | Page title (translation key) |
detail | The embedded detail Livewire component (full name, e.g. inventory::warehouse-detail). Drives the generic store roundtrip |
details | A list of embedded detail components saved by the same Save button (see Several embedded details). The first entry — or detail: when both are given — is the PRIMARY detail whose record is the page's own; a component named twice is embedded once |
quickCreate | Opt-in for the narrow quick-create modal on new records (also sizes the modal via noerd-modal) |
tabs | Page-level tabs (e.g. Media, Activity Log) — rendered by the page blade via <x-noerd::tabs>; same properties as detail tabs |
relations | Relation Box tiles (see Relation Box below). Each tile may carry route: next to component: |
widgets | Right-hand widget sidebar rendered by <x-noerd::detail-grid> / <x-noerd::detail-widgets> (see Widgets below) |
fields | Optional field grid the page renders ITSELF (same syntax and field types as a detail YAML, theme: included) via @include('noerd::components.detail.block', array_merge($pageLayout, ['modelId' => …, 'detailData' => $detailData])) or <x-noerd::tab-content :layout="$pageLayout" …>. A page has no validateFromLayout() and no store() of its own for them — they are display and binding only. Typical use: a read-only block of facts in the display theme (see Themes), configurable per installation and through the layout-override hook |
Both relations and widgets open a list NARROWED by the current record, so their route: resolves the component WITHOUT rewriting the browser URL — see Modal System.
Widgets
<x-noerd::detail-widgets :layout="$pageLayout" :modelId="$modelId" /> (rendered by <x-noerd::detail-grid> as the right-hand column) mounts every widgets entry as a <x-noerd::detail-widget>: a bordered card with the embedded list in minimal mode showing only the named columns, plus a "Show more" link that re-opens the same list as a full modal with the identical arguments. Nothing renders until the record is saved ($modelId is set).
| Property | Description |
|---|---|
title | (optional) Card heading (translation key) |
component | The list Livewire component embedded in the card (e.g. inventory::stock-movements-list) — also the "Show more" fallback when route is not registered |
route | (optional) Named list route the "Show more" link opens as a modal (URL not rewritten) |
columns | Column field names of the list YAML shown in the card (the list's minimalColumns) |
arguments | Arguments passed to the list — for the card and the "Show more" modal alike; the $modelId token resolves to the current record id, static values pass through unchanged |
Quick-Create Lifecycle
Quick-create mode (quickCreate: true in the page or detail YAML, or quickCreate passed as a mount argument) opens the new record as a narrow modal showing only the required fields (and fields flagged quickCreate: true). Exiting the mode is a framework default: a global Livewire hook watches every component using NoerdPage — as soon as an action leaves the component with a modelId (i.e. the record was saved), the hook clears the quick-create flag and resizes the modal to the full detail. Components never need to reset quickCreate themselves.
Component structure
<?php
use Livewire\Component;
use Noerd\Traits\NoerdPage;
use Vendor\Inventory\Models\Warehouse;
new class extends Component {
use NoerdPage;
public ?string $detailPrimary = 'warehouseId';
public $detailModel = Warehouse::class;
}; ?>
<x-noerd::page>
<x-slot:header>
<x-noerd::modal-title>{{ __('Warehouse') }}</x-noerd::modal-title>
</x-slot:header>
<x-noerd::detail-relations :layout="$pageLayout" :modelId="$modelId"
:modelClass="\Vendor\Inventory\Models\Warehouse::class"/>
<x-noerd::detail-grid :layout="$pageLayout" :modelId="$modelId">
@livewire($pageLayout['detail'], ['modelId' => $modelId, 'embedded' => true], key('embedded-detail'))
</x-noerd::detail-grid>
<x-slot:footer>
<x-noerd::delete-save-bar :showDelete="isset($modelId)"/>
</x-slot:footer>
</x-noerd::page>initPage()(protected; the trait'smount()default) loads the record into$detailDatawhen$detailModelis set, loads the optional page YAML into$pageLayoutand resolves quick-create. A custommount()calls$this->initPage()first.- The embedded detail is mounted with
embedded: true—x-noerd::pagethen renders the detail chrome-less (no header/footer/scroll wrapper), the page owns all chrome. - The list refreshed when the page closes is derived from the component name with its namespace kept (
inventory::warehouse-page→inventory::warehouses-list;getListComponent()strips-pageand-detailalike). A list that does not follow the plural convention is declared asprotected string $listComponent = 'inventory::stock-list';on the page (the trait declares no such property itself — keep itprotected); overridinggetListComponent()stays the escape hatch for a dynamic target and wins over the property. The same shape configures the YAML the page's detail resolves under:protected string $detailConfigComponent/getDetailComponent(). $detailPrimarybinds$modelId(int|string|null) to the page's URL parameter (?warehouseId=5) via the trait'squeryStringNoerdPage()— never redeclare$modelIdor add a#[Url]attribute. The embedded detail may declare the SAME alias (it does, for standalone use): anembedded: trueinstance skips the binding automatically, so page and detail never compete for the URL parameter.openRelationDetail($fieldName)opens the record behind adetailDataforeign key as a modal — target route and component come from the field's registered relation definition in the layout (route first, component as fallback);setPreselect()/preselect()hand a preselected filter value between a page and a related list through the session (see Detail View).
Relation Box
A grid of clickable tiles (6 per row), each showing a heroicon, a label and the related record count, e.g. Contacts (5). Clicking a tile opens the related list as a modal, narrowed by the current record. Use it instead of relation tabs for an overview of all relations at a glance.
Place the generic component between the header slot and the page body (see Component structure); the tiles are the relations: entries of the page YAML shown above:
<x-noerd::detail-relations
:layout="$pageLayout"
:modelId="$modelId"
:modelClass="\Vendor\Inventory\Models\Warehouse::class" /><x-noerd::detail-relations> wraps the <livewire:noerd::relation-box> component. It renders only when modelId, modelClass (a fully-qualified Eloquent model class, used to load the record and count the relations) and at least one tile are present, and refreshes its counts when a list modal closes (#[On('closeTopModal')]).
| Property | Description |
|---|---|
label | Tile label (translation key) |
heroicon | Heroicon rendered before the label |
relation | Eloquent relationship method on the model used to count records (e.g. items). An unknown method yields a count of 0 instead of throwing |
route | Named route of the list, opened as a modal. The browser URL is deliberately NOT rewritten — the tile opens the list NARROWED by the current record, which a plain list route cannot express |
component | List component opened as a modal on click (e.g. inventory::items-list) — also the fallback when route is not registered |
arguments | Arguments passed to the modal; the $modelId token resolves to the current record id, static values pass through unchanged |
An optional module can contribute tiles through the RelationBoxRegistry — e.g. an invoicing module appends an Invoices tile to the customer page without the customer module knowing about it. Registry tiles render after the YAML tiles and may additionally carry a count closure (instead of relation) and a visible closure, both receiving the record; see Extension Registries.
Generic store roundtrip
The save flow between page and embedded detail is fully generic — no per-component events:
- The page footer's Save calls
NoerdPage::store(), which dispatchesstoreDetail-{detail}for every embedded detail (suffix = the full component name from the YAMLdetail:/details:keys). - The detail listens (via
getListeners()), runs its normalstore()— identical to a standalone save — and ends infinishStore($model). finishStore()(protected) dispatchesdetailStored-{detail}with the payloadmodelIdanddetail(the reporting component's name). For the primary detail the page adopts the id (embeddedDetailStored()), refreshes its$detailDatasnapshot (merge — page-owned keys survive), runsstoreProcess()and finally the protected hookafterEmbeddedDetailStored($model).- Pages that persist page-owned state (e.g. variant groups, uploads) override
afterEmbeddedDetailStored(Model $model).
Live form sync: an embedded detail mirrors its form state via detailDataUpdated-{detail} (payload detailData + detail): NoerdDetail::updatedDetailData() calls the protected syncEmbeddedDetailData(), which dispatches syncPayload(). Override syncPayload() to filter the payload; a detail with its own updatedDetailData() hook calls syncEmbeddedDetailData() itself. The page merges the primary detail's state in embeddedDetailDataUpdated() (override to add side effects, e.g. a change counter for a live preview).
Several embedded details
A page may embed MORE than one detail — a second form of the same record, or a related record such as a settings row — and save all of them with its single Save button:
title: Warehouse
detail: inventory::warehouse-detail # the primary: the page's own record
details:
- inventory::warehouse-pricing-detail # saved by the same Save button@livewire($pageLayout['detail'], ['modelId' => $modelId, 'embedded' => true], key('embedded-detail'))
@if ($modelId)
@livewire('inventory::warehouse-pricing-detail', ['modelId' => $modelId, 'embedded' => true], key('embedded-detail-pricing'))
@endifembeddedDetailComponents()lists every embedded detail, the primary first (detail:, or the first entry ofdetails:).store()dispatchesstoreDetail-*to all of them; the listenersdetailStored-*/detailDataUpdated-*are registered for each.- Every detail validates and persists on its own, behind its own object permission. The default
store()writes only the fields its own YAML declares, so two slim details of the SAME model each persist their own columns. - Only the primary detail's report changes the page: its id is adopted, its data merged. An ADDITIONAL detail's report only sets the success indicator and runs the protected no-op hooks
afterAdditionalDetailStored(string $detail, int $modelId)andafterAdditionalDetailDataUpdated(string $detail, array $detailData)— its keys (name, …) must never clobber the page mirror. Override the hooks for a preview or a reload. - An additional detail that is a child record of the primary is rendered only once the record exists (
@if ($modelId), the same rule as embedded lists). Each embed needs its ownwire:key; there is still exactly ONE<x-noerd::delete-save-bar>on the page. - Each embedded detail carries its own module-contributed header actions on its form block (see Header Actions).
References
resources/views/components/noerd-user-page.blade.php+app-configs/setup/pages/noerd-user-page.ymland the embeddednoerd-user-detail— the shipped page/detail pair in the setup app- The
page.blade.stubrendered bynoerd:make-resource(src/Commands/stubs/resource/) — the minimal skeleton - Trait mechanics:
tests/Feature/NoerdPageTraitTest.php,tests/Feature/UserPageTest.php