Documentation menu
HomeDocsCustomizationOverride system

Override System

The override system lets you replace any Martis React component or layout, and hook your own logic into server-side CRUD operations, from your application. Everything can be customized without forking, and it is a core Martis differential.

Overview

Martis provides a 4-tier component resolution system that allows you to replace any field component, layout, or CRUD view at different granularity levels:

PriorityLevelScopeMethod
1 (highest)Explicit keySingle field instance->component('key') in PHP
2Per-resourceAll instances of a field in one resourceregisterResourceFieldDisplay()
3Global typeAll fields of a given typeregisterFieldDisplay()
4 (lowest)Built-in defaultFallbackPre-registered by Martis

The registry calls below run in your consumer extension (resources/js/martis-extensions/), which imports componentRegistry from @martis/runtime (v1.38.0+). It is the instance the SPA resolves from, also reachable as window.Martis.componentRegistry, which the auto-discovery entry uses to register the four buckets. On an extension scaffolded before v1.38.0, refresh the shim first: see Refreshing the extension scaffold after an upgrade.

1. Field Component Overrides

1.1 Global Type Override

Replace the component for all fields of a given type across every resource.

// resources/js/martis-extensions/index.ts
import { componentRegistry } from '@martis/runtime'
import { MyRatingDisplay, MyRatingInput } from './components/RatingField'
 
// All "number" fields now use MyRatingDisplay/MyRatingInput
componentRegistry.registerFieldDisplay('number', MyRatingDisplay)
componentRegistry.registerFieldInput('number', MyRatingInput)

1.2 Per-Resource Override

Replace the component only for a specific field in a specific resource.

// resources/js/martis-extensions/index.ts
import { componentRegistry } from '@martis/runtime'
import { StatusBadgeDisplay } from './components/StatusBadge'
 
// Only the "status" field in the "posts" resource uses StatusBadgeDisplay
componentRegistry.registerResourceFieldDisplay('posts', 'status', StatusBadgeDisplay)
componentRegistry.registerResourceFieldInput('posts', 'status', MyStatusInput)

1.3 Explicit Key Override (via PHP)

The most precise method — the PHP field declaration specifies which component to use. The frontend registers a component with the same key.

PHP (Resource):

// app/Martis/PostResource.php
public function fields(Request $request): array
{
return [
Text::make('title'),
Text::make('status')->component('status-badge'), // explicit key
Number::make('rating')->component('star-rating'),
];
}

TypeScript:

// resources/js/martis-extensions/index.ts
import { componentRegistry } from '@martis/runtime'
import { StatusBadge } from './components/StatusBadge'
import { StarRating } from './components/StarRating'
 
componentRegistry.register('status-badge', StatusBadge)
componentRegistry.register('star-rating', StarRating)

1.4 Per-Context Overrides

Override a field component differently for each view context (index, detail, create, update):

Text::make('status')
->overrideIndex(new Override('status-badge')) // badge on index
->overrideDetail(new Override('status-detail')) // rich view on detail
->overrideCreate(new Override('status-select')) // dropdown on create
->overrideUpdate(new Override('status-select')); // dropdown on update

2. Layout Overrides

Each resource can render its pages in a layout of its own (v1.38.0+). The layout wraps every page of the resource (index, lens, create, detail and update) inside the shell, so the sidebar and topbar stay, and receives the page as children. A resource with no registered layout renders its pages as before. Before v1.38.0 layoutRegistry.register() had no effect: nothing in the SPA read the registry.

// resources/js/martis-extensions/index.ts
import { layoutRegistry } from '@martis/runtime'
import { UserResourceLayout } from './layouts/UserResourceLayout'
 
// The "users" resource uses a custom layout
layoutRegistry.register('users', UserResourceLayout)

Layout component (outside the four auto-discovered buckets, since the call above registers it):

// resources/js/martis-extensions/layouts/UserResourceLayout.tsx
import type { LayoutProps } from '@martis/runtime'
 
export function UserResourceLayout({ children }: LayoutProps) {
return (
<div className="user-admin-shell">
<UserQuickStats />
{children}
</div>
)
}

Built-in layout presets (configured in config/martis.php):

PresetDescription
sidebarLeft sidebar navigation + top bar (default)
topnavTop navigation bar
minimalMinimal header, no sidebar

A resource layout lives inside the shell. To replace the shell itself, for every page, register a component under layout:shell in the component registry, or one piece under layout:sidebar, layout:topbar or layout:footer: see Shell piece-by-piece overrides.

3. CRUD View Overrides (Drawers)

Override how create, update, and detail views are rendered. The built-in drawer components render forms/details inside a sliding panel instead of a full page.

PHP Configuration

// app/Martis/PostResource.php
public function overrides(): array
{
return [
'create' => new Override('martis:drawer-create'),
'update' => new Override('martis:drawer-update'),
'detail' => new Override('martis:drawer-detail'),
];
}

Custom Drawer

Register a custom component to handle a CRUD action:

// resources/js/martis-extensions/index.ts
import { componentRegistry } from '@martis/runtime'
import { MyPostCreator } from './components/MyPostCreator'
 
componentRegistry.register('custom-post-creator', MyPostCreator)
// app/Martis/PostResource.php
public function overrides(): array
{
return [
'create' => new Override('custom-post-creator', ['wizardMode' => true]),
];
}

Override constructor + redirectAfter()

The Override class accepts an optional second argument for arbitrary params and exposes a redirectAfter() chainable method that controls where the user lands after a successful CRUD operation:

use Martis\Override;
use Martis\RedirectAfter;
 
public function overrides(): array
{
return [
'create' => (new Override('custom-post-creator', ['wizardMode' => true]))
->redirectAfter(RedirectAfter::DETAIL), // open the new record
'update' => (new Override('martis:drawer-update'))
->redirectAfter(RedirectAfter::INDEX), // back to the list
];
}

RedirectAfter enum cases: DETAIL · INDEX · EDIT · CREATE · DASHBOARD · STAY. A literal string ('detail', 'index', …) is accepted as a fallback for the same values. STAY keeps the drawer / page open after save — useful for "save and continue editing" workflows. With confirmUnsavedChanges() on, the update drawer then counts the values it saved as clean, and what was typed while the save ran as unsaved (v1.38.0+; before v1.38.0 closing it after the save asked to discard the changes just saved). The create drawer clears its form for the next record and mounts its fields again, so no input keeps the previous record's state, and counts the empty form as clean (v1.38.0+; before v1.38.0 an input that keeps state of its own could carry the previous record's over, and a drawer opened on a copy asked to discard the empty form).

DrawerSlot enum (typed slot keys)

Resource::overrides() returns an associative array keyed by slot. The bundled controllers accept either string keys ('create' | 'update' | 'detail' | 'quick') or the typed DrawerSlot enum — using the enum surfaces typos at compile time:

use Martis\Enums\DrawerSlot;
 
public function overrides(): array
{
return [
DrawerSlot::Create->value => DrawerOverride::create(),
DrawerSlot::Update->value => DrawerOverride::update(),
DrawerSlot::Detail->value => DrawerOverride::detail(),
DrawerSlot::Quick->value => DrawerOverride::quick(),
];
}

Both forms are accepted in the same return array — adopt the enum incrementally.

Override Props

All override components receive the OverrideProps interface:

interface OverrideProps {
schema: ResourceSchema // Full resource schema with fields
resource: string // Resource URI key
params: Record<string, unknown> // Custom parameters from Override()
record?: ResourceRecord | null // Current record (detail/update) or null (create)
recordId?: string | null // Record ID
fromResourceId?: string | number | null // Create only: the record a Replicate copies (v1.38.0+)
navigate: (to: string) => void // Navigation function
onClose: () => void // Close the drawer/panel
onCreated: (record: { id: string | number }) => void
onUpdated: (record: { id: string | number }) => void
onDeleted: () => void
onEdit: (id?: string | number) => void
onView: (id: string | number) => void
addToast: (type: string, message: string) => void
}

On a create override, fromResourceId names the record the form replicates: the detail page opens its create override with it for the Replicate action (together with that record), and /create?fromResourceId={id} passes it too. A custom create override reads the copy's values from GET /api/resources/{resource}/{id}/replicate, which applies authorizedToReplicate(), leaves File fields out and hides the fields the user may not see for the record, and sends the id back as fromResourceId with the create, so the server answers with replicatedMessage(). The bundled DrawerCreate does exactly that (v1.38.0+): it mounts its fields once the copy has arrived, shows the endpoint's error instead of a form when the record cannot be replicated, and sends fromResourceId with the first create only. Before v1.38.0 it copied the record's detail payload into the form (File paths and fields the replicate endpoint leaves out included, with no authorizedToReplicate() check), and a create override opened by /create?fromResourceId={id} got no copy at all.

A record lists under _hidden the attributes of the fields canSeeForModel() hides for it (v1.38.0), and carries no value for them: the schema describes the resource, not the record. The bundled pages and drawers leave those fields out (the detail page, DrawerDetail and DrawerQuick, the update page and DrawerUpdate, the cells of the index, a lens and a relationship panel). A custom override that renders schema.fieldsForDetail or schema.fieldsForUpdate against a record skips them the same way, so it does not render a hidden field empty (and an update form does not send it):

export function EmployeeCard({ schema, record }: OverrideProps) {
const hidden = new Set(record?._hidden ?? [])
const fields = (schema.fieldsForDetail ?? []).filter(
(item) => !('attribute' in item) || !hidden.has(item.attribute),
)
// ...render `fields`; walk the fields of a panel, section or tab the same way
}

Before v1.38.0 nothing told a hidden field from an empty one, and the bundled pages rendered it empty.

A host can hand a mounted override another record / recordId, or another resource's schema and resource, without remounting it: ActionDrawer does when Edit on another index row or an action response opens another record while its drawer is open, and the detail page does when its route moves to another record behind an open update drawer. An override that keeps form state has to seed it again for the record it now receives, or render its body with a key built from resource and recordId. Since v1.38.0 the bundled DrawerUpdate seeds its values, validation errors and dirty baseline again when the resource or the record changes; a fresh copy of the same record keeps the edits. The bundled DrawerCreate does the same since v1.38.0 when the resource or the record it replicates changes (an action response that opens another resource's create drawer while one is open, the detail page moving to another record behind an open Replicate drawer); before, it kept what was typed for the first target and created it in the second. The index page, for its part, closes its create drawer, the drawers opened from its rows or by an action, and its confirmations when its route moves to another resource's index (v1.38.0+); before, they stayed open, and the delete confirmation of a row then deleted the record with the same id in the new resource. The detail page keeps its drawers on the record in the URL but closes its delete, force-delete and restore confirmations and its action modal when that record changes, and a lens page starts over for each lens (v1.38.0+); before, confirming the delete opened for one record deleted the record the page had moved to.

Built-in Drawer Components

ComponentKeyDescription
DrawerCreatemartis:drawer-createSlide-in create form
DrawerUpdatemartis:drawer-updateSlide-in edit form
DrawerDetailmartis:drawer-detailSlide-in detail view
DrawerQuickmartis:drawer-quick⭐ Lightweight read-only quick-look (narrower, no actions). Distinct from the BelongsTo / MorphTo hover peek popover, which surfaces related-record metadata; DrawerQuick is for the current row.

DrawerDetail, DrawerUpdate and DrawerQuick tell the relationship panels inside them which record they belong to, since the page behind the drawer may not name it, and DrawerCreate tells them its record does not exist yet (see relationships.md § Which record a panel belongs to). A custom override does the same through @martis/runtime: see Naming the record of the relationship panels.

⭐ Reading override props from nested components — useOverrideProps()

When a custom override has its own internal component tree (header, sidebar, form sections), prop-drilling OverrideProps through every level is noisy. The useOverrideProps() hook exposes the same payload via React context — wrap once at the top of your override, read anywhere underneath:

import { useOverrideProps, OverridePropsProvider, type OverrideProps } from '@martis/runtime' // v1.38.0+
 
export function MyDrawerCreate(props: OverrideProps) {
return (
<OverridePropsProvider value={props}>
<MyHeader />
<MyForm />
</OverridePropsProvider>
)
}
 
function MyHeader() {
// No props passed — pulls from context
const { schema, onClose } = useOverrideProps()
return (
<header>
<h2>{schema.singularLabel}</h2>
<button onClick={onClose}>×</button>
</header>
)
}

The hook throws outside the provider so wiring bugs are loud. Use useOverridePropsOptional() (returns null) when an override component is shared between contexts where the provider may not exist.

The provider is opt-in — overrides that pass props manually keep working unchanged. It is the override's own: the package mounts none around the components it renders.

Drawer features:

  • Slide-in animation from left/right
  • Expand/collapse toggle
  • Fullscreen mode
  • Close on ESC or backdrop click
  • Responsive grid layout with field column spans
  • Browser back button closes the drawer (history integration)
  • Optional unsaved-changes confirmation — see Unsaved changes guard

Detail overrides open over the index

A resource with a detail override (DrawerOverride::detail(), or any custom component in the detail slot) opens its records over its index (v2.18.0). The row click and the default View row action push the record URL (/resources/{resource}/{id}), and the index stays mounted behind the override:

  • The list stays on screen and is not fetched again: opening costs the record's request only. The bundled drawer opens at once with its own skeleton while the record loads; a custom override component still receives the loaded record, so a light loader shows over the list until it arrives.
  • Closing (the close button, ESC, the backdrop, a delete, with or without a confirmation open over it) steps back to the index entry of the history, with the list's page, sort and filters as they were. The browser back button closes it the same way, and forward opens it again.
  • A direct visit to the record URL (a shared link, a reload) renders the index with the override on top as soon as the resource's schema has loaded (until then the page loader shows, since the schema says whether the resource has a detail override). Closing it moves to the index URL. A record reached by another link (a link in a cell, a relationship panel row, a lens row, the command palette) opens over the index the same way and closes like a direct visit.
  • The top progress bar does not run and the menu badges are not refreshed for an open or a close: it is the same page.
  • Edit inside the drawer swaps in the update override when the resource declares one, as before. Viewing another record of the resource from the override opens it in the same place, in place of the current one in the history.

The URL, the deep link and the back button work as they did, so nothing needs to change in a resource. A resource without a detail override keeps the full detail page, and so does a detail override whose component is not registered (a typo, an extension bundle that did not load). A resource whose records have a custom URL (config.resourceRecordUrls, e.g. a headless resource whose records live in a Tool) still navigates there from the row click; its own /resources/{resource}/{id} URL still renders the override over the index. Before v2.18.0 the row click navigated to a route that showed a full-page loader until the schema and the record arrived, then mounted the index again behind the drawer, and closing mounted it once more.

The browser tab follows the record while it is open: see documentTitle().

Unsaved changes guard

Both drawer-based create/update and the full-page ResourceCreate / ResourceUpdate routes can warn the user before discarding unsaved edits. The guard intercepts:

  • The drawer close button, backdrop click and ESC
  • The browser back button
  • Clicks on breadcrumb / in-app router links

Tab close and hard reload are not intercepted: the guard wires no beforeunload prompt, which doubled up with its own dialog.

On the full-page routes the guard holds the browser back button only while the form has unsaved changes (v1.38.0+). A clean form leaves the history alone, so Back and Forward move between pages as on any other page; once the user has typed something, Back asks first, and leaving the page after that can drop the Forward history, as editing a page does in any browser. Before v1.38.0 every visit to a create or edit page added an entry to the history, which erased the pages the user could go Forward to, and a clean form could make Back skip the previous page.

Opt in via Resource::confirmUnsavedChanges():

use Martis\Contracts\UnsavedChangesConfigContract;
use Martis\UnsavedChangesConfig;
 
// Enable with the package defaults (localised copy).
public static function confirmUnsavedChanges(): bool|UnsavedChangesConfigContract
{
return true;
}
 
// OR fully customised copy + visuals.
public static function confirmUnsavedChanges(): bool|UnsavedChangesConfigContract
{
return UnsavedChangesConfig::make()
->title(__('projects.unsaved_title'))
->body(__('projects.unsaved_body'))
->icon('briefcase')
->iconColor('info') // success | warning | danger | info | muted | accent
->confirmLabel(__('projects.unsaved_discard'))
->confirmColor('danger')
->cancelLabel(__('projects.unsaved_keep'));
}

Default: the method returns false on the base Resource — resources opt in explicitly. Return true to use the localised defaults shipped with the package, or an UnsavedChangesConfig instance to customise every piece of copy and colour. icon(null) hides the dialog icon.

DrawerOverride PHP API

Use the DrawerOverride class for chainable PHP configuration of the built-in drawers. Three static factories spin up a pre-keyed override — pick the one that matches the slot:

FactorySlot key it emitsDrawer component
DrawerOverride::create()martis:drawer-createDrawerCreate
DrawerOverride::update()martis:drawer-updateDrawerUpdate
DrawerOverride::detail()martis:drawer-detailDrawerDetail
use Martis\Enums\DrawerPosition;
 
public function overrides(): array
{
return [
'create' => DrawerOverride::create()
->width('600px')
->expandedWidth('900px')
->allowExpand()
->allowFullscreen()
->position(DrawerPosition::Right)
->showCloseButton()
->backdrop(false)
->subtitle('Fill in the details below')
->showIcon()
->iconColor('#6366f1'),
 
'update' => DrawerOverride::update()
->width('720px')
->allowExpand()
->subtitle('Editing this record'),
 
'detail' => DrawerOverride::detail()
->position(DrawerPosition::Left)
->backdrop(true),
];
}

All three factories return a DrawerOverride instance, so every chainable method below is shared.

Available methods:

MethodSignatureDescription
width()width(string $width)Set the collapsed drawer width (CSS value, e.g. '40rem'). Default: 720px (configurable via config('martis.drawer.width')).
expandedWidth()expandedWidth(string $width)Set the expanded drawer width. Default: 960px (configurable via config('martis.drawer.expanded_width')).
allowExpand()allowExpand(bool $value = true)Show the expand/collapse toggle button.
allowFullscreen()allowFullscreen(bool $value = true)Show the fullscreen toggle button.
showCloseButton()showCloseButton(bool $value = true)Show the close (×) button in the header.
position()position(DrawerPosition $position)Slide in from DrawerPosition::Right (default) or DrawerPosition::Left.
backdrop()backdrop(bool $value = true)Show a dimmed backdrop behind the drawer.
subtitle()subtitle(string $subtitle)Display a subtitle below the drawer title.
showIcon()showIcon(bool|string $value = true)Show an icon in the header. Pass a Phosphor icon name to use a specific icon.
iconColor()iconColor(string $color)Set the header icon color (any CSS color value).

On a phone (v2.18.2). width() and expandedWidth() are desktop sizes: a drawer is never wider than the viewport, and at 768px or less (the layout's mobile breakpoint) it takes the whole screen and drops the expand and fullscreen buttons, so nothing is cut off on the left. While any drawer is open the page behind it does not scroll, and closing the drawer brings the page back at the same scroll position; nested drawers share one lock. Before v2.18.2 a 720px drawer kept its width on a 390px screen and the page scrolled under it.

4. Server-Side Hooks

Lifecycle hooks execute custom logic before/after CRUD operations.

Resource Hooks

// app/Martis/PostResource.php
public function beforeSave(Model $model, Request $request, bool $creating): void
{
$model->slug = Str::slug($model->title);
parent::beforeSave($model, $request, $creating); // fires BeforeSave event
}
 
public function afterSave(Model $model, Request $request, bool $creating): void
{
Cache::forget("post:{$model->id}");
parent::afterSave($model, $request, $creating); // fires AfterSave event
}
 
public function beforeDelete(Model $model, Request $request): void
{
if ($model->status === 'published') {
// The message reaches the user as it is. A plain \RuntimeException
// would answer a generic message in production.
throw new \Martis\Exceptions\UserFacingException('Cannot delete published posts.');
}
parent::beforeDelete($model, $request); // fires BeforeDelete event
}

Event Listeners

Events are dispatched automatically when hooks call parent::hookName(). Use listeners for cross-cutting logic:

// app/Providers/AppServiceProvider.php
use Martis\Events\AfterSave;
use Martis\Events\BeforeDelete;
 
Event::listen(AfterSave::class, function (AfterSave $event) {
AuditLog::record('saved', $event->resourceClass, $event->model->id, $event->creating);
});
 
Event::listen(BeforeDelete::class, function (BeforeDelete $event) {
if ($event->model->is_protected) {
throw new \RuntimeException('Record is protected.');
}
});
EventTriggerProperties
BeforeSaveBefore $model->save()resourceClass, model, request, creating
AfterSaveAfter $model->save()resourceClass, model, request, creating
BeforeDeleteBefore $model->delete()resourceClass, model, request
AfterDeleteAfter $model->delete()resourceClass, model, request

5. Component Interface

Display Component

Every display component receives FieldDisplayProps. Examples below use the Tailwind preset so the override stays in sync with the active theme (light/dark, accent override, density).

import type { FieldDisplayProps } from '@martis/runtime'
 
export function StatusBadge({ field, value }: FieldDisplayProps) {
const label = String(value ?? '')
// Map domain status to a semantic Martis tone.
const tone = label === 'published' ? 'success' : 'info'
 
return (
<span
className={
'inline-flex items-center rounded-martis-full px-2 py-0.5 text-martis-xs font-martis-medium ' +
(tone === 'success'
? 'bg-martis-success-bg text-martis-success'
: 'bg-martis-info-bg text-martis-info')
}
>
{label}
</span>
)
}

FieldDisplayProps carries, besides field and value (v2.16.0+, all optional):

PropValue
resourceKeyThe resource the record belongs to (the related resource in a relationship panel's row).
resourceIdThe record's key, when the display renders a record (a detail page, an index or relationship row, a drawer).
context'index' or 'detail', where the display renders.

Nova passes the same to its index and detail field components (resourceName, resourceId). A display that acts on its record reads them instead of carrying the resource key and id inside the field's value.

Running an action from a component (v2.16.0+)

A display, a Tool or a card can run one of the resource's actions with useRunAction() from @martis/runtime. It goes through the same request and the same answer handling as the action modal: the toast with the message, a redirect, a download, a modal answer or a new tab, and martis:action-executed. Keep an action meant only for a component off every menu with exceptOnIndex()->exceptOnDetail()->exceptInline() (see Actions → Actions run only from your own components): it keeps canSee(), canRun(), the policy and the action events.

import { useState } from 'react'
import { useRunAction, type FieldDisplayProps } from '@martis/runtime'
 
export function TerminalLinks({ value, resourceKey, resourceId }: FieldDisplayProps) {
const runAction = useRunAction()
const [errors, setErrors] = useState<Record<string, string>>({})
 
async function save(url: string) {
const result = await runAction({
resource: resourceKey!,
id: resourceId,
action: 'update-terminal-links',
fields: { url },
})
setErrors(result.ok ? {} : result.errors)
}
 
// ...render the links, an input and `errors.url`
}

runAction(options) takes:

OptionMeaning
resource, actionThe resource key and the action's uriKey.
id / idsThe record, or the records of a bulk run (id wins). Leave both out for a standalone action.
fieldsThe values of the action's fields, by attribute.
lens, viaRun the action as a lens, or as a relationship panel ({ viaResource, viaResourceId, viaRelationship }), runs it.
handleResponsetrue by default. false returns the answer without showing it (no toast, redirect or refresh).

An ActionResponse::openCreate() / openDetail() / openUpdate() answer, which the modal turns into a drawer of its page, has no drawer to open from a component: it ends like a plain success (the toast and the refresh). It never throws. It resolves to { ok: true, response }, where response is the action's answer, or to { ok: false, message, errors }, where errors maps each failing field's attribute to its first message (the 422 fields. prefix removed) and a toast shows message when handleResponse is on. After a successful run it refreshes the record (one id) or the resource's lists (several ids), as the modal does on its page.

useRefreshRecord() returns refreshRecord(resourceKey, id?), which refetches that record wherever the panel shows it (the detail page, its drawer, its update form) and the resource's index lists, so the other fields of the page show what a component changed. Without id every record of the resource is refetched:

import { useRefreshRecord, type FieldDisplayProps } from '@martis/runtime'
 
export function InlineToggle({ resourceKey, resourceId }: FieldDisplayProps) {
const refreshRecord = useRefreshRecord()
// ...after your own request changed the record:
// refreshRecord(resourceKey!, resourceId)
return null
}

Nova has no core equivalent: a detail field there listens for its own events. Republish the extension shims so tsc sees the new hooks and types: php artisan vendor:publish --tag=martis-extension-shims --force.

Input Component

Every input component receives FieldInputProps:

import type { FieldInputProps } from '@martis/runtime'
 
export function StatusSelect({ field, value, onChange, error }: FieldInputProps) {
return (
<div className="flex flex-col gap-1">
<select
value={String(value ?? '')}
onChange={(e) => onChange(e.target.value)}
className={
'rounded-martis-md bg-martis-input-bg text-martis-text px-2 py-1 ' +
(error ? 'border border-solid border-martis-danger' : 'border border-solid border-martis-border')
}
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
{error && <small className="text-martis-danger">{error}</small>}
</div>
)
}

If you opted out of the Tailwind preset, the same effect works with inline styles (style={{ color: 'var(--martis-danger)' }}) or the bundled helper classes (.martis-text, .martis-border). Either way, don't hard-code colours like bg-red-500 — they don't follow the active theme. A border utility needs its style next to it (border border-solid): the bundled CSS has no Tailwind preflight to supply one (see Borders).

An input whose value holds other values (rows, items with fields of their own) also receives nestedErrors: the server errors of the values inside its value, keyed by their path below the field's attribute. A 422 on the name field of row 1 of a lines Repeater (lines.1.fields.name) reaches the lines input as nestedErrors = { '1.fields.name': 'The Name field is required.' }, while error keeps the field's own message. The bundled Repeater shows each under the matching row field. An input that renders other inputs passes each child its own error and the entries below child., so a nested input gets its errors too. Every bundled form and useMartisForm().fieldProps() fill the prop (since v1.38.0); an input with a scalar value can ignore it.

value can change after the input mounts. The edit forms (the update page and the update drawer) mount the fields once the record has filled the form, so an input gets the stored value on its first render. When the form moves to another record (the update page follows the record in the URL, the update drawer the record its host hands it), the fields mount again with that record's values, and the create page does the same when it moves to another resource, parent or record to replicate, so no input state carries from one form to the next. "Create & add another" mounts the fields again as well, with the empty values of the next record (v1.38.0+; before v1.38.0 it cleared the values under the mounted inputs, and an input that could not tell the cleared value from its own last one carried the previous record's state over). The replicate form, too, mounts its fields once the copy has filled it (v1.38.0+; before v1.38.0 the copy arrived one render after the fields mounted). The value can still change under a mounted input: a nested create form fills its parent in once the parent has loaded, and a Tool form can replace its values with setValues(). Render from value where you can. An input that keeps its own state (rows, a selection, a preview) has to adopt a value it did not emit itself, and keep its state when the form hands back what it just emitted:

import { useEffect, useRef, useState } from 'react'
 
const toChips = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : [])
 
export function ChipsInput({ value, onChange }: FieldInputProps) {
const [chips, setChips] = useState(() => toChips(value))
// The last value this input handed to `onChange`.
const emitted = useRef<unknown>(value)
 
useEffect(() => {
if (value === emitted.current) return // the form handing back our own value
emitted.current = value // a value from outside: the record, a cleared form
setChips(toChips(value))
}, [value])
 
function emit(next: string[]) {
emitted.current = next
setChips(next)
onChange(next)
}
 
// ... render `chips`, call `emit(...)` on every edit
}

The bundled KeyValue, Tag, Avatar, BelongsTo, Repeater and (v1.38.0+) Sparkline inputs follow this pattern, and Slug uses it to follow its source again once the form clears it. When the fields inside your input can emit while they mount (a slug generating itself from a default), compare during render instead of in an effect, as Repeater does: child effects run before the parent's.

The comparison only sees a value that changes. A form cleared back to the very value your input emitted last (null after its own Clear button) hands it nothing new, so keep no state that value does not carry, or that the rest of the form can tell you about: a slug the user emptied by hand is null before and after the form is cleared, and Slug follows its source again once the source is empty too (v1.38.0+).

Several of those fields can also emit before the form hands your value back (on an edit form, every stored row whose slug generates itself from its source), and each of their handlers still sees the value of your last render, so an update built on that value drops the updates before it. Build each update on the value you emitted last, until the form hands you a new one, as Repeater does since v1.38.0. Tell the two apart with a token for each value your input receives, not with the value itself: a form cleared back to null hands an input that started empty the very value its first emission started from, and the comparison would take the cleared form for one that has not handed anything back yet. Before v1.38.0 this snippet compared the value, and so did Repeater, which brought the previous record's rows back into the next one after "Create & add another":

import { useMemo, useRef } from 'react'
 
type Row = Record<string, unknown>
const toRows = (v: unknown): Row[] => (Array.isArray(v) ? v.map((row) => ({ ...row })) : [])
 
// Next to `emitted`: a token for each `value` the form hands in, and the
// token of the value the last emission started from.
const valueToken = useMemo(() => ({ value }), [value])
const emittedFrom = useRef<object | null>(null)
 
function emit(next: Row[]) {
emittedFrom.current = valueToken
emitted.current = next
onChange(next)
}
 
// A fresh copy of the rows the next update builds on.
const latestRows = () => toRows(valueToken === emittedFrom.current ? emitted.current : value)
 
function setRowField(index: number, attribute: string, fieldValue: unknown) {
const next = latestRows()
next[index] = { ...next[index], [attribute]: fieldValue }
emit(next)
}

5.A Composing native field components (v1.14.0+)

The two sections above show how to replace a field renderer. The opposite direction — composing the canonical Martis field renderer from inside your own custom component (a custom Action component, a Tool, a Card) — is supported via the consumer-extension runtime.

Since v1.14.0, @martis/runtime exposes:

ExportShape
FieldInputThe form-mode renderer. Receives the same FieldInputProps documented in section 5.
FieldDisplayThe read-mode renderer. Receives FieldDisplayProps.
apiPath, pathSegment, routePath, withQuery (v2.4.0)The path builders the panel's own pages use. api.get(apiPath/api/findings/${id}) encodes every interpolated value as one path segment (a slash is spelt %252F, since Laravel decodes %2F before it routes), pathSegment(id) encodes one value, withQuery(path, query) appends a built query string and routePath builds a link of the SPA's own router. Use them instead of encodeURIComponent(). A path value that holds a literal %2F throws an ApiError (status 400). See Custom pages.
FieldDefinition (type)Re-exported so you can type your field payload without reaching into internal paths.
FieldDisplayProps, FieldInputProps (types)Re-exported for the same reason.
useRunAction, useRefreshRecord (v2.16.0)Run a resource's action from a component through the modal's code path, and refetch a record a component changed. Types RunAction, RunActionOptions, RunActionResult, RefreshRecord. See Running an action from a component.
DrawerShellGeneric slide-over drawer shell. Host edit/add/detail forms (composed from FieldInput) in a native drawer; you control open/close from your own state, like a modal.
DrawerShellProps (type)Props for DrawerShell: title, subtitle?, icon?, onClose, children, …
TooltipThe PrimeReact Tooltip component, for React content in a tooltip (JSX content), since the extension build doesn't alias primereact. The global [data-pr-tooltip] provider renders plain text, or markup you write when the trigger is registered with htmlTooltip() (sanitised): see Tooltip Standard.
htmlTooltip, trustHtmlTooltip (v2.4.0)htmlTooltip(markup, position?) returns the props (data-pr-tooltip, data-pr-tooltip-html, data-pr-position and a registering ref) that make an element a trigger of the global tooltip that shows HTML. trustHtmlTooltip is the ref callback for an element that has a ref of its own. An element that is not registered shows its data-pr-tooltip as plain text, whatever data-pr-tooltip-html says.
HtmlTooltipProps (type)The props htmlTooltip() returns.
Dropdown, MultiSelect (v1.29.0)The exact PrimeReact controls Martis's own filters use. Apply the martis-filter-dropdown class for the compact filter look. Lets a Tool render pixel-identical single/multi filters without bundling a second copy of PrimeReact.
createPortal (v1.29.0)react-dom's createPortal, for overlays: the host's, so the portal renders with the host's React DOM. Since v1.38.0 import { createPortal } from 'react-dom' reaches the same function: the extension build sends react-dom to a shim that serves the host's public React DOM API (see The react-dom shims).
flushSync (v1.38.2)react-dom's synchronous flush, the host's. Third-party libraries import it from react-dom (@tanstack/react-virtual calls it while scrolling a virtualised list), and the react-dom shim re-exports it; in v1.38.0 and v1.38.1 the shim did not, and such a library stopped the build.
ResourceIcon, ResourceIconProps (v2.10.0)An icon by name, <ResourceIcon iconName="rocket-launch" size={16} />, resolved through iconRegistry: the curated Phosphor set, the lazily loaded rest and the icons the app registered. An extension needs no @phosphor-icons/react copy of its own. See components.md § ResourceIcon.
DropdownProps, MultiSelectProps (types)Re-exported so you can type the controls above without reaching into primereact/*.
NestedParentProvider (v1.38.0)Names the record whose related records the relationship panels inside list, when the page URL does not name it; id: null on a create form. See Naming the record of the relationship panels.
NestedParent (type)The provider's value: { resource: string; id: string | number | null }.

Import each one by name from @martis/runtime, which your extension build resolves to resources/js/martis-extensions/.shims/runtime.mjs. martis:install publishes that file once and composer update does not refresh it, so on an extension scaffolded before the version that added a name the build fails with "Dropdown" is not exported by "resources/js/martis-extensions/.shims/runtime.mjs": see Refreshing the extension scaffold after an upgrade. Their types come with the scaffold (.shims/runtime.d.mts, v1.38.0): see Type-checking your extensions.

Showing a validation error in a custom input (v2.23.0+)

A custom field input registered with componentRegistry receives the server's validation message as its error prop. The forms render nothing around it, so the input draws the message itself. @martis/runtime exports the helpers the built-in inputs use:

  • useFieldError(error) returns { hasError, message, errorId, invalidProps }. Spread invalidProps on the input (aria-invalid and aria-describedby while there is an error, nothing otherwise).
  • <FieldError error={message} id={errorId} /> draws the message with the warning icon, in the martis-input-error style, as an alert. It renders nothing when the message is empty.
import { FieldError, useFieldError } from '@martis/runtime'
 
export function SlugInput({ value, onChange, error }) {
const { errorId, invalidProps } = useFieldError(error)
 
return (
<>
<input value={value ?? ''} onChange={(e) => onChange(e.target.value)} {...invalidProps} />
<FieldError error={error} id={errorId} />
</>
)
}

The forms still render errors as before: this is a helper, and an input that already draws its own message keeps working unchanged.

Example — Select inside a custom Action component

import { useState } from 'react'
import { FieldInput } from '@martis/runtime'
 
const docTypeField = {
type: 'select',
attribute: 'document_type',
label: 'Document type',
options: [
{ value: 'note', label: 'Note' },
{ value: 'transcript', label: 'Transcript' },
{ value: 'screenshot', label: 'Screenshot' },
],
}
 
export function CandidateReview() {
const [docType, setDocType] = useState<string>('note')
return (
<div className="martis-form-stack">
<FieldInput
field={docTypeField as any}
value={docType}
onChange={(v) => setDocType(v as string)}
/>
</div>
)
}

The component you receive is the real Martis renderer — same look, same behaviour, same i18n. No duplicate CSS, no rebuilt search, no drift over time. If a host project has registered an override for select, FieldInput will resolve to the override for free.

Need a whole form, not a single field? For form-level behaviour inside a Tool — shared values state across fields, the dependsOn sync, per-field error clearing, and the tab_group/section/panel render loop — use the useMartisForm + FieldsForm harness (and optionally declare the fields in PHP with Tool::fields()). See tool-fields.md. The two caveats below apply there too.

Example — a Tool hosting fields in a native drawer

DrawerShell is the bare, resource-agnostic slide-over used by the built-in martis:drawer-* wrappers. A Tool can mount it directly to host its own edit/add/detail form composed from FieldInput, controlling open/close from its own state:

import { useState } from 'react'
import { DrawerShell, FieldInput } from '@martis/runtime'
 
export function ReviewTool() {
const [open, setOpen] = useState(false)
const [docType, setDocType] = useState('note')
if (!open) return <button onClick={() => setOpen(true)}>Review</button>
return (
<DrawerShell title="Review candidate" onClose={() => setOpen(false)}>
<div className="martis-form-body martis-form-stack">
<FieldInput
field={{ type: 'select', attribute: 'document_type', label: 'Document type', options: [] } as any}
value={docType}
onChange={(v) => setDocType(v as string)}
/>
</div>
</DrawerShell>
)
}

Example — a filter that matches the native look (v1.29.0)

A Tool that renders its own filter bar can reach for the same Dropdown / MultiSelect Martis's built-in filters use, instead of hand-replicating PrimeReact's DOM. Add the martis-filter-dropdown class for the compact look:

import { useState } from 'react'
import { Dropdown } from '@martis/runtime'
 
export function StatusFilter() {
const [status, setStatus] = useState<string | null>(null)
return (
<Dropdown
className="martis-filter-dropdown"
value={status}
options={[
{ label: 'Active', value: 'active' },
{ label: 'Archived', value: 'archived' },
]}
onChange={(e) => setStatus(e.value)}
placeholder="Status"
showClear
/>
)
}

createPortal (exported by @martis/runtime, and since v1.38.0 by the extension's react-dom shim) is available for overlays that must escape a clipped or overflow: hidden container.

The react-dom shims (v2.10.0)

An extension build sends react-dom and react-dom/client to shims that re-export the host's copy of the public React DOM 18 API, the part libraries import and React 19 keeps, so a portal, a batched update or a root an extension (or a library it bundles) creates runs on the host's React DOM instead of a second copy:

SpecifierNames
react-domcreatePortal, flushSync, unstable_batchedUpdates, version, and the default export holding the same names
react-dom/clientcreateRoot, hydrateRoot, and the default export holding the same names

@dnd-kit/core imports unstable_batchedUpdates from react-dom, which the shim did not export before v2.10.0: npm run build:extensions stopped with "unstable_batchedUpdates" is not exported by ".shims/react-dom.mjs", and react-dom/client had no alias.

The legacy root APIs are not exposed: render, hydrate, findDOMNode, unmountComponentAtNode and unstable_renderSubtreeIntoContainer. React 18 deprecated them and React 19 removed them, so an extension that used them would break when the host moves on. An import of one stops the build ("render" is not exported by ".shims/react-dom.mjs") and tsc reports it first. Use createRoot from react-dom/client.

The shims read the host's functions off window.Martis.reactDom and window.Martis.reactDomClient. createPortal and flushSync stay named exports of @martis/runtime, so an extension that imports them from there, or one built with the shims of v1.38.0 to v2.9.x, keeps building and running. To use the new names an app republishes its shims and brings its Vite config and tsconfig.extensions.json up to date (the react-dom/client alias and path): see Refreshing the extension scaffold.

Naming the record of the relationship panels (v1.38.0+)

A relationship field rendered through FieldDisplay or FieldInput (a HasMany, MorphMany, BelongsToMany or MorphToMany panel, a HasOne / MorphOne card) lists the related records of the record it belongs to: the nearest record named with NestedParentProvider, else the record in the page URL (/resources/{resource}/{id}). The bundled drawers and cards name theirs (see relationships.md § Which record a panel belongs to). A custom override that shows a record the URL may not name (a drawer an action, a lens row or an index row opens, a card of another record) names it the same way:

import { FieldDisplay, NestedParentProvider } from '@martis/runtime'
import type { FieldDefinition, OverrideProps } from '@martis/runtime'
 
export function ProjectSummaryDrawer({ schema, resource, record, recordId }: OverrideProps) {
const fields = (schema.fieldsForDetail ?? []) as FieldDefinition[]
return (
<NestedParentProvider value={{ resource, id: recordId ?? null }}>
{fields.map((field) => (
<FieldDisplay
key={field.attribute}
field={field}
value={record?.[field.attribute] ?? null}
resourceKey={resource}
context="detail"
/>
))}
</NestedParentProvider>
)
}

Without the provider the panels read the page's record: on /resources/team-members/2, a drawer showing project 3 would ask /api/resources/team-members/2/has-many/tasks for the project's tasks (404, or team member 2's own rows when team members declare the same relationship).

A custom create override names no record, since it does not exist yet: pass id: null. A BelongsToMany / MorphToMany panel with no record renders nothing and asks nothing (the schema keeps both fields off create forms anyway). Without the provider, a create override opened over a record's page (a Replicate, an action response) would hand its panels that record.

<NestedParentProvider value={{ resource, id: null }}>
{/* the create form */}
</NestedParentProvider>

The nearest provider wins, so a provider inside another one (a create modal opened from a record drawer) names the record of its own subtree. Type the value with the re-exported NestedParent.

An extension scaffolded before v1.38.0 has a .shims/runtime.mjs without the NestedParentProvider export: see Refreshing the extension scaffold after an upgrade.

Caveats

1. Relation pickers take their scope from the props you pass.

BelongsTo, MorphTo and Tag load their options from /api/resources/{resource}/{id}/relatable/{attribute}. {resource} is resourceKey, else the resource of the page. {id} is recordId; without one, context="create" sends _ (the create forms), and any other input uses the page's record only when it is scoped to the page's own resource (_ otherwise). In a custom Action component, pass actionEndpoint (built with apiPath from @martis/runtime, v2.4.0+) so the pickers read the Action's own declaration of the field:

<FieldInput
field={field}
value={values[field.attribute] ?? null}
onChange={(v) => setValue(field.attribute, v)}
context="create"
actionEndpoint={apiPath`/api/resources/${resource}/actions/${action.uriKey}`}
/>

When the component runs the action on one record, also pass actionQuery (v2.16.0+), the query string that names it, resourceId={id} (with viaResource, viaResourceId and viaRelationship through a relationship panel): the pickers send it to the options endpoint, so a relatableQueryUsing() that reads the Action's selectedModel() sees the record, as in the bundled modal. Without it the endpoint resolves the field with no record.

A custom modal that renders a relationship's pivot fields passes pivotEndpoint the same way: /api/resources/{resource}/{id}/{belongs-to-many|morph-to-many}/{relationship}/pivot-fields to attach, plus /{relatedId} to edit the pivot row of an attached record (v1.38.0+). A custom container that renders the fields of a Repeater row passes repeaterRow={{ repeater: '<Repeater attribute>', repeatable: '<row type>' }} next to the form's own scope, so the pickers and the remote Select search name the row the server reads the field from (see Repeater → Relation pickers and remote selects in rows).

With no resource at all (a Tool page without resourceKey), the input falls back to the context-free /api/resources/_/_/relatable/{attribute}?related_resource={uriKey}, so the FieldDefinition must carry relatedResource (the target resource's uriKey) for the server to resolve the relatable query. For pure enum dropdowns prefer select: it has no async dependency and works anywhere.

2. Consumer bundles hosted outside the Martis shell need the published stylesheet.

The field components rely on the martis-* class namespace defined in the package's stylesheet, which the host SPA already loads. If your extension renders inside Martis pages (the normal case — registered via componentRegistry), you inherit the styles for free. If your bundle runs in a context outside the shell, ensure vendor/martis/assets/app-*.css is also loaded.

Why this layer, not the individual *FieldInput components

FieldInput is a stable single entry point that resolves through componentRegistry — overrides, lazy-loading, per-context resolution all keep working. Exposing each of the 46 internal *FieldInput components would freeze them as part of the public contract and prevent refactor. Reach for FieldInput first; open an issue if the routing layer is the wrong shape for your use case.

6. Creating Custom Components (Artisan)

Use the martis:component artisan command to scaffold an override TSX (alias: martis:override, kept for back-compat). Each --type option writes a different starter — see the stubs directory on GitHub for the full source of every scaffold.

Auto-registration scope (v1.10.1+)

Every --type value is now zero-config. The bundle's auto-discovery loop covers two paths:

(a) Canonical layout / auth slots — fixed filename → key map:

FilenameRegistered key
Shell.tsxlayout:shell
Sidebar.tsxlayout:sidebar
Topbar.tsxlayout:topbar
Footer.tsxlayout:footer
TopbarStart.tsxtopbar:start (v2.7.0, see Top-bar slots)
TopbarEnd.tsxtopbar:end (v2.7.0)
LoginPage.tsxauth:login
RegisterPage.tsxauth:register
ForgotPasswordPage.tsxauth:forgot-password
ResetPasswordPage.tsxauth:reset-password
EmailVerifyNoticePage.tsxauth:email-verify-notice
PasswordChangePage.tsxauth:password-change
(no fixed-filename scaffold yet)auth:invitation-accept
(no scaffold; a widget, not a page)auth:challenge (v2.21.0, the captcha widget of the public forms)

The SPA router resolves these hard-coded strings, so the map cannot be filename-derived. auth:invitation-accept has no martis:component --type= scaffold yet — register a replacement under this key directly from your extension bundle (componentRegistry.register('auth:invitation-accept', MyScreen)); the resolution mechanism is identical to the scaffolded slots above, it just skips the auto-discovery step. See invitations.md § Overriding the screen.

auth:challenge is not a page: it is the widget the Login, Forgot password, Reset password, Register and invitation-accept forms render while martis.auth.challenge.enabled is on. Register it directly (componentRegistry.register('auth:challenge', MyWidget)); it receives { siteKey, surface, onToken, resetSignal }. An override of a form page keeps the challenge with useAuthChallenge and AuthChallenge, both exported by @martis/runtime (v2.21.0), instead of re-implementing the token wiring. See authentication.md § Challenge (captcha) hook.

If you only need to change copy, not markup, every page above (including the invitation-accept screen) also supports the lighter-weight config('martis.auth.copy.*') override — for invitation-accept, config.auth.copy.invitation_accept.title / .subtitle — before reaching for a full component override. See authentication.md § Customising the auth copy.

(b) Generic / field-shape overrides — filename derives the key:

FilenameRegistered key(s)Export shape
StatusBadge.tsxstatus-badgedefault export → single component
StatusBadge.tsx (with named Display + Input)status-badge + status-badge-inputfield-shape pair
RichBio.tsxrich-biodefault export → single component

The PHP Override('status-badge') / Override('status-badge-input') strings match what martis:component --type=field StatusBadge produces. No manual OVERRIDE_KEYS extension required since v1.10.1.

Recommendation: when you want a brand-new field type with matching PHP class, prefer martis:field <Name>: that generator writes the PHP Field subclass too and routes the component through the bundle's fields/ bucket, which registers it as the display and input of the field type the PHP class declares (field:display:{kebab} / field:input:{kebab}) instead of under the bare {kebab}. Use martis:component --type=field only when you want to override the visual of an existing field (Text, Select, etc.) without introducing a new PHP field.

--typeStub source
fieldstubs/component-field.tsx.stub
shellstubs/component-shell.tsx.stub
sidebarstubs/component-sidebar.tsx.stub
topbarstubs/component-topbar.tsx.stub
footerstubs/component-footer.tsx.stub
genericstubs/component-generic.tsx.stub
login-pagestubs/component-login-page.tsx.stub
register-pagestubs/component-register-page.tsx.stub
forgot-password-pagestubs/component-forgot-password-page.tsx.stub
reset-password-pagestubs/component-reset-password-page.tsx.stub
email-verify-notice-pagestubs/component-email-verify-notice-page.tsx.stub
cardstubs/component-card.tsx.stub
php artisan martis:component StatusBadge --type=field
php artisan martis:component AcmeShell --type=shell
php artisan martis:component AcmeSidebar --type=sidebar
php artisan martis:component AcmeTopbar --type=topbar
php artisan martis:component AcmeFooter --type=footer
php artisan martis:component InfoPanel --type=generic
 
# Generate all four shell pieces at once (prefix defaults to "Custom"):
php artisan martis:component --type=complete-layout
# → CustomShell.tsx, CustomSidebar.tsx, CustomTopbar.tsx, CustomFooter.tsx
 
# Or pass a prefix to namespace them:
php artisan martis:component Acme --type=complete-layout
# → AcmeShell.tsx, AcmeSidebar.tsx, AcmeTopbar.tsx, AcmeFooter.tsx

The command:

  1. Creates the React component file at resources/js/martis-extensions/overrides/{ClassName}.tsx.
  2. For shell pieces and auth pages, the file is published with the canonical fixed filename (Shell.tsx, Sidebar.tsx, LoginPage.tsx, etc.) so the bundle's OVERRIDE_KEYS map registers it automatically. The user-supplied name argument is ignored for these fixed slots — they exist exactly once each.
  3. For --type=generic and --type=field the user-supplied name becomes the filename; the bundle's auto-discovery loop derives {kebab(name)} (and {kebab(name)}-input for the field-shape pair) and registers each half automatically. v1.10.1+.
  4. Shell stubs document the exact props the shell injects (collapsed state, mobile drawer callbacks, navigation payload from /api/navigation) so you can skip reading the source. The sidebar stub types that payload with NavigationGroup / NavigationItem from @martis/runtime and lists the items of a nested menu group (type: 'group') under its label (v1.38.0; the v1.9.3 stub drew such a group as a link).

martis:component --type=field only scaffolds TSX. To create a brand-new field type with matching PHP class + React display/input, use php artisan martis:field <Name> instead: that command writes both app/Martis/Fields/<Name>Field.php and resources/js/martis-extensions/fields/<Name>.tsx. Use martis:component --type=field when you just want to override the visual of an existing field (Text, Badge, etc.) without introducing a new PHP field.

Arguments:

ArgumentRequiredDescription
nameRequired for all types except complete-layoutPHP StudlyCase name. Becomes the React export name and — kebab-cased — the registry key for field/generic types. For complete-layout it becomes the prefix (e.g. Acme → AcmeShell, AcmeSidebar, …). Optional for complete-layout; defaults to Custom.
--typeOptional, defaults to genericSee table below.
--forceOptionalOverwrite an existing file. Without it, the command aborts when the destination already exists.

Component types (--type):

TypeWhat it generatesRegistry key
fieldDisplay + Input pair for overriding a field visual{kebab-name} + {kebab-name}-input
shellEntire shell replacement (composes sidebar + topbar + content + footer)layout:shell
sidebarLeft nav column onlylayout:sidebar
topbarTop bar onlylayout:topbar
footerPage footer onlylayout:footer
complete-layoutAll four shell pieces at once (shell + sidebar + topbar + footer), each under its default keylayout:shell, layout:sidebar, layout:topbar, layout:footer
login-pageCustom login page (replaces the bundled one)auth:login
register-pageCustom registration pageauth:register
forgot-password-pageCustom "forgot password" pageauth:forgot-password
reset-password-pageCustom "reset password" pageauth:reset-password
email-verify-notice-pageCustom email verification notice pageauth:email-verify-notice
password-change-pageCustom forced password change page (v2.3.0)auth:password-change
genericFree-form component{kebab-name}

The six auth-page types follow the same wiring as the shell pieces — generate the TSX, build, and the bundled login / register / password-reset / email-verify pages are automatically replaced. The router resolves exactly the keys in the table (auth:login, auth:register, auth:forgot-password, auth:reset-password, auth:email-verify-notice, auth:password-change, plus auth:invitation-accept for the invitation page), so a hand-written register() call must use them as they are. Each type writes a fixed file name (overrides/LoginPage.tsx, ...), whatever name you pass. See authentication.md for the broader auth customisation surface (backend handlers, blade templates, OAuth providers). The router reads these keys when it is built, after the extension bundles load (v2.2.0+; before, it read them ahead of the bundle and the bundled page always rendered). To check an override, load its page: componentRegistry.has() only shows that the key is registered.

After creating a component, rebuild the consumer extension bundle:

npm run build:extensions

That runs vite build --config vite.extensions.config.ts, which is published into your app by martis:install. The bundle is emitted at public/vendor/martis-user/extensions.js and Martis loads it at runtime through the URL listed in MARTIS_EXTENSIONS (also set by martis:install). The auto-discovery entry walks the four buckets under resources/js/martis-extensions/ and registers every .tsx against window.Martis.componentRegistry — no manual boot.ts, no MARTIS_USER_DIR, no symlink.

No source map by default (v2.4.0+). The bundle lands under public/, the web root, so a source map written beside it (extensions.js.map) would be served to everyone and hand them the original TSX of every tool, field, card and override, comments included. The published config sets build.sourcemap: false. To debug a build, set it to true (the map is written and linked from the bundle) or to 'hidden' (the map is written, the bundle does not link it: upload it to your error tracker, then delete it from public/vendor/martis-user/ before deploying). An app that published the config before v2.4.0 keeps sourcemap: true (the scaffold is copied once): set build.sourcemap to false in vite.extensions.config.ts, rebuild, and delete the extensions.js.map already under public/vendor/martis-user/.

The password-reset, invitation and magic-link emails carry their one-time token (and, for reset and magic link, the email) in the URL fragment since v2.6.0, never in the path or the query string, so access logs never record it (see authentication.md § One-time links keep the token out of the request line). The routes are /reset-password, /invitations/accept and /magic-link/confirm, without a :token segment. An override of auth:reset-password or auth:invitation-accept reads the link with useAuthLinkParams() from @martis/runtime:

import { useAuthLinkParams } from '@martis/runtime'
 
export default function CustomResetPasswordPage() {
const { token, email } = useAuthLinkParams() // '' when the link lacks one
// ... POST /api/auth/password/reset { token, email, password, password_confirmation }
}

The hook reads the fragment once and drops it from the address bar, so the token does not stay on screen or in a bookmark. parseAuthLinkFragment(hash) parses a fragment string without the hook. An override written before v2.6.0 that reads useParams().token (or useSearchParams().get('email')) gets nothing: switch it to the hook. The reset-password-page scaffold uses it since v2.6.0. An extension whose shims were published before v2.6.0 republishes them first (php artisan vendor:publish --tag=martis-extension-shims --force), or reads runtime.useAuthLinkParams off the shim's default export.

Shell piece-by-piece overrides

Replace any of the three shell pieces (Sidebar, Topbar, Footer) without touching the rest. Two equivalent wiring options — pick the one that matches your workflow:

Option A — JS boot file (register directly under the default key):

// resources/js/martis-extensions/index.ts
import { componentRegistry } from '@martis/runtime'
import { MyTopbar } from './components/MyTopbar'
import { MyFooter } from './components/MyFooter'
 
componentRegistry.register('layout:topbar', MyTopbar)
componentRegistry.register('layout:footer', MyFooter)

Option B — PHP config + any registry key (useful when multiple candidates are registered and PHP owns the selection, e.g. for feature-flagged deploys):

// resources/js/martis-extensions/index.ts
import { componentRegistry } from '@martis/runtime'
import { MyTopbar } from './components/MyTopbar'
import { MyFooter } from './components/MyFooter'
 
componentRegistry.register('my-topbar', MyTopbar)
componentRegistry.register('my-footer', MyFooter)
// config/martis.php
'layout' => [
'preset' => 'sidebar',
'components' => [
'shell' => null, // whole shell; skips grid + drawer
'sidebar' => null,
'topbar' => 'my-topbar', // the auto-discovered key
'footer' => 'my-footer',
],
],

Resolution precedence for each piece: config.layout.components.<piece> → layout:<piece> → bundled component.

Your replacement receives the same props the bundled component does, so the shell's state (sidebarCollapsed, onToggleCollapse, onToggleSidebar, sidebarOpen, mobileOpen, onMobileClose) keeps flowing. Use piece-by-piece overrides when you want to change one piece's visual design but keep the overall shell mechanics (mobile drawer, grid, collapse animation) intact.

Keyboard and screen-reader contract (v2.7.0)

The sidebar layout's skip links and mobile menu find the pieces by id. A replacement sidebar or top bar keeps them working when it renders the same ids, as the martis:component --type=sidebar and --type=topbar scaffolds do:

ElementWhat it carriesWhy
Sidebar rootid="martis-sidebar"; on mobile, while mobileOpen is true, role="dialog", aria-modal="true" and a name (aria-label); while it is false, inertThe menu button names it in aria-controls; the open drawer is a modal dialog; the closed one is out of reach
Sidebar menu<nav id="martis-navigation" tabIndex={-1} aria-label=…>"Skip to navigation" moves focus there on desktop
Sidebar links in the collapsed railaria-label with the label the rail hidesA tooltip is not an accessible name
Top bar menu buttonid="martis-sidebar-toggle", aria-controls="martis-sidebar", aria-expanded={sidebarOpen}"Skip to navigation" moves focus there on mobile, and the drawer gives focus back to it when it closes

sidebarOpen (v2.7.0) is the top bar's prop for aria-expanded: true or false on mobile, undefined on desktop. The drawer's focus handling is one hook, useModalFocus(ref, open, { onClose, returnFocus }) from @martis/runtime: while open is true it moves focus to the first focusable element inside ref, keeps Tab and Shift+Tab inside, and calls onClose on an Escape no popup or modal above it took (the Escape layer rule); a key pressed while focus is in another surface, such as the command palette, is left to that surface; when open turns false it moves focus to returnFocus(), or back to the element that had focus when it opened.

import { useRef } from 'react'
import { useModalFocus } from '@martis/runtime'
 
interface SidebarProps {
mobileOpen?: boolean
onMobileClose?: () => void
collapsed?: boolean
}
 
export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const ref = useRef<HTMLDivElement>(null)
useModalFocus(ref, mobileOpen === true, {
onClose: () => onMobileClose?.(),
returnFocus: () => document.getElementById('martis-sidebar-toggle'),
})
 
return (
<div
ref={ref}
id="martis-sidebar"
className="martis-sb"
data-mobile={mobileOpen === undefined ? undefined : mobileOpen ? 'open' : 'true'}
role={mobileOpen === true ? 'dialog' : undefined}
aria-modal={mobileOpen === true ? true : undefined}
aria-label={mobileOpen === true ? 'Main navigation' : undefined}
{...(mobileOpen === false ? { inert: '' } : {})}
>
<nav id="martis-navigation" tabIndex={-1} className="martis-sb-nav" aria-label="Main navigation">
{/* brand, links, footer */}
</nav>
</div>
)
}

Render inert on the root while the drawer is closed (mobileOpen === false) so its off-screen links leave the Tab order and the accessibility tree; React 18 sets it from a string, {...(mobileOpen === false ? { inert: '' } : {})}, and drops a boolean (React 19 takes the boolean instead). Hide it with inert, not visibility: hidden: under reduced motion Martis stretches every transition to 1 ms, so the links would still be hidden when the drawer opens and refuse the focus it moves to them. The hook is in the shim since v2.7.0: an extension whose shims are older republishes them first (see Refreshing the extension scaffold).

Top-bar slots (v2.7.0)

To add a component to the top bar without replacing it (and losing the search, the notification bell, the preferences menu and the user menu), register it under one of two slots:

Registry keyConfig overrideWhere it renders
topbar:startmartis.layout.components.topbar_startAfter the menu and collapse buttons, before the breadcrumbs and the search
topbar:endmartis.layout.components.topbar_endAfter the preferences menu, before the user menu

The sidebar preset's top bar and the topnav preset's bar both render the slots (in the topnav bar, topbar:start sits after the brand, before the menu links). A slot renders nothing when nothing is registered; a component that throws renders nothing (the error is logged) and the rest of the bar stays. The sidebar preset's search bar takes its room in the row (v2.18.3): it sits near the centre of the bar while the breadcrumbs and the right-hand cluster both fit beside it (they get equal room, so the buttons before the breadcrumbs shift it slightly), moves left when topbar:end (or a long user name) makes the cluster wider, and narrows down to 160px when the bar runs out of room, while the breadcrumbs shrink and end in an ellipsis. Below 1025px it gives way to the search icon in the right-hand cluster. Before v2.18.3 it was laid over the bar with a fixed budget for its neighbours, so a wide topbar:end component slid under it (the notification bell showed inside the search box) and a topbar:start one ran under it. Keep slot components compact (an icon, a short label) all the same: the cluster never shrinks. Resolution is the pieces' one: the key the config names, when registered, then the default key.

// resources/js/martis-extensions/overrides/TopbarStart.tsx
// (auto-discovered under `topbar:start`)
import { api, useQuery } from '@martis/runtime'
 
export default function TenantIndicator() {
const { data } = useQuery({
queryKey: ['current-tenant'],
queryFn: () => api.get<{ name: string }>('/api/current-tenant'),
})
 
return <span>{data?.name}</span>
}

overrides/TopbarStart.tsx and overrides/TopbarEnd.tsx map to the two keys through OVERRIDE_KEYS in resources/js/martis-extensions/index.ts. That file is the app's own, so an install scaffolded before v2.7.0 adds the two lines to it (TopbarStart: 'topbar:start', and TopbarEnd: 'topbar:end',), or registers the component by hand: componentRegistry.register('topbar:start', TenantIndicator). A custom top bar (layout:topbar) renders the slots only if it renders them itself.

On a phone (v2.14.0)

At or under the mobile breakpoint (768px wide) a phone's top bar has no room next to Martis's own items (the menu button, the breadcrumbs, the search, the bell, the preferences and the user menu), so the slots leave the bar:

PresetWhere the slots render on a phone
sidebarIn the navigation drawer, under the brand, topbar:start then topbar:end, each stretched to the drawer's width
topnav (no drawer)At the end of the breadcrumb strip under the bar; they drop to their own line when they and the breadcrumbs do not fit on one

The component receives one prop, placement: 'topbar' in the bar, 'drawer' in the drawer, 'breadcrumbs' in the topnav strip. A component that ignores it keeps working; one that reads it can render a fuller form in the drawer (a labelled switcher) and a compact one in the bar. The type is TopbarSlotProps from @martis/runtime (republish the extension shims to get it, see Refreshing the extension scaffold):

// resources/js/martis-extensions/overrides/TopbarStart.tsx
import type { TopbarSlotProps } from '@martis/runtime'
 
export default function HotelSwitcher({ placement }: TopbarSlotProps) {
return placement === 'drawer'
? <label className="flex flex-col gap-1">Hotel <HotelDropdown /></label>
: <HotelDropdown compact />
}

In the drawer the slots come first, so opening the menu focuses the slot component's first control when it has one. The drawer clips what overflows it: a popover the component opens should be portalled to <body>, as PrimeReact's overlays are (a Dropdown, an OverlayPanel). A slot that renders nothing leaves no empty block. The component moves between the bar and the drawer when the viewport crosses the breakpoint, so it mounts again and loses its local state.

With a custom piece:

  • Custom sidebar (layout:sidebar). The drawer is the bundled sidebar's: a custom sidebar does not render the slots, and the bundled top bar leaves them out on a phone, so the app renders its component in its own sidebar if it wants it there.
  • Custom top bar (layout:topbar) that renders a slot component itself. On a phone the bundled sidebar already renders it in the drawer: skip it in the bar when the top bar receives onToggleSidebar (set only on a phone), or it renders twice. A component the app renders by hand receives the props the app gives it, not placement.

The shell's content column never grows past the viewport (it is minmax(0, 1fr) since v2.14.0): a bar whose items need more than the screen (a custom top bar, or a wide topbar:end component on a tablet, where the slots stay in the bar) overflows the bar instead of widening and clipping every page.

Use layout:shell (or config.layout.components.shell) when you want to rebuild the entire layout from scratch and don't need Martis's default mobile drawer / collapse behaviour.

A custom shell owns its markup, the skip link included: render a first focusable link to its content and give that element an id and tabIndex={-1}, as the bundled layouts do with #martis-main.

Component Registry API

import { componentRegistry } from '@martis/runtime'
 
// ─── Registration ──────────────────────────────────────────
// Register by key (also used for explicit `field.component` keys from PHP)
componentRegistry.register(key, component)
 
// Register a global field display/input by field type
componentRegistry.registerFieldDisplay(type, component)
componentRegistry.registerFieldInput(type, component)
 
// Register a per-resource field display/input
componentRegistry.registerResourceFieldDisplay(resource, field, component)
componentRegistry.registerResourceFieldInput(resource, field, component)
 
// Register a component that loads on demand (v2.20.0), with options
componentRegistry.registerLazy(key, () => import('./tools/Reports'), { preload: 'idle' })
 
// ─── Lookup ────────────────────────────────────────────────
// Resolve a display component with the full 4-tier priority chain
componentRegistry.resolveDisplay(type, fieldName, resourceKey, explicitKey, fallback)
 
// Resolve an input component with the full 4-tier priority chain
componentRegistry.resolveInput(type, fieldName, resourceKey, explicitKey, fallback)
 
// Resolve a single component by exact key (no fallback chain)
componentRegistry.resolve(key)
 
// Boolean check
componentRegistry.has(key)
 
// Registered with a loader? (v2.20.0)
componentRegistry.isLazy(key)
 
// Fetch a lazy component's chunk ahead of its first render (v2.20.0)
await componentRegistry.preload(key)
 
// List every registered key — useful in devtools console for debugging
// "why didn't my override pick up?" cases.
componentRegistry.keys()

resolveDisplay and resolveInput walk Tiers 1 → 4 in order (explicit key → per-resource → global type → fallback). The single-arg resolve(key) is the low-level lookup used by drawer / shell overrides where the consumer already knows the exact registry key.

The same instance is window.Martis.componentRegistry, so window.Martis.componentRegistry.keys() in the browser console lists every registered key without a rebuild.

Lazy registration (v2.20.0)

Every component registered by hand or discovered by the scaffold's index.ts is imported eagerly, so a large tool or card adds to extensions.js, which every page of the panel downloads. registerLazy moves a component into a chunk that is fetched the first time it renders:

import { componentRegistry } from '@martis/runtime'
 
componentRegistry.registerLazy('tool:reports', () => import('./heavy/Reports'), {
fallback: <p>Loading reports…</p>, // default: the Martis loader
preload: 'idle', // default: 'never'
})
  • Loader. A function that returns a promise of the module (import('./Reports'), whose default export is the component) or of the component itself. It runs at most once, on the first render or on preload(). A failed fetch is not cached: preload() calls the loader again, and so does "Try again" in the error state (it builds a fresh lazy component, because React.lazy itself would keep the rejection for good). A plain re-render of the failed component does not retry.
  • Where it works. resolve() returns a component that carries its own Suspense boundary, so a lazy component renders in every place Martis renders a registry component: Tool pages, dashboard cards and component-keyed metrics, filter controls, custom actions and action drawers, CRUD overrides, registered pages, shell slots, auth pages, and field displays and inputs (global, per-resource and explicit key). Nothing at the call site needs to know it is lazy.
  • fallback. What shows while the chunk loads: a node, or a component. Without one, Martis shows its loader (including a loader override registered in the registry; the loader key itself falls back to the bundled loader).
  • preload. 'never' (default) fetches on first render. 'idle' fetches once the app is on screen and the browser is idle (requestIdleCallback, a short timer where it is missing), so the first visit to the tool does not wait. 'visible' is accepted and behaves as 'never': a chunk loads when the page that renders it mounts, which is when it becomes visible. await componentRegistry.preload(key) fetches a chunk at a moment of your choosing (for example on hover of a link to it); it is idempotent and resolves at once for an eager or unknown key.
  • register(key, () => import('./X')). The overload that takes a loader is detected from the function's source: a zero-argument, expression-bodied arrow (or function() { return import(...) }) that reaches an import( call, including the __vitePreload wrapper Vite emits. A function component with no props is also a zero-argument function, and a function cannot be called to find out which it is, so a component is never taken for a loader unless its source looks like that; anything with a block body, parameters, or a class is eager. Only registerLazy is guaranteed to be lazy: a loader with a block body (() => { return import('./X') }), one wrapped by a minifier or an instrumenter, or one that takes arguments is silently registered as an eager component, and rendering it fails. Use registerLazy whenever the loader is not a plain () => import('./X'). The typed helpers (registerFieldDisplay, registerFieldInput, registerResourceFieldDisplay, registerResourceFieldInput) take the same two forms and the same options.
  • A chunk that cannot load. If the loader rejects, only that component shows Martis's error state (with the surface's key) and the rest of the page keeps working. After a deploy, a tab opened before it asks for chunk files the deploy replaced (their hashed names changed), and the fetch fails with Failed to fetch dynamically imported module (or Importing a module script failed, error loading dynamically imported module, ChunkLoadError). Martis reloads the tab to pick up the new entry and chunk names, at most once per minute per tab: a sessionStorage timestamp keeps a failure that survives the reload (a chunk that is really gone) from looping, and a successful load of another chunk does not reset it. It never reloads while the browser is offline (navigator.onLine === false): that failure is the network, not a deploy, and a reload would lose unsaved form state, so the error state shows with "Try again". If sessionStorage is blocked it never reloads. Set MARTIS_EXTENSIONS_RELOAD_ON_STALE=false (martis.extensions_reload_on_stale) to never reload automatically. A dynamic import() of your own can use the same handler: import('./Page').catch(reloadOnStaleChunk) (reloadOnStaleChunk is exported by @martis/runtime).
  • Build. vite.extensions.config.ts writes chunks to chunks/[name]-[hash].js next to extensions.js. Republish the scaffold (php artisan vendor:publish --tag=martis-extension-shims --force, or martis:install --force) to get the new rollup options on an install made before v2.20.0. Serve the folder with the cache headers in Extensions: caching.

Debugging — martis:list-overrides

When an override does not pick up, the most common cause is a key mismatch between the PHP layer (which declares "I want a component called <key>") and the consumer extension bundle (resources/js/martis-extensions/) (which registers the actual React component under that key). The martis:list-overrides artisan command prints the keys the PHP layer declares: the component key of each Tool and of each Action with a custom component, and each resource's URI key:

php artisan martis:list-overrides
php artisan martis:list-overrides --kind=tool # only Tools
php artisan martis:list-overrides --kind=action # only Actions with Action::component()
php artisan martis:list-overrides --kind=resource # only Resources (uri keys)
php artisan martis:list-overrides --filter=order # substring filter on the key
php artisan martis:list-overrides --frontend # ⭐ cross-check vs `resources/js/martis-extensions/` and flag missing registrations

⭐ --frontend cross-check

The --frontend flag adds a Frontend column that shows whether your extension registers each key, read statically from resources/js/martis-extensions/: the key each file of the four buckets registers through the auto-discovery entry (tools/Charts.tsx is tool:charts, overrides/Sidebar.tsx is layout:sidebar), and the literal key of each register() call on the component registry in index.ts (v1.38.0). A resource row shows n/a: the SPA renders a resource without a component of its own.

+----------+-----------------+----------------------------+--------------+
| Kind | Component key | Source | Frontend |
+----------+-----------------+----------------------------+--------------+
| resource | clients | App\Martis\ClientResource | n/a |
| resource | invoices | App\Martis\InvoiceResource | n/a |
| tool | tool:imports | App\Martis\Tools\Imports | ✓ registered |
| tool | system-status | App\Martis\Tools\… | ✗ missing |
+----------+-----------------+----------------------------+--------------+

Exit code 2 (INVALID) when a Tool or Action key is missing, so you can wire it into CI as php artisan martis:list-overrides --frontend || exit 1. Pass --extensions-dir=path/to/dir when the extension sources live outside resources/js/martis-extensions/.

Only string-literal keys are read, and only from index.ts: a computed key (register('field:' + kind, ...), a template literal with ${...}) or a register() call in another module shows as missing. Check those in the browser console (below).

Sample output:

+----------+--------------------------+------------------------------------------+
| Kind | Component key | Source |
+----------+--------------------------+------------------------------------------+
| resource | clients | App\Martis\ClientResource |
| resource | invoices | App\Martis\InvoiceResource |
| tool | system-status | App\Martis\Tools\SystemStatus |
| action | order-bulk-publish | App\Martis\OrderResource → PublishOrders |
+----------+--------------------------+------------------------------------------+
4 component key(s) declared. Verify each Tool and Action key is registered by your extension: a TSX file
under resources/js/martis-extensions/{tools,fields,cards,overrides}/ (v1.9+ filename → key auto-discovery)
or a register() call in its index.ts. A resource needs no component.

The command lists what is expected, not what is registered: the actual override registry lives in the browser and cannot be introspected from PHP. Check the matching list in your frontend by running this in the browser devtools console after the SPA boots:

window.Martis.componentRegistry.keys()

Any Tool or Action key that appears in martis:list-overrides but not in componentRegistry.keys() is one your extension does not register (a missing TSX file under resources/js/martis-extensions/, or a missing register() call), the most common reason an override fails to resolve.

⭐ Component Inspector — /dev/components

A developer-only page mounted at /martis/dev/components (alongside the regular admin routes) that lets you preview any registered component in isolation, fed by an editable JSON payload. The intended workflow:

  1. Scaffold an override (php artisan martis:component StatusBadge --type=field).
  2. Build the extension bundle in your app root (npm run build:extensions).
  3. Open /martis/dev/components, pick status-badge from the list on the left.
  4. Tweak the JSON payload ({ field: {...}, value: 'draft' }) and watch the component re-render on the right.
  5. Iterate until the design is right — then go test through a real Resource page.

Distinct from the BelongsTo / MorphTo peek popover. Peek shows real records of a related model to end-users. The Inspector renders synthetic payloads for developers.

The page reads the same componentRegistry.keys() you'd inspect via the browser devtools console, so it surfaces every registered key — bundled drawers (martis:drawer-*), built-in field renderers (field:display:text, field:input:select, …), and your own overrides.

Keys the Inspector does not recognise by prefix (custom action components such as the bundled demo-custom-action, tools, ad-hoc overrides) are seeded with an empty {} payload, so a component you intend to preview there should render sensible fallbacks from missing props (the bundled demo action defaults componentProps, selectedIds and its handlers for exactly this reason). A misshapen JSON payload renders a red error box instead of crashing the inspector. The page sits inside the same authenticated shell as every other Martis admin page; restrict it via your existing middleware if you don't want non-developers landing on it.

Environment gate

The Inspector route is only registered when config('martis.dev.tools_enabled') is true. The default resolves to true on local / testing environments and false everywhere else, so production bundles do not expose /dev/components at all. Force either value via env:

# Force enable on a staging box for a specific debugging session.
MARTIS_DEV_TOOLS=true
 
# Force disable even in local (e.g. recording a clean walkthrough).
MARTIS_DEV_TOOLS=false

Setting this is a server-side config change, so re-publish or re-bundle after editing — the bridge runs through window.MartisConfig.dev.toolsEnabled and the React router conditionally registers the route from there.

Page Title Hook

Custom pages (layouts, custom resource views, dashboards built outside the default router) should set the browser tab title so navigation inside the SPA stays consistent with the server-side title on hard reload.

import { usePageTitle } from '@martis/runtime' // v1.38.0+
 
export function MyCustomPage({ resource }) {
// Passing a segment → `"${segment} · ${brand}"`.
usePageTitle(resource.label)
 
// Passing null/undefined → the bundled "brand — Admin Control" default
// in the active locale.
// usePageTitle(null)
 
return <div>…</div>
}

While the segment is still loading, pass { pending: true } (v2.19.0): the title is left as it is (the server's title on a full page load, the previous page's after a client-side navigation) instead of flashing the generic default, and the segment replaces it once it is known.

const finding = useQuery({ queryKey: ['finding', id], queryFn: () => api.get(apiPath`/api/findings/${id}`) })
usePageTitle(finding.data?.title, { pending: finding.isPending })

The hook restores the previous title on unmount so stacked modals/drawers don't leave stale segments in the tab bar. See configuration.md for the server-side half (static config + closure API).