# Resources — Complete Reference · Martis docs

> The Resource class is the core building block of Martis, and each resource maps to the Eloquent model you administer through it. It defines the model's CRUD interface, fields, authorization, and behavior.

Source: https://getmartis.com/docs/core/resources

# Resources — Complete Reference

The `Resource` class is the core building block of Martis, and each resource maps to the Eloquent model you administer through it. It defines the model's CRUD interface, fields, authorization, and behavior.

## Creating a Resource

Create a PHP class in `app/Martis/` (or any path configured in `config/martis.php`) that extends `Martis\Resource`:

```
<?php
 
namespace App\Martis;
 
use App\Models\Post;
use Illuminate\Http\Request;
use Martis\Fields\Id;
use Martis\Fields\Text;
use Martis\Resource;
 
class PostResource extends Resource
{
    public static function model(): string
    {
        return Post::class;
    }
 
    public function fields(Request $request): array
    {
        return [
            Id::make('id'),
            Text::make('title')->sortable()->searchable()->required(),
        ];
    }
}
```

Resources are **auto-discovered** by the `ResourceDiscovery` class. No manual registration is needed.

Discovery scans `config('martis.resources_path')` (default `app_path('Martis')`) recursively and maps each file to a class under `config('martis.resources_namespace')`. Since v1.36.0 the namespace is derived from Composer's PSR-4 map when the key is `null` (the default), so any autoloadable directory works. This also covers one directory per panel, tenant surface or module, each served by its own process or config:

```
// config/martis.php
'resources_path' => app_path('Martis/Customer'),   // App\Martis\Customer\Resources\OrganizationResource, ...
'resources_namespace' => null,                      // derived: App\Martis\Customer
```

```
// A module package under "Modules\\": "modules/"
'resources_path' => base_path('modules/Billing/Martis'),   // derived: Modules\Billing\Martis
```

Pin the namespace explicitly (`'resources_namespace' => 'App\\Martis\\Customer'`) when the directory is not autoloaded. See [Configuration → Resources path and namespace](https://getmartis.com/docs/reference/configuration#resources-path-and-namespace).

## Required Methods

### model()

Returns the fully-qualified class name of the Eloquent model.

```
public static function model(): string
{
    return Post::class;
}
```

### fields()

Defines the fields displayed and editable in the admin panel. Receives the current HTTP request for conditional field display.

```
public function fields(Request $request): array
{
    return [
        Id::make('id'),
        Text::make('title')->sortable()->searchable()->required(),
        Textarea::make('body')->hideFromIndex()->nullable(),
    ];
}
```

See the [Fields Reference](https://getmartis.com/docs/core/fields) for all 50 available field types.

### detailSidebar() (⭐ Martis differential)

Returns fields rendered in the **sticky right rail** of the detail page. When non-empty, the page lays out as a 1fr / 320px grid: the main column scrolls while the sidebar stays pinned beside it. Use it for at-a-glance metadata (status, owner, dates, IDs) so it doesn't compete with the long-form fields below.

```
public function detailSidebar(Request $request): array
{
    return [
        Status::make('status'),
        BelongsTo::make('owner', 'Owner', UserResource::class),
        DateTime::make('created_at'),
        DateTime::make('updated_at'),
    ];
}
```

Fields rendered in the sidebar are automatically removed from the main `fieldsForDetail()` body so they only appear once. Below 1100px the layout collapses to a single column and the sidebar pushes to the bottom. Default: empty (single-column layout).

### detailKicker()

The small uppercase heading above the detail field block (and above the sidebar). `null` (default) keeps the translated "Details"; a string is shown verbatim (translate it yourself); `false` hides the heading. v2.23.0+.

```
public function detailKicker(Request $request): string|false|null
{
    return __('Record sheet'); // or false to hide, null for "Details"
}
```

The detail drawer has no such heading, so the method does not affect it. The value is sent with the resource schema as `detailKicker`.

Detail rows honour `Field::fullWidth()` and `Field::hideLabelOnDetail()` (see [fields.md](https://getmartis.com/docs/core/fields)), and each row carries `data-field` (the attribute) and `data-type` (the field component) for host CSS. Fields inside a `Section` or `Panel` follow the same rules: `fullWidth()` takes the whole row of the section grid (whatever `colSpan()` says), `hideLabelOnDetail()` keeps a screen-reader label, and each row carries the same hooks.

### Context-specific field overrides

Override any of `fieldsForIndex`, `fieldsForDetail`, `fieldsForCreate`, `fieldsForUpdate`, `fieldsForInlineCreate`, or `fieldsForPreview` to return a different field set per context. Each defaults to `fields($request)` so the override is opt-in.

```
public function fieldsForIndex(Request $request): array
{
    return [
        Id::make('id'),
        Text::make('title')->sortable(),
        // No body / cover_image columns on the listing.
    ];
}
 
public function fieldsForDetail(Request $request): array
{
    return $this->fields($request); // include everything
}
```

Useful when the listing wants only a few summary columns while the detail page renders the full editor.

A field can live on a form override alone. The endpoints that answer for one field of a form (the `BelongsTo` / `MorphTo` / `Tag` pickers, the Slug check, the `dependsOn` sync and the server-side `Select` search) read it from the form it renders on: `fieldsForUpdate()` on the edit form, `fieldsForCreate()` and `fieldsForInlineCreate()` on the create forms. See [Relationships → Relation fields declared on one form only](https://getmartis.com/docs/core/relationships#relation-fields-declared-on-one-form-only).

### filters() / lenses() / cards() / dashboards()

Resources opt into the four extension subsystems by overriding these methods on the Resource class. Each returns a list of objects of the matching type.

```
public function filters(Request $request): array  { return [new StatusFilter()]; }
public function lenses(Request $request): array   { return [new HighValueClients()]; }
public function cards(Request $request): array    { return [new RevenueTrend()]; }
public static function dashboards(): array        { return [SalesDashboard::class]; }
```

See [Filters](https://getmartis.com/docs/core/filters), [Lenses](https://getmartis.com/docs/core/lenses), [Metrics](https://getmartis.com/docs/core/metrics), and [Dashboards](https://getmartis.com/docs/core/dashboards) for the per-class authoring guide.

### Eager-load relations declaratively — static $with

Add a `protected static array $with = [...]` to eager-load relations on every index, detail, and `relatableQuery` build. Saves the boilerplate `indexQuery` override that previously had to wrap `$query->with([...])`.

```
class PostResource extends Resource
{
    /** @var list<string> */
    protected static array $with = ['author', 'tags', 'comments.user'];
}
```

The relations are loaded both on the listing query (`indexQuery`) and on the relationship-picker query (`relatableQuery`). For surgical control on a specific surface, override `indexQuery()` directly — the explicit override wins.

Source: `src/Resource.php::applyWith()`.

### Per-record title — title()

`titleAttribute()` declares which column to read; `title()` is the per-instance override that builds the actual label string. Default reads `static::titleAttribute()` from the model and falls back to `"{Singular} #{id}"` when no attribute is set.

```
public function title(): string
{
    if ($this->model === null) return parent::title();
 
    return $this->model->name.' ('.$this->model->code.')';
}
```

Used in breadcrumbs, the relationship picker, and the global search palette. Override when a single attribute is not enough.

### Browser tab title — documentTitle()

The browser tab of a record's detail (the detail page, or the detail override it opens in over the index) reads `"{singular label}: {title()}"`, followed by the panel brand. When `title()` already names the resource, that repeats it ("Conversation: Conversation of 7 Nov"). Return a string from `documentTitle()` to use it as the tab title instead (v2.18.0); return `null` (the default) to keep the default.

```
public function documentTitle(): ?string
{
    return $this->title(); // "Conversation of 7 Nov · Visitor"
}
```

The brand suffix (`" · {brand}"`) is still appended. The detail response carries the value as `_documentTitle` (`GET /api/resources/{resource}/{id}`, detail context only). The drawer and page headers keep showing `title()`.

### Search bar configuration

| Method | Default | Effect |
| --- | --- | --- |
| `indexSearchable(): bool` | `true` | Whether the search input renders on the resource index. Return `false` to hide it (search-by-URL still works for resources that need it). |
| `searchPlaceholder(): ?string` | `null` | Placeholder text shown inside the search input. Returns `null` to fall back to `martis::messages.search_placeholder`. |

```
public static function indexSearchable(): bool       { return false; } // hide the bar
public static function searchPlaceholder(): ?string  { return 'Search by SKU or barcode…'; }
```

### Sidebar count badges — showMenuCount() / menuCount()

Render a small numeric badge next to the resource name in the sidebar. Useful for queues, inboxes, "needs review" lists.

```
public static function showMenuCount(): bool { return true; }
 
public static function menuCount(Request $request): ?int
{
    return static::newModel()->newQuery()->where('status', 'pending')->count();
}
```

Returning `null` from `menuCount()` hides the badge for that user. Counts are formatted by the frontend with the `formatItemCount()` helper (compact `K` / `M` notation above the configured threshold).

If `menuCount()` throws, the badge is hidden for that user (the rest of the sidebar renders) and the failure is reported through `report()` as a `Martis\Exceptions\MenuCountFailedException` naming the resource class (v1.32.4). See [Menus → When a badge is missing](https://getmartis.com/docs/core/menus#when-a-badge-is-missing) for the dev-mode `_failed` diagnostics and the `counts.report_failures` switch.

#### Caching a count on its own: menuCountCache() (v2.20.0)

By default the count is computed with the user's badge map, so N admins run it N times per map TTL. Declare `menuCountCache()` to cache it by itself, shared by every admin or per user, with a TTL in seconds:

```
public static function menuCountCache(): ?array
{
    return ['scope' => 'shared', 'ttl' => 30];   // scope: 'shared' | 'user' (default), ttl: seconds (default 60)
}
```

Use `shared` only for a count that is the same for everyone who sees the badge (a row total). `null` (the default) keeps today's behaviour. The shared key still carries the `Martis::cacheScopeUsing()` scope. Pair it with `martis.cache.navigation.badge_ttl_seconds`, and see `Martis::menuCountsUsing()` to resolve several counts in one query: both in [Cache](https://getmartis.com/docs/reference/cache#badge-counts-and-metric-ttls-in-seconds-v2200).

### Place a resource under "System" — belongsToSystemSection()

Returns `true` to dock the resource under the sidebar's "System" group rather than the user-defined sections. Used internally by `ActionEventResource`. Override on consumer-side resources that should sit alongside the package's system tools.

```
public function belongsToSystemSection(): bool { return true; }
```

A System-section resource keeps `group() === null` — the sidebar buckets it under the **System** header, not a `group()` bucket. The command palette (⌘K) mirrors this: since **v1.29.3** it tags such resources with the same "System" label the sidebar uses, so the two surfaces agree (previously the palette showed no group tag for them).

Tools have the same opt-in since **v1.35.0**: `Tool::withSystemSection()` docks a Tool in the same section, after the resources and before the Cache admin link. See [Tools → Place a Tool under "System"](https://getmartis.com/docs/customization/tools#place-a-tool-under-system--withsystemsection-v1350).

Since **v1.38.0** the section is ordered by a weight. Override `systemSectionOrder(): int` (default `100`) to move the resource: a lower weight lists it earlier, and entries with equal weights keep the natural order (resources in registration order, then Tools, then the cache link at `1000`). See [Menus → Order inside the System section](https://getmartis.com/docs/core/menus#order-inside-the-system-section-v1380).

```
public function belongsToSystemSection(): bool { return true; }
 
public function systemSectionOrder(): int { return 10; } // first in the System section
```

### Claim a record for reverse-mapping — matchesRecord()

When **several resources share one Eloquent model** — e.g. an Approval-Queue resource scoped to `pending` records and a Processed resource scoped to `approved`/`indexed`, both on `App\Models\Candidate` — Martis sometimes needs to turn a model instance back into the resource surface it belongs to. The clearest case is the command palette's **Recent activity** deep-links: an event only carries `model_type` + `model_id`, so the palette must decide *which* of the sharing resources a record opens.

By default a resource claims every record of its model (`matchesRecord()` returns `true`), and the **first-registered** resource wins — which is wrong when a second resource scopes the same model by status. Override `matchesRecord()` on each competing resource to claim its records:

```
class CandidateResource extends Resource // uriKey: candidates (pending/failed)
{
    public function matchesRecord(Model $model): bool
    {
        return in_array($model->status, ['pending', 'failed'], true);
    }
}
 
class ProcessedCandidatesResource extends CandidateResource // uriKey: processed-candidates
{
    public function matchesRecord(Model $model): bool
    {
        return in_array($model->status, ['approved', 'indexed', 'rejected', 'deleted'], true);
    }
}
```

Since **v1.30.1** the palette loads the record (only when a model has 2+ registered resources; soft-deleted rows are loaded with `withTrashed()`, so a trashed record still resolves) and picks the first whose `matchesRecord()` returns `true`, so a processed candidate deep-links to `/resources/processed-candidates/{id}` and a pending one to `/resources/candidates/{id}`. It falls back to the first-registered resource when the record is gone or none claims it. Resources with a unique model never pay the lookup cost. Since v2.11.0 only the routable resources over the model compete: a [headless](https://getmartis.com/docs/core/resources#a-headless-resource-over-a-model-that-has-a-page) one registered first never takes the link.

### Custom search ordering — searchOrderBy()

The global search applies `LIKE %term%` (or Scout) and returns the first N matches. Override `searchOrderBy()` to apply a custom `ORDER BY` to the search hits — for example, surface recently-active records first.

```
public function searchOrderBy(Builder $query, string $term): Builder
{
    return $query->orderByDesc('updated_at');
}
```

### Custom search matching — searchQuery()

By default the resolver matches with `ILIKE '%term%'` over `searchable()` fields (plus `searchableRelations()`). Override `searchQuery()` to **replace the match predicate entirely** — for driver-native search (PostgreSQL full-text `@@`, `pg_trgm` fuzzy, MySQL `FULLTEXT`, SQLite FTS5) — without standing up Scout and without leaving the resource pipeline. Return a `Builder` and the resolver applies **none** of its own matching (no `ILIKE`, no `field:value` parsing, no priority ranking, and it skips the empty-set `1 = 0` guard); return `null` (the default) to keep the built-in `ILIKE`. Filters, the user's sort, and pagination are untouched, and it applies to the resource index, global Cmd+K, and relation pickers alike.

```
use Illuminate\Database\Eloquent\Builder;
 
public function searchQuery(Builder $query, string $term): ?Builder
{
    // Delegates to a model scope that owns the SQL (@@ + similarity, ranked by
    // ts_rank), tenant-scoped through the model. The scope lives in YOUR app.
    return $query->search($term);
}
```

> **You own the WHERE — in place.** Constrain and return the **given** `$query` (Eloquent `where`/scope calls return `$this`, so `return $query->search($term)` is correct). Returning a **different** Builder instance throws `LogicException` — it would drop the index/filter scoping already applied and be ignored by relation-picker pagination. A non-null return that adds no predicate bypasses the empty-set guard and dumps every row — the same trust the package places in `indexQuery()`. Keep the driver-specific SQL in your own model scope; the package ships only the seam. A resource that opts into `usesScout()` uses Scout instead — `searchQuery()` is the non-Scout path.

### Search mode — searchMode()

`public static ?string $searchMode` (or override `searchMode()`) sets how the database search matches a term: `contains` (default, `ILIKE '%term%'`), `prefix` (`ILIKE 'term%'`) or `columns` (only the columns named by `col:term` tokens; a bare term acts as `contains`). It falls back to the `martis.search.match_mode` config key; an unknown value throws an `InvalidArgumentException` naming the resource. See [search-performance.md](https://getmartis.com/docs/core/search-performance), and `php artisan martis:search-index` to check the indexes behind a resource's search.

### $stickyView (⭐ Martis differential)

Controls whether this resource participates in the [Sticky Views](https://getmartis.com/docs/core/sticky-views) feature — per-user view state persistence on the index page (filters, sort, pagination, per-page, search). Default: `true`.

```
class AuditEventResource extends Resource
{
    // Audit logs always want to show "today first" — opt out so the
    // index never restores a stale page / filter from the previous
    // session.
    protected static bool $stickyView = false;
}
```

When the global `config('martis.sticky_views.enabled')` is `false`, the per-resource flag is ignored and the feature is off everywhere.

### Prefill the create form from a link — acceptsPrefill()

`Field::default(Closure)` is resolved for the schema request, which does not carry the create page's query string. A resource that wants a link such as `/resources/versions/create?of=12` to start the form from a value lists the query keys the page may forward:

```
class VersionResource extends Resource
{
    public static function acceptsPrefill(): array
    {
        return ['of'];
    }
 
    public function fields(Request $request): array
    {
        return [
            BelongsTo::make('document', 'Document')
                ->default(fn (?Request $request) => $request?->query('of')),
        ];
    }
}
```

The schema payload lists the keys as `prefillKeys`. The create page reads those keys from its own URL and asks for the schema with `prefill[of]=12`; the server makes the values readable as `$request->query('of')` while it builds the defaults. Rules:

- Only the keys `acceptsPrefill()` returns are forwarded. Any other key in the URL, and any other `prefill[...]` key, is ignored.
- A value is a string of at most 255 characters; an array or a longer value is ignored.
- A prefilled `Select` value that is not one of its options is dropped (the field starts empty), unless the select takes custom values or searches on the server. A prefilled `BelongsTo` id the field's picker would not list (the related resource's `relatableQuery()`, the field's `relatableQueryUsing()`) is dropped too, so a crafted link cannot preselect a choice the user cannot make. Whatever the form submits still goes through the create's own validation.
- The prefilled schema is built for the request and never cached, and the page keeps it under its own query key (`['schema', resource, 'prefill', {...}]`), so one link's values never serve another.
- It applies to the create page only: the update form loads the record's values, and a replicate (`fromResourceId`) starts from the copied record.

A "link from an action": build the create URL in an action and send the user there. `ActionResponse::visit()` navigates inside the panel and adds the values to the query string (`ActionResponse::redirect()` does the same with a full URL or path):

```
public function handle(ActionFields $fields, Collection $models): ActionResponse
{
    return ActionResponse::visit('/resources/versions/create', ['of' => $models->first()->getKey()]);
}
```

### Auto-refresh polling — $polling, $pollingInterval, $showPollingToggle

Auto-refreshes the resource index payload at a fixed cadence. Useful for resources whose rows reflect external state (queue jobs, deployments, scraper feeds, real-time order books) where a stale view actively misleads the operator.

```
class DeploymentResource extends Resource
{
    public static bool $polling = true;
    public static int $pollingInterval = 10;        // seconds
    public static bool $showPollingToggle = true;   // pause/resume control
}
```

Defaults: `$polling = false`, `$pollingInterval = 15`, `$showPollingToggle = true`.

The interval is silently clamped to a 5-second floor (`Resource::resolvedPollingInterval()`) — anything lower would risk hammering the backend on a long-running tab. When `$showPollingToggle` is `false` the user has no way to pause polling; when `true` the toolbar exposes a control that flips the live state for the current session.

The values surface in the index payload as:

```
{
  "schema": {
    "polling": true,
    "pollingInterval": 10,
    "showPollingToggle": true
  }
}
```

Lenses use the same shape (see [lenses.md](https://getmartis.com/docs/core/lenses)) — they inherit nothing from the resource, so each Lens declares its own polling cadence if it wants one.

## Configuration Methods

### titleAttribute()

Defines which model attribute is used to identify records in breadcrumbs, titles, and relationship dropdowns. Defaults to `id`.

```
public static function titleAttribute(): string
{
    return 'name'; // Shows "John Doe" instead of "#1"
}
```

### label() / singularLabel()

Override the resource name shown in the UI. By default, Martis derives labels from the class name.

```
public static function label(): string
{
    return 'Blog Posts';
}
 
public static function singularLabel(): string
{
    return 'Blog Post';
}
```

### uriKey()

The URL segment used for routing. Defaults to the kebab-case plural of the class name.

```
public static function uriKey(): string
{
    return 'blog-posts'; // /martis/resources/blog-posts
}
```

### icon()

Sidebar icon. Accepts any [Phosphor Icon](https://phosphoricons.com/) name in kebab-case or PascalCase.

```
public function icon(): string
{
    return 'newspaper'; // or 'Newspaper'
}
```

### subtitle()

Short description displayed below the resource name in the sidebar.

```
public static function subtitle(): ?string
{
    return 'Manage blog posts and articles';
}
```

### group()

Groups resources in the sidebar navigation. Resources without a group appear at the top level.

```
public function group(): ?string
{
    return 'Content';
}
```

When no custom main menu is defined, Martis uses `group()` to build the default menu sections automatically.

### displayInNavigation()

Controls whether the resource appears in the navigation menu. Override to `false` to hide a resource from the sidebar while keeping it accessible via direct URL.

```
public static function displayInNavigation(): bool
{
    return false; // Hidden from sidebar, still accessible via URL
}
```

`displayInNavigation()` is still respected even when the main menu is customized through the menu builder.

### routable()

Controls whether the resource has a routable **human page surface**. Default `true`. Override to `false` to make the resource a headless data source: it stays registered and fully usable as a relation target, but its index/detail/create/edit pages disappear.

```
public static function routable(): bool
{
    return false; // Headless: not a page, still a valid relation target
}
```

When `routable()` returns `false`:

- The index, show, store, update, destroy, restore, schema, and replicate-fields endpoints all return 404 — the resource has no human-facing page surface.
- The resource is excluded from the sidebar navigation, badge counts, global search, and the command palette (in addition to whatever `displayInNavigation()` already controls).
- The resource **remains fully usable as a relation target and data source**: the relatable endpoint (`BelongsTo`/`BelongsToMany`/etc. option lists), the Slug field's slug-check endpoint, `dependsOn` field sync, inline-create, and peek all keep working, still gated by the resource's own `authorizedTo*` methods.

Use this for the "a Tool owns the domain; the Resource is a headless data source" pattern: a custom Tool provides the human-facing UI and workflow for a model, while the underlying Resource definition is kept around purely so other resources can relate to it, validate slugs against it, or inline-create records of it, without exposing a redundant (and potentially confusing) standalone CRUD page for it.

Unlike `displayInNavigation()`, which only hides the resource from menus while leaving its endpoints reachable by direct URL, `routable(false)` fully removes the human page surface (404) while deliberately preserving the internal/relation surface.

A non-routable resource has no page to link to — see [`recordUrl()`](https://getmartis.com/docs/core/resources#recordurl) below for how to give it one anyway (and re-enter global search in the process).

#### A headless resource over a model that has a page

A relationship write needs the related resource's `viewAny` ([Authorization → `viewAny` is the entry gate](https://getmartis.com/docs/auth/authorization#viewany-is-the-entry-gate)), so an app often adds a second, narrower resource over a model that already has one: a headless `AgencyMemberResource` over `User`, next to `UserResource`, as the relation target for users who may not list every user. Since v2.11.0 such a resource never takes the model's place: wherever Martis looks a resource up from a model, the routable resources over it win, whatever the registration order (`ResourceRegistry::preferredForModel()`):

- the audit log's target column (label, and the link to the record);
- the resource a `BelongsTo` without `relatedResource()` checks its value against ([Relationships → Writes follow the pickers](https://getmartis.com/docs/core/relationships#writes-follow-the-pickers));
- the resource behind the automatic Action Events panel;
- the command palette's Recent block: the link of an entry, and the audit-log resource that gates the block.

A model whose resources are all headless keeps them. Two routable resources over one model stay ambiguous for the `BelongsTo` inference: declare `relatedResource()` on the field. Up to v2.10 the first registered resource won, and registration follows file-name order, so `AgencyMemberResource` took over the users' audit-log labels and links and switched off the `BelongsTo` inference.

The headless resource answers to its own policy: its `$policy`, else `AgencyMemberPolicy` by convention (the name follows the resource's class, so a resource that extends `UserResource` does not pick up `UserPolicy` that way), else the policy the Gate has for the model. A resource with none of these allows every ability, as in Nova. A `$policy` set on the parent resource is inherited, so declare `$policy` on the headless resource when you subclass.

A headless resource still answers `peek` for any record of its model, gated by its own `viewAny` and `view`. To make a record outside its view look exactly like a missing one (`404` with the same body, instead of `403`), deny it with `Response::denyAsNotFound()` in the policy's `view()`: see [Authorization → HTTP responses](https://getmartis.com/docs/auth/authorization#http-responses).

```
use Illuminate\Auth\Access\Response;
 
class AgencyMemberPolicy
{
    public function viewAny(User $user): bool
    {
        return $user->agency_id !== null;
    }
 
    public function view(User $user, User $member): Response
    {
        return $member->agency_id === $user->agency_id
            ? Response::allow()
            : Response::denyAsNotFound();
    }
}
```

### recordUrl()

Declares a URL template for a single record of this resource, with an `{id}` placeholder. Default `null`. `Resource::recordHref(int|string $id): string` interpolates the template (URL-encoding the id); when `recordUrl()` is `null`, `recordHref()` falls back to the standard `/resources/{uriKey}/{id}` path.

```
public static function recordUrl(): ?string
{
    return '/tools/project-knowledge?id={id}'; // {id} is interpolated, URL-encoded
}
```

The template can point at any page of the SPA, including a page your app [registered at a URL of its own](https://getmartis.com/docs/customization/custom-pages) (`'/findings/{id}'`).

**Anchor example — a headless resource whose records live in a Tool:**

```
class ProjectResource extends Resource
{
    public static function routable(): bool { return false; }
    public static function recordUrl(): ?string { return '/tools/project-knowledge?id={id}'; }
 
    public function fields(Request $request): array
    {
        return [
            Id::make('id'),
            Text::make('name')->sortable()->searchable(),
            Text::make('code')->searchable(),
        ];
    }
}
```

`ProjectResource` has no index/detail page of its own (`routable()` is `false`), but because it declares `recordUrl()`, projects are findable in global search and every project link — a search hit, a `BelongsTo` display pointing at a project, a breadcrumb — opens `/tools/project-knowledge?id={id}` instead of a 404'd `/resources/projects/{id}`.

#### Dual role

`recordUrl()` does two things at once:

1. **Destination for every record link.** The template drives `Resource::recordHref()` on the backend and the frontend `recordHref(uriKey, id)` helper (fed by a `resourceRecordUrls` map on `window.MartisConfig`, built from `MartisManager::recordUrlMap()`). Every surface that links to a record — global search results, `BelongsTo`/`MorphTo`/`HasMany`/`MorphMany`/`BelongsToMany`/`MorphToMany` displays, breadcrumbs, index row-click, post-save redirects — resolves through one of these two functions, so declaring `recordUrl()` once retargets the record everywhere.
2. **Opt-in back into global search for a non-routable resource.** `SearchController` skips a resource when `! routable() && recordUrl() === null` — i.e. a non-routable resource is excluded from search unless it declares a `recordUrl`, in which case it participates like any other resource (subject to the same `globallySearchable()` and authorization gates below).

The exact rule: **a non-routable resource appears in global search / the ⌘K palette if and only if it declares `recordUrl()`** — and its results deep-link to that template instead of a resource detail page.

#### Interaction with routable() / globallySearchable()

| Resource type | To be searchable, needs | Record links resolve to |
| --- | --- | --- |
| Normal (`routable()` default `true`) | At least one `searchable()` field; `globallySearchable()` on (default `true`) | `/resources/{uriKey}/{id}` |
| Headless (`routable() === false`) | The above, **plus** a non-null `recordUrl()` | The `recordUrl()` template, interpolated |

- `globallySearchable()` remains the on/off toggle on top of everything above — a headless resource with `recordUrl()` still needs `globallySearchable()` to not return `false`.
- `authorizedToViewAny()` still gates search participation per request. A denied user never sees the resource's results, `recordUrl()` or not.
- For a **routable** resource, `recordUrl()` stays `null` by default, so `recordHref()` returns the unchanged `/resources/{key}/{id}` — fully backward-compatible; nothing changes unless you opt in.
- `viewAllUrl` ("View all N") defaults to `null` for a non-routable resource, since it has no index page to send the user to — the palette then renders the count as a static, non-clickable label instead of a dead link. To give it a real destination, declare [`searchIndexUrl()`](https://getmartis.com/docs/core/resources#searchindexurl) below.

**Security note:** `recordUrl()` only supplies a destination string — it does not change authorization. The v1.24 guarantee still holds: a user who fails `authorizedToViewAny()` never sees the resource's records in search, regardless of whether `recordUrl()` is set.

Source: `src/Resource.php::recordUrl()` / `recordHref()`, `src/Http/Controllers/SearchController.php`, `src/MartisManager.php::recordUrlMap()`, `resources/js/lib/recordHref.ts`.

### searchIndexUrl()

Declares the destination for the **"View all N matches"** affordance in global search / the ⌘K palette, with an optional `{search}` placeholder (the searched term, URL-encoded). Default `null`. Companion of `recordUrl()`: `recordUrl()` resolves a single record (`{id}`), `searchIndexUrl()` resolves the listing.

```
public static function searchIndexUrl(): ?string
{
    return '/tools/normas?search={search}';
}
```

`Resource::searchIndexHref(string $search): ?string` resolves the value the backend serializes as the group's `viewAllUrl`, by this precedence:

| Resource | `searchIndexUrl()` | Resolved `viewAllUrl` |
| --- | --- | --- |
| Routable (default) | `null` | `/resources/{uriKey}?search={term}` (unchanged) |
| Any | non-null template | template with `{search}` interpolated (URL-encoded) |
| Non-routable | `null` | `null` → static, non-clickable count in the palette |

This closes the one gap the headless pattern left open: a non-routable resource (v1.24) that opts back into search via `recordUrl()` (v1.25) previously showed a "View all N" row that navigated nowhere. Point `searchIndexUrl()` at the Tool that owns the listing and the affordance sends the user there, pre-filtered by the searched term.

- **Backward-compatible.** A routable resource leaves `searchIndexUrl()` at `null`, so `viewAllUrl` stays byte-for-byte `/resources/{uriKey}?search={term}`. Nothing changes unless you opt in.
- **Security.** Like `recordUrl()`, `searchIndexUrl()` is only a destination string. It never widens authorization — the group still only appears after `globallySearchable()` and `authorizedToViewAny()` pass.

Source: `src/Resource.php::searchIndexUrl()` / `searchIndexHref()`, `src/Http/Controllers/SearchController.php`, `resources/js/components/GlobalSearch.tsx`.

### menuItem()

Customize the navigation item generated for a resource.

```
use Illuminate\Http\Request;
use Martis\Menu\MenuItem;
 
public function menuItem(Request $request): MenuItem
{
    return MenuItem::resource(static::class)
        ->label('Help Desk')
        ->icon('lifebuoy')
        ->path('/support/help-desk');
}
```

Use this when the resource should remain a resource-backed navigation item, but needs a custom label, icon, or path.

Use this for internal resources (e.g., `ActionEventResource`) that should not clutter the main navigation.

### perPageOptions()

Configures the "per page" dropdown on the index page.

```
public function perPageOptions(): array
{
    return [10, 25, 50, 100]; // default: [10, 25, 50, 100]
}
```

### perPage()

Sets the default number of records shown per page. Falls back to `config('martis.pagination.default_per_page')` (25 out of the box).

```
public static function perPage(): int
{
    return 25;
}
```

### Effective per-page

`perPageOptions()` is the source of truth for the page-size dropdown. When the value returned by `perPage()` is not in that list, Martis silently clamps it to `perPageOptions()[0]`. This Option A behaviour keeps the dropdown and the actual filter in sync — no more "I set 7 in `perPage()` but the dropdown only shows 10/25/50 and returns 25".

Three rules govern it:

1. `perPageOptions()` is the canonical list. The dropdown renders exactly what this returns.
2. If `perPage()` is not in `perPageOptions()`, Martis clamps to `perPageOptions()[0]`. The clamp is silent (no warning, no exception).
3. Relation fields (`HasMany`, `MorphMany`, `BelongsToMany`, `MorphToMany`) inherit the related resource's `perPageOptions()` when the developer did not call `->perPageOptions([...])` on the field itself. See [relationships.md](https://getmartis.com/docs/core/relationships).

Two public accessors expose the clamped value:

| Method | Return | Notes |
| --- | --- | --- |
| `Resource::resolvedPerPage(): int` | Clamped value used everywhere the backend or API needs a single "effective" per-page. | Use this in custom controllers instead of `perPage()`. |
| `Lens::resolvedPerPage(): int` | Same rule for lenses. | See [lenses.md — Effective per-page](https://getmartis.com/docs/core/lenses#effective-per-page). |

```
public static function perPageOptions(): array { return [10, 25, 50, 100]; }
public static function perPage(): int          { return 7; } // not in options
 
// Resource::resolvedPerPage() === 10 — clamped to first option.
```

Source: `src/Resource.php::resolvedPerPage()`.

### defaultSort() / defaultSortDirection()

Sets the initial sort column and direction on the index page. Returns `null` (default) for no default sorting.

The default sort follows the rule of every `?sort=` (v1.38.0): it orders the index only when it names a `sortable()` field the user can see (`canSee()`). For a user who cannot see that field the index keeps the order its query gives (`indexQuery()`), as for a default sort that names no sortable field. Before v1.38.0 a default sort by a field the user cannot see ordered the index by that field.

```
public static function defaultSort(): string|array|null
{
    return 'created_at';
}
 
public static function defaultSortDirection(): SortDirection
{
    return SortDirection::Desc; // SortDirection::Asc or SortDirection::Desc
}
```

#### Several default keys

`defaultSort()` may also return an ordered array of column to direction, a `SortDirection` or `'asc'` / `'desc'`. `defaultSortDirection()` is then ignored.

```
public static function defaultSort(): string|array|null
{
    return [
        'category' => SortDirection::Asc,
        'position' => SortDirection::Asc,
        'locale'   => 'asc',
    ];
}
```

A value that is not a direction (or a key that is not a column name) throws an `InvalidArgumentException` naming the resource class and the key: it is a mistake in the code, not request input. Every key goes through the check of a requested key (a `sortable()` field the user can see); a key that fails is dropped, as a single default is.

The index (and the lens and the relationship panels, see [Sorting by several columns](https://getmartis.com/docs/core/resources#sorting-by-several-columns) below) is ordered by:

1. the keys the request names, in order (`?sort=locale:asc,name:desc`);
2. then the `defaultSort()` keys the request did not name, in their order: **the default is the tiebreaker**, so one click on "Language" gives `locale, category, position`, not `locale` alone;
3. then the model's qualified primary key, ascending, so pagination is stable.

The primary key tiebreaker changes the order of tied rows on a resource that sorts by a non-unique column: they come by id now. Without a request key, the order is the default, then the key.

The schema payload carries `defaultSort` as `[{ "attribute": "category", "direction": "asc" }, ...]` (the keys the user may sort by); `defaultSortDirection` is no longer part of it. A custom `component:Table` override receives `sort` (the list of keys) and `onSort(attribute, additive)` instead of `sortBy`, `sortDir` and `onSort(attribute)`.

#### Sorting by several columns

`?sort=` takes a comma-separated list of `attribute[:direction]` (`?sort=category:asc,position:asc,locale:desc`), a missing direction being `asc`. A single key without a suffix still takes `&direction=` (`?sort=title&direction=desc`). At most 3 keys count (the rest are ignored), a repeated attribute keeps its first occurrence, and a key that is not a sortable field the user can see is dropped while the others apply. One parser and one applier (`Martis\Support\ListSort`) serve the index, a lens and the `HasMany`, `MorphMany`, `HasManyThrough`, `BelongsToMany` and `MorphToMany` panels.

On screen, a click on a sortable header sorts by that column alone (the default keys still break ties) and a second click flips it; **Shift+click** (Shift+Enter on a focused header) adds the column as the next key and cycles ascending, descending, removed. A fourth key is refused with a toast. With more than one key each sorted header shows its position (1, 2, 3); `aria-sort` is on the first key's header only, the others carry hidden text ("sort 2 of 3, ascending"). When the sort differs from the default, a "Sorted by" chip on the index toolbar opens a popover to move a key up or down, flip it, remove it or add a column: the way to sort by several columns on a touch screen. "Reset view" returns to the default sort. The server orders the rows; the table never re-sorts them in the browser.

## Query Hooks

Static query hooks wrap every Eloquent query built by Martis for this resource. They run server-side before pagination and coexist with authorization policies — policies decide *who* can query, query hooks decide *which rows* are visible.

### indexQuery()

Constrains the index listing query and the queries Martis builds like it: the count badge, the global search (its results and `total`), the records an action runs on, and the parent record of a `BelongsToMany` panel and of the pivot routes. The canonical place for multi-tenancy, ownership scoping, or any other structural filter. The declarative [`scopes()`](https://getmartis.com/docs/auth/authorization#declarative-query-scopes) run first wherever it runs. A lens starts from the same query (v2.4.0), so it never lists what the index hides, unless it opts out with `Lens::$withoutIndexScope` (see [Lenses](https://getmartis.com/docs/core/lenses#the-lens-lists-what-the-index-lists)); the relationship pickers use `relatableQuery()` below.

```
public static function indexQuery(Request $request, Builder $query): Builder
{
    return $query->where('tenant_id', $request->user()?->tenant_id);
}
```

Override when: you need a global constraint on the index that is *not* a user-configurable filter (filters are opt-in; `indexQuery()` is always applied).

**Relationship panels apply it too (v2.0).** A `HasMany`, `HasManyThrough`, `MorphMany`, `BelongsToMany` or `MorphToMany` panel that lists this resource on another resource's detail page applies its `scopes()` and `indexQuery()`, in the index's order, so a row the index hides is hidden there too (and out of the panel's count and search); so do the relationship counts (`showOnIndex()` columns, the one-of-many "1 of N"). Nova does the same: its relationship index runs the related resource's `indexQuery()` ([nova-issues#971](https://github.com/laravel/nova-issues/issues/971), [#3655](https://github.com/laravel/nova-issues/issues/3655), [#337](https://github.com/laravel/nova-issues/issues/337)), not its `relatableQuery()`, which only feeds the pickers ([docs](https://nova.laravel.com/docs/v5/resources/authorization#relatable-filtering), [#909](https://github.com/laravel/nova-issues/issues/909)).

Write the hooks for this resource's own index query; Martis keeps them there:

- On a plain `HasMany` / `MorphMany` panel they run on the panel's query as an Eloquent scope, so what they add is grouped, a leading `orWhere()` included (it reads as `and`, as on the index): an `orWhere()` cannot widen the panel to another parent's rows (the Nova issue #3655 describes). Their order and select aliases stay; their order comes before the panel's `?sort=`, as on the index. A hook that joins must select its table's columns (`$query->select('comments.*')->join(...)`), as the index already needs. A search on such a panel, as on the index, fails (500) when the joined table has a column of the same name as one the search reads: qualify it in the join, or select only what the index needs.
- On a Through or pivot panel, and for every count, they run on a fresh query of this model, and the panel keeps the rows whose key they return. A `select()`, a join (to the parent, the pivot or the intermediate table) or an unqualified column works as on the index; the hooks' order and aliases do not reach those rows (a panel cannot sort by an alias only the hook adds). The key subquery costs little: a 100-row page of a parent index that counts three relationships runs 2 queries, where the per-row counts before v2.0 ran 302, and in one machine's run it was about an order of magnitude faster, with 100,000 related rows as with 500,000 (absolute timings vary with the load).
- The relationship keeps its own definition: a global scope it removes (`->withoutGlobalScope(ArchivedScope::class)`, `->withoutGlobalScopes()`, `->withTrashed()`) stays removed on its panel and in its counts, and the hooks receive this model's query without it, as Nova's relationship index runs them on the relationship's own query. The hooks only narrow the relationship's rows: a row it leaves out stays out, and a row only a removed scope would hide stays in.
- To tell a panel from the index, read `$request->route('relationship')`: it is set on a relationship panel's own request and on its pivot actions' runs. The counts a parent's index or detail page shows run in that page's request, where it is not set, and an action run from a `HasMany` / `MorphMany` panel names it in its input instead (`$request->input('viaRelationship')`), since it resolves its records as that panel lists them.

Source: `src/Resource.php::indexQuery()`.

### Avoiding per-row queries on index pages (v2.20.0)

A listing runs your code once per row: a field resolver, `canSeeForModel()`, an action's `canRun()`, an accessor that reads a relation. Each query inside one costs a round trip per row. Fix it by loading what the page needs once, then reading it per row.

**Do not attach that work to `indexQuery()` or `scopes()`.** They are not a listing hook: Martis runs them wherever it needs this resource's records, namely the index, the count badge, the global search, the records an **action runs on** (a POST, and the action's fields request), the parent record of a `BelongsToMany` panel, and the lens and relationship-panel lookups an action resolves its targets through. Heavy work there is wasted on an action POST, and what it loaded describes the records before the action changes them.

Use `prefetchIndexPage()`:

```
use Illuminate\Database\Eloquent\Collection;
use Martis\Support\PageContext;
 
public static function prefetchIndexPage(Request $request, Collection $rows, PageContext $context): void
{
    $rows->load('currentAgent');                                    // eager load for the page
 
    $context->putMany('has_grant', Grant::query()                   // one query for the page
        ->whereIn('user_id', $rows->modelKeys())
        ->pluck('user_id', 'user_id')
        ->map(fn () => true)
        ->all());
}
```

- It is called **once per listed page**, after the page is read and before its rows are serialized, by the endpoints that list rows: the resource index, a lens, and a relationship panel's table (`HasMany`, `HasManyThrough`, `MorphMany`, `BelongsToMany`, `MorphToMany`). `$rows` is the page's models. An empty page does not call it.
- It is on `Resource`, not on `ResourceContract`: a resource class that only implements the contract has no hook, and Martis skips the call for it.
- It is **never** called when an action resolves its records, nor by the detail, edit, create or count endpoints, nor by a relationship picker. The default does nothing, so a resource that ignores it keeps today's behaviour.
- A lens page that is served from the lens cache (`cacheFor()`) was serialized before and does not read the rows, so the hook does not run for it either.
- `Martis\Support\PageContext` holds the facts for the request: `put($key, $row, $value)`, `putMany($key, [$id => $value])`, `get($key, $row, $default = null)`, `has($key, $row)`. `$row` is a model or its key. Read it from a field resolver, `canSeeForModel()`, an accessor or an action's `canRun()` with `PageContext::current($request)` (`PageContext::current()` uses the current request).
- **Always fall back to your domain logic for a row the context does not hold.** The context is empty outside a listing (`get()` returns its default), which is the case on the action POST that runs after the page. A `canRun()` whose default is permissive would allow the run:

```
->canRun(function (Request $request, Model $row): bool {
    $context = PageContext::current($request);
 
    return $context->has('has_grant', $row)
        ? $context->get('has_grant', $row)       // the page's answer
        : $row->grants()->exists();               // the same rule, for a row not on a page
})
```

For an action that must decide a whole page at once, see [`canRunForRows()`](https://getmartis.com/docs/core/actions#deciding-a-page-in-one-call-canrunforrows-v2200).

**Prove it stays flat.** `Martis\Testing\AssertsFlatQueries` seeds a few rows, counts the queries of a request, seeds many more and asserts the count did not grow. A failure lists the queries that ran more often:

```
uses(\Martis\Testing\AssertsFlatQueries::class);
 
it('lists customers in a flat number of queries', function () {
    $this->actingAs(User::factory()->create());
 
    $this->assertFlatQueries(
        seed: fn (int $rows) => Customer::factory()->count($rows)->create(),   // rows to ADD
        request: fn () => $this->getJson('/martis/api/resources/customers?per_page=100')->assertOk(),
        few: 2,
        many: 20,
    );
});
```

`seed` receives the number of rows to **add**: `few`, then `many - few`. Create whatever each row needs (related records) inside it. Pass `warmUp: true` when the first run of the request fills a cache. It returns the two counts.

**Catch a lazy load where it is written.** Set `martis.dev.prevent_lazy_loading_in_listings` (`MARTIS_DEV_PREVENT_LAZY_LOADING_IN_LISTINGS`, off by default) to guard the listings: a relationship lazy loaded while a page's rows are serialized throws Laravel's `LazyLoadingViolationException` in `local` and `testing`, and is logged as a warning elsewhere (the load goes ahead). Only the rows of the page, and the relations already loaded on them, are guarded, and only while they are serialized; every flag is put back afterwards and Laravel's global `Model::preventLazyLoading()` is never turned on. An application that registered its own `Model::handleLazyLoadingViolationUsing()` keeps its handler in `local` and `testing`; elsewhere the warning handler replaces it for the length of the serialization and it is restored. Reading that handler needs reflection on Laravel's `Model::$lazyLoadingViolationCallback` (verified on Laravel 13.39.0); if a Laravel release renames it, the guard is skipped for the listing and one warning is logged per process, so a listing never fails because of this dev aid. The guard sees a relation read as a property (`$row->parent`), not a query a resolver writes (`DB::table(...)`, `$row->parent()->first()`): `AssertsFlatQueries` catches those.

Source: `src/Resource.php::prefetchIndexPage()`, `src/Support/PageContext.php`, `src/Support/ListingGuard.php`, `src/Testing/AssertsFlatQueries.php`.

### relatableQuery()

Constrains the query used to list candidate records in every relationship picker that targets this resource: BelongsTo dropdowns, the context-free relatable form, and the BelongsToMany / MorphToMany attach pickers. It is the resource's own fence and always applies; a source resource's `relatable{PluralModelName}()` and a field's `relatableQueryUsing()` narrow on top of it, never replace it (see [Relationships → Relatable scoping precedence](https://getmartis.com/docs/core/relationships#relatable-scoping-precedence)). A resource that confines its index with `indexQuery()` or `scopes()` on a model that cannot carry a global scope should declare the same predicate here so the fence holds on the pickers too: neither hook applies to a picker. This includes the context-free picker (`GET /resources/_/_/relatable/{field}?related_resource={uriKey}`, used by a form that belongs to no resource, such as a Tool's): it lists any resource's records for a user who may `viewAny` it, behind this query alone, so a resource that fences tenants only in `indexQuery()` lists every tenant's rows there.

**What a picker row carries (v2.4.0).** A picker renders an id, a label and, when the field asks for it, a subtitle, so the relatable endpoint serialises a row as `id`, `_title` (the resource's `title()`) and the display value of the attributes the picker reads: the field's `titleAttribute()` and, with `withSubtitles()`, its `subtitleAttribute()`, as far as they are index fields the user may see for the record (`canSee()`, `canSeeForModel()`). It no longer serialises the whole index row: the other columns, the `_authorization` block and the `_resource` descriptor are not in a picker row. The context-free endpoint has no field to read the attributes from, and what a client names in `?title_attribute=` / `?subtitle_attribute=` is its own choice: only a `title_attribute` equal to the related resource's declared `titleAttribute()` is serialised (when it is a visible index field), the subtitle has no declaration on the resource and is dropped, and any other column answers nothing (it would read a column of rows `relatableQuery()` does not fence). A custom picker that read another index column from a relatable row must put that column in `titleAttribute()` / `subtitleAttribute()`, or read it from the record's own endpoint.

The writes follow it too, as Nova's `Relatable` rule: a create, update, attach or Action run that names a record outside the query answers 422 on the field (see [Relationships → Writes follow the pickers](https://getmartis.com/docs/core/relationships#writes-follow-the-pickers)). So keep it as wide as what the app actually saves.

```
public static function relatableQuery(Request $request, Builder $query): Builder
{
    return $query->where('active', true); // never offer inactive records
}
```

Override when: a BelongsTo/MorphTo selector should hide records that are otherwise valid on the index.

Source: `src/Resource.php::relatableQuery()`.

## Table Configuration

### tableStriped() / tableShowGridlines() / tableSize() / tableRowHover()

Configure the appearance of the index table.

```
public function tableStriped(): bool      { return true; }      // Alternating row colors
public function tableShowGridlines(): bool { return false; }     // Cell borders
public static function tableSize(): TableSize { return TableSize::Normal; } // Small, Normal, Large
public function tableRowHover(): bool      { return true; }      // Highlight on hover
```

### tableLayout()

Controls how the index `<table>` distributes column widths.

```
use Martis\Enums\TableLayout;
 
public static function tableLayout(): TableLayout
{
    return TableLayout::Auto; // default
}
```

- `Auto` (default) — the browser sizes each column by content. Martis ships sensible per-type defaults (Id → 80px, Email/Url → 280px max + ellipsis, Date → 140px, Boolean/Status → 120px; the `titleAttribute` column gets `minWidth: 220px`). Override per field with `->width()`, `->minWidth()`, `->maxWidth()`, `->truncate()`.
- `Fixed` — applies CSS `table-layout: fixed`, locking every column to its declared `->width()` (or the type default). Only pick this when you need pixel-perfect alignment across pages and can afford to width every visible column.

Per-field examples:

```
Url::make('homepage')->maxWidth('200px')->truncate();
Id::make();                      // auto: 80px from the type default
Text::make('name');              // auto: minWidth 220px when name = titleAttribute
Badge::make('state')->width('96px');
```

### stickyActions()

```
public static function stickyActions(): bool
{
    return true; // default: false
}
```

Pins the row-actions column to the right edge of the index table (and of a lens) while a wide table scrolls sideways, with a left hairline and an opaque background that follows the zebra, hover and selected row states in light and dark. Combine it with `Field::hideBelow()` and `maxWidth()` to keep narrow viewports usable. v2.23.0+.

#### Opting out of the type-default heuristics

If you prefer the pre-v0.7.0 behaviour where every column auto-sizes and nothing truncates, flip the global switch:

```
// config/martis.php
'index' => [
    'column_defaults' => env('MARTIS_INDEX_COLUMN_DEFAULTS', true),
],
```

Set to `false` (or `MARTIS_INDEX_COLUMN_DEFAULTS=false` in `.env`) and Martis:

- skips every per-type default (`Id`, `Email`/`Url`, `Boolean`/`Status`/`Badge`, `Date`/`DateTime`);
- no longer injects the `minWidth: 220px` on the `titleAttribute` column.

Explicit per-field calls (`->width()`, `->minWidth()`, `->maxWidth()`, `->truncate()`) still apply regardless of the flag, so you can opt out globally and then whitelist individual columns as needed.

### actionsColumnLabel() / actionsMenuLabel() / bulkActionsMenuLabel()

Per-resource overrides for the text shown on the row-actions column and its two menus. Return `null` to fall back to the i18n default (`martis::actions.actions`).

| Method | Default | Controls |
| --- | --- | --- |
| `actionsColumnLabel(): ?string` | `"Actions"` / `"Ações"` (i18n) | Header text of the row-actions column on the index. |
| `actionsMenuLabel(): ?string` | `null` (falls back to i18n) | Label of the per-row "Actions" dropdown. |
| `bulkActionsMenuLabel(): ?string` | `null` (falls back to i18n) | Label of the toolbar "Bulk Actions" dropdown shown when rows are selected. |

```
public static function actionsColumnLabel(): ?string     { return 'Tools'; }
public static function actionsMenuLabel(): ?string        { return 'Row tools'; }
public static function bulkActionsMenuLabel(): ?string    { return 'Batch tools'; }
```

### Row-click behaviour

Whether clicking a row in the index opens the detail page is controlled by `rowClickOpensDetail(Request $request): ?bool` (per-resource override) and resolved to a concrete boolean by `resolveRowClickOpensDetail(Request $request): bool`. When the per-resource method returns `null`, the resolver falls back to `config('martis.index.row_click_opens_detail')`.

See [Default Row Actions — Row-click redundancy](https://getmartis.com/docs/core/default-row-actions#row-click-redundancy) for the full decision matrix. A resource with a detail override opens the record over the index instead of on its own page: see [Overrides → Detail overrides open over the index](https://getmartis.com/docs/customization/overrides#detail-overrides-open-over-the-index).

### confirmUnsavedChanges()

Martis differential. Opts the create/update surfaces — both drawer overrides and full-page create/update routes — into the **UnsavedChangesDialog**. When the user tries to discard changes (close the drawer, navigate away, click Cancel), the dialog asks for confirmation.

On the full-page routes the browser back button is held only while the form has unsaved changes (v1.38.0+); a clean form leaves Back and Forward working as on any other page. Before v1.38.0 opening a create or edit page erased the Forward history, and Back could skip the previous page. See [Overrides → Unsaved changes guard](https://getmartis.com/docs/customization/overrides#unsaved-changes-guard).

```
// Enable with package defaults (generic copy).
public static function confirmUnsavedChanges(): bool
{
    return true;
}
 
// Or enable and customise copy, icon, colours, button labels.
public static function confirmUnsavedChanges(): \Martis\Contracts\UnsavedChangesConfigContract
{
    return \Martis\UnsavedChangesConfig::make()
        ->title('Discard post changes?')
        ->body('Your edits to this post will be lost.')
        ->confirmLabel('Discard')
        ->cancelLabel('Keep editing');
}
```

Three return shapes:

- `false` (default) — dialog disabled; forms close silently.
- `true` — dialog enabled with package defaults.
- `UnsavedChangesConfigContract` — dialog enabled with custom title/body/icon/colours/button labels.

The resource-level `archiveConfirmMessage()`, `deleteConfirmMessage()`, and `forceDeleteConfirmMessage()` methods (see [Custom Messages](https://getmartis.com/docs/core/resources#custom-messages)) win over the generic per-variant defaults of this dialog.

Source: `src/Resource.php::confirmUnsavedChanges()`.

## Validation

Validation in Martis is **field-level** — each field declares its own rules using `rules()`, `required()`, `nullable()`, and `unique()`. The `ResourceController` automatically collects and validates all field rules on create and update.

```
// Validation is set on each field directly:
Text::make('title')->required()->rules(['string', 'max:255']),
Email::make('email')->required()->unique(['users', 'email'], 'This email is already in use.'),
Password::make('password')->required()->rules(['min:8']),
```

See the [Fields Reference](https://getmartis.com/docs/core/fields) for all 50 field types and validation methods.

### errorDisplay()

Controls how validation errors are shown in the frontend.

```
public static function errorDisplay(): ErrorDisplayMode
{
    return ErrorDisplayMode::Inline; // Inline, Toast, or Both
}
```

## Form save variants

Both the create and update forms ship with three submit buttons each, covering the common power-user shortcuts. The default button preserves the historical behaviour; the two extra buttons stay on or jump out of the form depending on the operator's intent.

| Page | Button | Post-save destination |
| --- | --- | --- |
| Create | `Create {Resource}` (primary) | Detail page of the new record |
| Create | `Create & add another` | Same `/create` page with a freshly cleared form, whose fields mount again so none keeps the previous record's state (v1.38.0+) |
| Create | `Create & view list` | Resource index |
| Update | `Save changes` (primary) | Detail page of the record |
| Update | `Save & continue editing` | Same `/edit` page (baseline refreshed to the values saved, so the unsaved-changes guard does NOT re-trigger for them; anything typed while the save ran still counts as unsaved, v1.38.0+) |
| Update | `Save & view list` | Resource index |

The two extra buttons are **hidden when the form is launched from a nested relation** (i.e. `?viaResource=…&viaResourceId=…`). Nested flows already manage their own post-save redirect to the parent surface, and a "view list" jump from inside a relation panel would be confusing.

Translations live in `actions.php`:

| Key | English |
| --- | --- |
| `create_and_add_another` | Create & add another |
| `create_and_view_list` | Create & view list |
| `save_and_continue_editing` | Save & continue editing |
| `save_and_view_list` | Save & view list |
| `reset_filters` | Reset filters |

### Custom post-save destination — redirectAfterCreate() / redirectAfterUpdate()

When the operator clicks the **primary** submit button (`Create {Resource}` or `Save changes`), the SPA navigates to the new record's detail page by default. Override these hooks to send the user somewhere else for that primary path:

```
public function redirectAfterCreate(Model $model, Request $request): ?string
{
    // Land on the column the new card joined.
    return "/resources/boards/{$model->board_id}";
}
 
public function redirectAfterUpdate(Model $model, Request $request): ?string
{
    // Stay on the parent resource when this row was edited from a panel.
    if ($request->input('viaResource')) {
        $via = $request->input('viaResource');
        $viaId = $request->input('viaResourceId');
        return "/resources/{$via}/{$viaId}";
    }
    return null; // fall through to default
}
```

Return `null` (the default) to keep the standard behaviour. The two extra save variants — "Create & add another" / "Create & view list" / "Save & continue editing" / "Save & view list" — always win because they encode an explicit user intent. The hook is only consulted for the primary submit button.

The string travels back to the SPA in the create/update response under `meta.redirectTo`.

Since v1.38.0 a destination on another record's edit page opens that record's form from scratch; the page used to keep the form it had just saved, so the next save sent those values to the new record. A destination on the same record's edit page keeps the saved values, which no longer count as unsaved changes; what was typed while the save ran still does (before v1.38.0 it counted as saved too, and leaving the page dropped it without asking). A destination on another resource's create page, another parent's nested create or another record to replicate opens a fresh create form the same way.

## Index toolbar resets

The index toolbar surfaces two reset affordances:

- **Reset filters** — clears only the active filters and resets to page 1. Sort, search, pagination size, and the trashed-toggle are preserved. Visible whenever any filter is active.
- **Reset view** — the bigger hammer that clears filters + search + sort + per-page + trashed-toggle and forgets the [sticky view](https://getmartis.com/docs/core/sticky-views) for this resource. Visible whenever any of those drift away from the resource's defaults.

Both buttons coexist on the toolbar — they target different aggressiveness levels so users can pick the right one for the moment.

## Lifecycle Hooks

### beforeSave()

Called before `$model->save()` on both create and update. The `$creating` parameter distinguishes between the two.

```
public function beforeSave(Model $model, Request $request, bool $creating): void
{
    if ($creating) {
        $model->user_id = $request->user()?->id;
    }
    $model->slug = Str::slug($model->title);
 
    parent::beforeSave($model, $request, $creating); // Dispatches BeforeSave event
}
```

### afterSave()

Called after `$model->save()`.

```
public function afterSave(Model $model, Request $request, bool $creating): void
{
    if ($creating) {
        $model->tags()->sync($request->input('tags', []));
    }
    Cache::forget("post:{$model->id}");
 
    parent::afterSave($model, $request, $creating); // Dispatches AfterSave event
}
```

### beforeDelete()

Called before deletion. Throw an exception to prevent deletion. To tell the user why, throw `Martis\Exceptions\UserFacingException`: its message is the one the delete endpoint returns as it is.

```
use Martis\Exceptions\UserFacingException;
 
public function beforeDelete(Model $model, Request $request): void
{
    if ($model->is_protected) {
        throw new UserFacingException('This record cannot be deleted.');
    }
 
    parent::beforeDelete($model, $request); // Dispatches BeforeDelete event
}
```

`UserFacingException` answers 422 by default; pass another status as the second argument (`new UserFacingException('Locked by another user.', 409)`). The panel shows the message of a failed delete in its toast (on the index, the detail page, the drawer and a lens), so the reason reaches the user. The typed `ValidationException`, `AuthorizationException` and `ResourceNotFoundException` (see [Exception Handling](https://getmartis.com/docs/core/resources#exception-handling)) keep their message and status too.

Any other exception that escapes the delete (a `RuntimeException` from a hook, an observer, a model event, a storage or cache driver) is internal: its message can hold a path, a bucket name or a class name. In production the endpoint answers 500 with the generic `martis::messages.error_delete` message and sends the details to the log (`Martis: error on delete`) and to `report()`. With `app.debug` on, the raw message is returned, so a developer still sees what broke. Never put an internal detail in the message of a `UserFacingException`: the user sees it.

### afterDelete()

Called after deletion.

```
public function afterDelete(Model $model, Request $request): void
{
    AuditLog::record('deleted', static::class, $model->id);
 
    parent::afterDelete($model, $request); // Dispatches AfterDelete event
}
```

## Events

When hooks call `parent::hookName()`, Laravel events are dispatched automatically. Use event listeners for cross-cutting concerns:

```
use Martis\Events\BeforeSave;
use Martis\Events\AfterSave;
use Martis\Events\BeforeDelete;
use Martis\Events\AfterDelete;
 
// In a service provider's boot() — typically MartisServiceProvider for app-side concerns.
Event::listen(AfterSave::class, function (AfterSave $event) {
    AuditLog::record('saved', $event->resourceClass, $event->model->id, $event->creating);
});
```

| Event | Trigger | Properties |
| --- | --- | --- |
| `BeforeSave` | Before `$model->save()` | `resourceClass`, `model`, `request`, `creating` |
| `AfterSave` | After `$model->save()` | `resourceClass`, `model`, `request`, `creating` |
| `BeforeDelete` | Before `$model->delete()` | `resourceClass`, `model`, `request` |
| `AfterDelete` | After `$model->delete()` | `resourceClass`, `model`, `request` |

## Custom Messages

Override the default notification messages and confirm dialogs shown after CRUD operations. Every method returns a string; the default implementation pulls from `martis::messages.*` so translations flow through Laravel's `__()` helper.

| Method | When shown | Default i18n key |
| --- | --- | --- |
| `createdMessage()` | Toast after `POST /resources/:key` succeeds. | `martis::messages.record_created` |
| `updatedMessage()` | Toast after `PUT /resources/:key/:id` succeeds. | `martis::messages.record_updated` |
| `deletedMessage()` | Toast after `DELETE /resources/:key/:id` succeeds. | `martis::messages.record_deleted` |
| `restoredMessage()` | Toast after a soft-deleted record is restored. | `martis::messages.record_restored` |
| `forceDeletedMessage()` | Toast after a soft-deleted record is permanently deleted. | `martis::messages.record_force_deleted` |
| `replicatedMessage()` | Toast after a record is duplicated via the Replicate action (v1.38.0+; before, the create after a Replicate showed `createdMessage()`). | `martis::messages.record_replicated` |
| `deleteConfirmMessage()` | Body of the destructive-delete confirm dialog. | `martis::messages.delete_confirm` |
| `archiveConfirmMessage()` | Body of the soft-delete (archive) confirm dialog. | `martis::messages.archive_confirm` |
| `forceDeleteConfirmMessage()` | Body of the force-delete confirm dialog (trashed records only). | `martis::messages.force_delete_confirm` |
| `validationMessage()` | Toast shown when validation fails and `errorDisplay()` includes toast mode. | `martis::messages.validation_failed` |

Example overrides:

```
public static function createdMessage(): string            { return 'Post published!'; }
public static function updatedMessage(): string             { return 'Post updated.'; }
public static function deletedMessage(): string             { return 'Post removed.'; }
public static function restoredMessage(): string            { return 'Post restored.'; }
public static function forceDeletedMessage(): string        { return 'Post permanently deleted.'; }
public static function replicatedMessage(): string          { return 'Post duplicated.'; }
public static function archiveConfirmMessage(): string      { return 'Archive this post? You can restore it later.'; }
public static function deleteConfirmMessage(): string       { return 'Delete this post permanently?'; }
public static function forceDeleteConfirmMessage(): string  { return 'This post will be gone forever. Continue?'; }
public static function validationMessage(): string          { return 'Please fix the errors in the form.'; }
```

> The three `*ConfirmMessage()` methods on the Resource win over the dialog's per-variant localized defaults. That is, if you override `archiveConfirmMessage()`, the UnsavedChangesDialog surfaces that string instead of its own generic archive copy.

## Soft Deletes

If your Eloquent model uses the `SoftDeletes` trait, Martis automatically:

- Shows an "Archive" button instead of "Delete" on the detail page
- Displays a badge on archived records
- Adds a "Restore" button to restore archived records
- Supports `?trashed=only` and `?trashed=with` query parameters on the index API

No additional configuration is needed — Martis detects `SoftDeletes` automatically.

### Restricting Trashed Visibility by Role

By default, all users see the trashed filter dropdown when a resource uses soft deletes. Override `canViewTrashed()` to restrict this by role:

```
public static function canViewTrashed(): bool
{
    return auth()->user()?->isAdmin() ?? false;
}
```

When `canViewTrashed()` returns false:

- The trashed filter dropdown is hidden on the index page
- The backend ignores `?trashed=` query parameters
- The schema reports `softDeletes: false` for that user

Note: `findModel()` and `serializeModel()` remain ungated so that users with `restore` or `forceDelete` permissions can still act on trashed records via direct URL.

`canViewTrashed()` gives per-resource role-based control over the SoftDeletes filter visibility.

## Search

Resources support global search when fields are marked as `searchable()`:

```
Text::make('title')->searchable(),
Email::make('email')->searchable(),
```

The search bar on the index page queries all searchable fields using a `LIKE %term%` query. For more advanced search, Martis supports Laravel Scout integration via the `SearchResolver`.

### globallySearchable()

Controls whether the resource participates in the Cmd+K global search modal. Defaults to `true` — opt out to hide internal or noisy resources from the palette.

```
public static function globallySearchable(): bool
{
    return false; // exclude from Cmd+K
}
```

Source: `src/Resource.php::globallySearchable()`.

### searchSubtitle()

Returns the per-record subtitle shown under the title in global-search results. Receives the current model so you can pull from any attribute.

```
public function searchSubtitle(Model $model): ?string
{
    return $model->email; // e.g. "john@acme.com" under "John Doe"
}
```

Return `null` (default) to show no subtitle. Source: `src/Resource.php::searchSubtitle()`.

### Laravel Scout integration

When the underlying Eloquent model uses the `Laravel\Scout\Searchable` trait, Martis automatically uses Scout for searches instead of `LIKE %term%`. Two hooks customise the integration:

| Method | Purpose |
| --- | --- |
| `usesScout(): bool` | `true` when the model is `Searchable`; override to `false` to force database search even when the trait is present. |
| `scoutQuery(Request $request, mixed $query): mixed` | Customise the Scout builder (add `where`, filters, callbacks). Only called when `usesScout()` is `true`. |

```
public static function usesScout(): bool
{
    return auth()->user()?->prefers_scout === true;
}
 
public static function scoutQuery(Request $request, mixed $query): mixed
{
    return $query->where('tenant_id', $request->user()?->tenant_id);
}
```

The `public static ?int $scoutSearchResults = null;` static property caps the number of hits Scout returns. Source: `src/Resource.php` (Scout integration section).

## Authorization

Martis provides dedicated resource policies, auto-discovery, and comprehensive ability checking.

### Policy Resolution Chain

When checking authorization, Martis resolves the policy in this order:

1. **Explicit $policy property** on the Resource class
2. **Auto-discovery** by convention: `{policy_namespace}\{ResourceBaseName}Policy`
3. **Laravel Gate** policy registered for the model class
4. **No policy found** → permissive defaults (all allowed)

The policy namespace defaults to App\Martis\Policies and can be configured via config(martis.policy_namespace).

### Resource Abilities

| Ability | Controls | Default (no method) |
| --- | --- | --- |
| viewAny | Navigation + index access | allowed |
| view | Detail page access | forbidden |
| create | Create button + form | forbidden |
| update | Edit button + form | forbidden |
| replicate | Replicate button | fallback to create AND update |
| delete | Delete/archive button | forbidden |
| restore | Restore soft-deleted record | forbidden |
| forceDelete | Permanently delete soft-deleted record | forbidden |

### Action Abilities

| Ability | Controls | Default (no method) |
| --- | --- | --- |
| runAction | Normal (non-destructive) actions | fallback to update |
| runDestructiveAction | Destructive actions | fallback to delete |

The matching hooks on the Resource base class are:

| Method | Policy method checked | Fallback when policy does not define it |
| --- | --- | --- |
| `authorizedToReplicate(Request $request): bool` | `replicate` | Must pass **both** `authorizedToCreate()` AND `authorizedToUpdate()`. |
| `authorizedToRunAction(Request $request): bool` | `runAction` | Delegates to `authorizedToUpdate()`. |
| `authorizedToRunDestructiveAction(Request $request): bool` | `runDestructiveAction` | Delegates to `authorizedToDelete()`. |

Override these directly on a Resource to hardcode behaviour without writing a Policy method — see [Disabling Specific Action Buttons](https://getmartis.com/docs/core/resources#disabling-specific-action-buttons) below.

### Relationship Abilities

| Ability | Controls | Default (no method) |
| --- | --- | --- |
| `add{Model}` | Inline create related record | allowed |
| `attach{Model}` | Attach specific related record | allowed |
| `attachAny{Model}` | Attach at all: the list of records to attach, the attach and the attach modal's pivot pickers answer 403 when it denies (v1.38.0+) | allowed |
| `detach{Model}` | Detach related record | allowed |

### Authorization Metadata

Every API response includes _authorization metadata per record with boolean flags: authorizedToView, authorizedToUpdate, authorizedToDelete, authorizedToReplicate, authorizedToRunAction, authorizedToRunDestructiveAction, authorizedToRestore, authorizedToForceDelete.

Schema responses include collection-level authorization: authorizedToViewAny, authorizedToCreate.

The frontend uses this metadata to conditionally show/hide action buttons.

### Disabling Authorization

Set authorizable() to false on a resource to skip all policy checks.

### Field-Level Authorization

Fields support canSee(callable) and canSeeWhen(ability) for visibility control.

### before() Callback

Policies support a before() method that runs before any specific ability check. Return true to allow, false to deny, or null to fall through to the specific method.

### Generating Policies

Use php artisan martis:policy PolicyName --model=ModelName to generate a complete policy stub. The historical `martis:make-policy` name is kept as a hidden alias for backwards compatibility.

The generated policy is **deny-by-default** (v2.4.0+): the ten resource and action abilities (`viewAny`, `view`, `create`, `update`, `replicate`, `delete`, `restore`, `forceDelete`, `runAction`, `runDestructiveAction`) each return `false` and carry a `// TODO` naming the decision to make, so a policy registered without an edit fails closed: nobody can list, read, create, change or delete the resource, and nobody can permanently delete a record by accident. Replace each `return false;` with the rule that fits (a role check, an ownership test) and delete the TODO. Keep the methods you do not mean to open: a method removed from the class falls back to the Martis default, which allows `viewAny` and the relationship abilities. Policies generated before v2.4.0 returned `true` from every ability; they are yours and were not touched, so review them if you registered one unedited. The commented relationship examples at the bottom of the file deny too.

### Disabling Specific Action Buttons

To hide a specific button (Edit, Delete, Replicate, etc.) from the UI and enforce it on the backend, override the corresponding `authorizedTo*()` method directly in your Resource class:

```
class PostResource extends Resource
{
    // Remove the Replicate button entirely
    public function authorizedToReplicate(Request $request): bool
    {
        return false;
    }
 
    // Remove Force Delete (even for soft-deleted records)
    public function authorizedToForceDelete(Request $request): bool
    {
        return false;
    }
 
    // Remove the Edit button
    public function authorizedToUpdate(Request $request): bool
    {
        return false;
    }
 
    // Remove the Delete button
    public function authorizedToDelete(Request $request): bool
    {
        return false;
    }
 
    // Remove the Create button from the index page
    public function authorizedToCreate(Request $request): bool
    {
        return false;
    }
}
```

This approach is **enforced on both frontend AND backend** — the button is hidden and the API rejects the request with 403.

For conditional logic (e.g., only admins can delete), use a Policy instead of hardcoding false.

Available methods: `authorizedToView`, `authorizedToCreate`, `authorizedToUpdate`, `authorizedToDelete`, `authorizedToReplicate`, `authorizedToRestore`, `authorizedToForceDelete`, `authorizedToRunAction`, `authorizedToRunDestructiveAction`.

### Attachment Uploads (Trix / Markdown)

The `AttachmentController` handles file uploads from Trix and Markdown rich text editors. A field accepts files when it declares `withFiles()`:

```
Trix::make('body')->withFiles();            // the panel's martis.storage.disk ('public' by default)
Trix::make('body')->withFiles('s3');        // a disk of its own
Markdown::make('notes')->withFiles('public');
```

An upload names the field it belongs to, as the form it comes from does, and is authorised like that form (v2.4.0):

```
POST /martis/api/attachments/upload?resource={uriKey}&field={attribute}[&id={recordId}][&repeater={attribute}&repeatable={shortName}]
file = the uploaded file (multipart)
```

- `resource` and `field` are required (422 without them; 404 for an unknown resource). `id` names the record an edit form is editing; leave it out on a create form. `repeater` and `repeatable` name the row when the field is inside a Repeater (the same parameters the relation pickers send).
- The user must be allowed to list the resource (`viewAny`), and either update the record `id` names (the field is looked up on the update form) or, without a record the user may update, create the resource (the create forms, the inline-create modal included). Otherwise the answer is 403 and nothing is stored: a signed-in user who may write no record cannot host files on the application's origin. An `id` the user may not update is answered like a missing one.
- The field must be on that form and visible to the user (404 otherwise, like any attribute no form declares), and must declare `withFiles()` (403 when it does not).
- The file goes to the disk the field declares, never to one the request names: a `disk` parameter is ignored. A field that calls `withFiles()` with no disk uses `config('martis.storage.disk')`.
- Uploads are stored under `martis-attachments/` on that disk, as `{40 random characters}.{extension}`, and the response is `{ "url": "...", "href": "..." }` for the editor to embed.
- Only the fields of a resource form upload: the SPA offers no attachment on the fields of an Action, a Tool or a many-to-many pivot, which this endpoint cannot resolve.

Allowed extensions, the maximum size and the upload rate are configurable via `config/martis.php`:

```
// config/martis.php
'attachments' => [
    'allowed_mimes' => explode(',' , env('MARTIS_ATTACHMENT_MIMES', 'jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip,mp4,mp3')),
    'max_size' => (int) env('MARTIS_ATTACHMENT_MAX_SIZE', 10240), // KB
    'throttle_max' => (int) env('MARTIS_ATTACHMENT_THROTTLE_MAX', 20),     // uploads per user...
    'throttle_decay' => (int) env('MARTIS_ATTACHMENT_THROTTLE_DECAY', 1),  // ...per this many minutes
],
```

To allow additional file types (e.g., `.ai`, `.psd`), either:

1. Set the `MARTIS_ATTACHMENT_MIMES` env variable with a comma-separated list
2. Or publish and edit `config/martis.php` directly

The extensions list is a deny-by-omission fence: keep script-capable types (`svg`, `html`) out of it, since the files are served from the application's own origin.

The route carries a dedicated per-user throttle (`throttle_max` uploads per `throttle_decay` minutes, in a bucket of its own, `martis-attachments:{guard}:`) on top of the API's, which `martis.throttle.enabled = false` turns off with it. Over the limit the answer is 429.

#### Removing the files no record references

An editor saves the URL of a file inside the content of the field, and there is no table of uploads, so a file the editor never saved (a closed form, a removed image) stays on the disk. `martis:attachments:prune` deletes them:

```
php artisan martis:attachments:prune --dry-run   # list what would go
php artisan martis:attachments:prune             # delete it
php artisan martis:attachments:prune --hours=72 --disk=s3 --disk=public
```

It reads the text and JSON columns of the table of every registered resource's model (Repeater rows and soft-deleted records included) for the stored file names, and deletes the files of `martis-attachments/` that none holds and that were written more than `--hours` ago (24 by default, at least 1, so an upload from a form that is still open is never taken). Only files with the names the endpoint writes are touched. `--disk` (repeatable) names the disks to sweep; without it, the panel's `martis.storage.disk`. It stops without deleting anything (exit code 1) when a resource's table cannot be read, and when nothing could be scanned at all (no registered resource, or none whose table has a text or JSON column): then every file would look unreferenced, as in a console that booted without the app's resources. Its blind spots are yours to check with a `--dry-run` first: a file referenced from a table no registered resource writes to; a column the database cannot read as text (an encrypted cast stores ciphertext, which holds no file name); and a database-per-tenant setup, where it reads only the default connection of each model, so every other tenant's attachments look unreferenced (sweep a disk per tenant only after checking each tenant's database, or do not schedule it). Nothing runs it for you: schedule it (`$schedule->command('martis:attachments:prune')->daily()`) once you have checked a dry run.

For field-specific MIME restrictions (on File/Image fields in forms), use the `acceptedTypes()` method on the field:

```
File::make('document')
    ->acceptedTypes(['pdf', 'doc', 'docx'])
    ->maxSize(5120);
```

## CRUD Override System

Resources can override how create/update/detail views are rendered using drawer overrides:

```
public function overrides(): array
{
    return [
        'create' => new Override('martis:drawer-create'),
        'update' => new Override('martis:drawer-update'),
        'detail' => new Override('martis:drawer-detail'),
    ];
}
```

See the [Override System](https://getmartis.com/docs/customization/overrides) documentation for full details.

## Exception Handling

Martis provides a set of typed exceptions for structured error handling. These are automatically converted to JSON API responses when thrown from resources, actions, or controllers.

| Exception | HTTP Status | Use Case |
| --- | --- | --- |
| `MartisException` | 500 | Base class for all Martis errors |
| `ValidationException` | 422 | Input validation failures with per-field errors |
| `AuthorizationException` | 403 | Unauthorized access attempts |
| `ResourceNotFoundException` | 404 | Record or resource not found |
| `UserFacingException` | 422 (configurable) | A plain message for the user, shown as it is |

```
use Martis\Exceptions\ValidationException;
use Martis\Exceptions\AuthorizationException;
use Martis\Exceptions\ResourceNotFoundException;
use Martis\Exceptions\UserFacingException;
 
// Per-field validation errors (e.g. in an Action):
throw ValidationException::fromFieldErrors([
    'title' => ['The title must be at least 5 characters.'],
    'slug'  => ['The slug is already taken.'],
]);
 
// Single-field shorthand:
throw ValidationException::forField('slug', 'Slug is already taken.', 'unique');
 
// Authorization:
throw AuthorizationException::forAction('delete', 'post');
 
// Not found:
throw ResourceNotFoundException::forRecord('posts', $id);
 
// A reason the user can act on, in a hook (message shown as it is, 422 by default):
throw new UserFacingException('This post is scheduled and cannot be deleted.');
```

Only these exceptions put their message in a response from a catch-all of the API (the delete endpoint, for one). Any other exception is internal and answers a generic message in production; see [beforeDelete()](https://getmartis.com/docs/core/resources#beforedelete).

All exceptions extend `MartisException` which itself extends `\RuntimeException`. Laravel's exception handler will catch them automatically. Unhandled `MartisException` subclasses return a 500 JSON response in production.

## Complete Example

```
<?php
 
namespace App\Martis;
 
use App\Models\Post;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Martis\Fields\BelongsTo;
use Martis\Fields\Date;
use Martis\Fields\DateTime;
use Martis\Fields\HasMany;
use Martis\Fields\Heading;
use Martis\Fields\Id;
use Martis\Fields\Image;
use Martis\Fields\Text;
use Martis\Fields\Textarea;
use Martis\Resource;
 
class PostResource extends Resource
{
    public static function model(): string { return Post::class; }
    public static function titleAttribute(): string { return 'title'; }
    public static function subtitle(): ?string { return 'Blog posts and articles'; }
    public function icon(): string { return 'newspaper'; }
    public function group(): ?string { return 'Content'; }
    public static function perPageOptions(): array { return [10, 25, 50]; }
    public static function defaultSort(): string|array|null { return 'created_at'; }
    public static function defaultSortDirection(): SortDirection { return SortDirection::Desc; }
 
    public function fields(Request $request): array
    {
        return [
            Id::make('id'),
 
            Heading::make('post_content', 'Post Content')
                ->content('Main post details'),
 
            Text::make('title')->sortable()->searchable()->required(),
 
            Textarea::make('body', 'Content')
                ->nullable()->hideFromIndex(),
 
            Image::make('cover_image', 'Cover')
                ->disk('public')->path('posts/covers')
                ->acceptedTypes(['image/jpeg', 'image/png', 'image/webp'])
                ->maxSize(5120),
 
            BelongsTo::make('user_id', 'Author')
                ->relatedResource('users')
                ->titleAttribute('name')
                ->nullable(),
 
            Date::make('published_at', 'Published')->nullable()->sortable(),
 
            HasMany::make('comments', 'Comments')
                ->relatedResource('comments'),
 
            DateTime::make('created_at', 'Created')->hideFromForms()->sortable(),
        ];
    }
 
    public function beforeSave(Model $model, Request $request, bool $creating): void
    {
        if (empty($model->slug) && !empty($model->title)) {
            $model->slug = Str::slug($model->title);
        }
        parent::beforeSave($model, $request, $creating);
    }
}
```

Upgrading

Fields
