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\MartisPin the namespace explicitly ('resources_namespace' => 'App\\Martis\\Customer') when the directory is not autoloaded. See 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 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), 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.
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, Lenses, Metrics, and 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 barpublic 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 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.
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".
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.
public function belongsToSystemSection(): bool { return true; } public function systemSectionOrder(): int { return 10; } // first in the System sectionClaim 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 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(Eloquentwhere/scope calls return$this, soreturn $query->search($term)is correct). Returning a different Builder instance throwsLogicException— 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 inindexQuery(). Keep the driver-specific SQL in your own model scope; the package ships only the seam. A resource that opts intousesScout()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, 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 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 otherprefill[...]key, is ignored. - A value is a string of at most 255 characters; an array or a longer value is ignored.
- A prefilled
Selectvalue 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 prefilledBelongsToid the field's picker would not list (the related resource'srelatableQuery(), the field'srelatableQueryUsing()) 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) — 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 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,dependsOnfield sync, inline-create, and peek all keep working, still gated by the resource's ownauthorizedTo*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() 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), 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
BelongsTowithoutrelatedResource()checks its value against (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.
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 ('/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:
- Destination for every record link. The template drives
Resource::recordHref()on the backend and the frontendrecordHref(uriKey, id)helper (fed by aresourceRecordUrlsmap onwindow.MartisConfig, built fromMartisManager::recordUrlMap()). Every surface that links to a record — global search results,BelongsTo/MorphTo/HasMany/MorphMany/BelongsToMany/MorphToManydisplays, breadcrumbs, index row-click, post-save redirects — resolves through one of these two functions, so declaringrecordUrl()once retargets the record everywhere. - Opt-in back into global search for a non-routable resource.
SearchControllerskips a resource when! routable() && recordUrl() === null— i.e. a non-routable resource is excluded from search unless it declares arecordUrl, in which case it participates like any other resource (subject to the samegloballySearchable()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 withrecordUrl()still needsgloballySearchable()to not returnfalse.authorizedToViewAny()still gates search participation per request. A denied user never sees the resource's results,recordUrl()or not.- For a routable resource,
recordUrl()staysnullby default, sorecordHref()returns the unchanged/resources/{key}/{id}— fully backward-compatible; nothing changes unless you opt in. viewAllUrl("View all N") defaults tonullfor 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, declaresearchIndexUrl()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()atnull, soviewAllUrlstays 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 aftergloballySearchable()andauthorizedToViewAny()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:
perPageOptions()is the canonical list. The dropdown renders exactly what this returns.- If
perPage()is not inperPageOptions(), Martis clamps toperPageOptions()[0]. The clamp is silent (no warning, no exception). - Relation fields (
HasMany,MorphMany,BelongsToMany,MorphToMany) inherit the related resource'sperPageOptions()when the developer did not call->perPageOptions([...])on the field itself. See relationships.md.
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. |
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 below) is ordered by:
- the keys the request names, in order (
?sort=locale:asc,name:desc); - then the
defaultSort()keys the request did not name, in their order: the default is the tiebreaker, so one click on "Language" giveslocale, category, position, notlocalealone; - 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() 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); 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, #3655, #337), not its relatableQuery(), which only feeds the pickers (docs, #909).
Write the hooks for this resource's own index query; Martis keeps them there:
- On a plain
HasMany/MorphManypanel they run on the panel's query as an Eloquent scope, so what they add is grouped, a leadingorWhere()included (it reads asand, as on the index): anorWhere()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 aHasMany/MorphManypanel 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).$rowsis the page's models. An empty page does not call it. - It is on
Resource, not onResourceContract: 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\PageContextholds the facts for the request:put($key, $row, $value),putMany($key, [$id => $value]),get($key, $row, $default = null),has($key, $row).$rowis a model or its key. Read it from a field resolver,canSeeForModel(), an accessor or an action'scanRun()withPageContext::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. AcanRun()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().
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). 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). 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 colorspublic function tableShowGridlines(): bool { return false; } // Cell borderspublic static function tableSize(): TableSize { return TableSize::Normal; } // Small, Normal, Largepublic function tableRowHover(): bool { return true; } // Highlight on hovertableLayout()
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; thetitleAttributecolumn getsminWidth: 220px). Override per field with->width(),->minWidth(),->maxWidth(),->truncate().Fixed— applies CSStable-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 defaultText::make('name'); // auto: minWidth 220px when name = titleAttributeBadge::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: 220pxon thetitleAttributecolumn.
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 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.
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.
// 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) 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 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 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) 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 overridearchiveConfirmMessage(), 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=onlyand?trashed=withquery 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: falsefor 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:
- Explicit $policy property on the Resource class
- Auto-discovery by convention:
{policy_namespace}\{ResourceBaseName}Policy - Laravel Gate policy registered for the model class
- 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 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 ownMarkdown::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)resourceandfieldare required (422 without them; 404 for an unknown resource).idnames the record an edit form is editing; leave it out on a create form.repeaterandrepeatablename 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 recordidnames (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. Anidthe 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
diskparameter is ignored. A field that callswithFiles()with no disk usesconfig('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:
- Set the
MARTIS_ATTACHMENT_MIMESenv variable with a comma-separated list - Or publish and edit
config/martis.phpdirectly
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 gophp artisan martis:attachments:prune # delete itphp artisan martis:attachments:prune --hours=72 --disk=s3 --disk=publicIt 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 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().
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); }}