Skip to content

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:

ConcernTraitComponent
Chrome, tabs, modal lifecycle, quick-create, list interplayNoerdPage{entity}-page.blade.php
Form fields, validation, persistence of ONE modelNoerdDetail{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).

yaml
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
PropertyDescription
titlePage title (translation key)
detailThe embedded detail Livewire component (full name, e.g. inventory::warehouse-detail). Drives the generic store roundtrip
detailsA 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
quickCreateOpt-in for the narrow quick-create modal on new records (also sizes the modal via noerd-modal)
tabsPage-level tabs (e.g. Media, Activity Log) — rendered by the page blade via <x-noerd::tabs>; same properties as detail tabs
relationsRelation Box tiles (see Relation Box below). Each tile may carry route: next to component:
widgetsRight-hand widget sidebar rendered by <x-noerd::detail-grid> / <x-noerd::detail-widgets> (see Widgets below)
fieldsOptional 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).

PropertyDescription
title(optional) Card heading (translation key)
componentThe 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)
columnsColumn field names of the list YAML shown in the card (the list's minimalColumns)
argumentsArguments 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
<?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's mount() default) loads the record into $detailData when $detailModel is set, loads the optional page YAML into $pageLayout and resolves quick-create. A custom mount() calls $this->initPage() first.
  • The embedded detail is mounted with embedded: true — x-noerd::page then 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 -page and -detail alike). A list that does not follow the plural convention is declared as protected string $listComponent = 'inventory::stock-list'; on the page (the trait declares no such property itself — keep it protected); overriding getListComponent() 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().
  • $detailPrimary binds $modelId (int|string|null) to the page's URL parameter (?warehouseId=5) via the trait's queryStringNoerdPage() — never redeclare $modelId or add a #[Url] attribute. The embedded detail may declare the SAME alias (it does, for standalone use): an embedded: true instance skips the binding automatically, so page and detail never compete for the URL parameter.
  • openRelationDetail($fieldName) opens the record behind a detailData foreign 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:

blade
<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')]).

PropertyDescription
labelTile label (translation key)
heroiconHeroicon rendered before the label
relationEloquent relationship method on the model used to count records (e.g. items). An unknown method yields a count of 0 instead of throwing
routeNamed 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
componentList component opened as a modal on click (e.g. inventory::items-list) — also the fallback when route is not registered
argumentsArguments 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:

  1. The page footer's Save calls NoerdPage::store(), which dispatches storeDetail-{detail} for every embedded detail (suffix = the full component name from the YAML detail: / details: keys).
  2. The detail listens (via getListeners()), runs its normal store() — identical to a standalone save — and ends in finishStore($model).
  3. finishStore() (protected) dispatches detailStored-{detail} with the payload modelId and detail (the reporting component's name). For the primary detail the page adopts the id (embeddedDetailStored()), refreshes its $detailData snapshot (merge — page-owned keys survive), runs storeProcess() and finally the protected hook afterEmbeddedDetailStored($model).
  4. 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:

yaml
title: Warehouse
detail: inventory::warehouse-detail          # the primary: the page's own record
details:
  - inventory::warehouse-pricing-detail      # saved by the same Save button
blade
@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'))
@endif
  • embeddedDetailComponents() lists every embedded detail, the primary first (detail:, or the first entry of details:). store() dispatches storeDetail-* to all of them; the listeners detailStored-* / 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) and afterAdditionalDetailDataUpdated(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 own wire: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.yml and the embedded noerd-user-detail — the shipped page/detail pair in the setup app
  • The page.blade.stub rendered by noerd:make-resource (src/Commands/stubs/resource/) — the minimal skeleton
  • Trait mechanics: tests/Feature/NoerdPageTraitTest.php, tests/Feature/UserPageTest.php