# Configuration · Martis docs

> Martis is configured through config/martis.php, which the installer publishes; most of its options also read an environment variable. Publish it with the bundled wrapper (or the standard vendor:publish if you prefer):

Source: https://getmartis.com/docs/reference/configuration

# Configuration

Martis is configured through `config/martis.php`, which the installer publishes; most of its options also read an environment variable. Publish it with the bundled wrapper (or the standard `vendor:publish` if you prefer):

```
php artisan martis:vendor-publish --config
# equivalent to:
# php artisan vendor:publish --tag=martis-config
```

This page documents every configuration option grouped by subsystem. The full env-var index lives at the bottom — that table is **generated** by `php artisan martis:list-env-vars` and reflects the live config surface (currently **130 env vars**).

> **Single source of truth.** Whenever you suspect an env var is documented incorrectly here, run `php artisan martis:list-env-vars` and compare. The command parses `config/martis.php` directly, so its output cannot drift. CI calls it during release-cut to refresh the table at the bottom of this page.

## Base Path

```
'path' => env('MARTIS_PATH', 'martis'),
```

The URL prefix for the admin panel. The panel will be accessible at `/{path}` (e.g., `http://yourdomain.com/martis`), its API at `/{path}/api`, and the routes a Tool loads with `loadRoutes()` at `/{path}/api/tools/{uriKey}` (v2.0; they stayed under `/martis` before, whatever the path). See [Tools → Tool routes and their middleware](https://getmartis.com/docs/customization/tools#tool-routes-and-their-middleware).

## Authentication

```
'guard' => env('MARTIS_GUARD', null),
'middleware' => ['web'],
'auth_middleware' => ['martis.auth'],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `guard` | `?string` | `null` | Authentication guard. `null` uses Laravel's default guard. The panel's requests run as that guard (it becomes the request's guard), and the auth flows, the Martis migrations and `martis:user` use its provider: see [Upgrading → A custom Martis guard](https://getmartis.com/docs/getting-started/upgrading#a-custom-martis-guard). |
| `middleware` | `array` | `['web']` | Applied to all Martis routes (public and protected). |
| `auth_middleware` | `array` | `['martis.auth']` | Applied to protected routes only. `martis.auth` implements Laravel's `AuthenticatesRequests`, so the router's middleware priority runs it where it runs Laravel's `auth`: before the throttle, the route bindings and any middleware outside the priority list (v2.0.0+). |

| `guard` | `?string` | `null` | Authentication guard. `null` uses Laravel's default guard. | | `middleware` | `array` | `['web']` | Applied to all Martis routes (public and protected) and to a Tool's routes. | | `auth_middleware` | `array` | `['martis.auth']` | Applied to protected routes only, a Tool's routes included (v2.0). A middleware name is accepted in place of a list; `null` means the default; any other value throws an `InvalidArgumentException` naming the key. |

## Brand

```
'brand' => [
    'name' => env('MARTIS_BRAND_NAME', 'Martis'),
    'logo' => env('MARTIS_BRAND_LOGO'),
    'logo_dark' => env('MARTIS_BRAND_LOGO_DARK'),       // v1.7.0
    'icon' => env('MARTIS_BRAND_ICON'),
    'icon_dark' => env('MARTIS_BRAND_ICON_DARK'),       // v1.7.0
    'auth_logo' => env('MARTIS_BRAND_AUTH_LOGO'),           // v2.6.0
    'auth_logo_dark' => env('MARTIS_BRAND_AUTH_LOGO_DARK'), // v2.6.0
    'favicon' => env('MARTIS_FAVICON', null),
    'manifest' => env('MARTIS_BRAND_MANIFEST'),                 // v2.6.0
    'apple_touch_icon' => env('MARTIS_BRAND_APPLE_TOUCH_ICON'), // v2.6.0
    'theme_color' => env('MARTIS_BRAND_THEME_COLOR'),           // v2.6.0
    'page_title' => env('MARTIS_PAGE_TITLE'),           // null | title | resolver class
    'version' => env('MARTIS_BRAND_VERSION'),
    'docs_url' => env('MARTIS_BRAND_DOCS_URL'),
    'logo_height' => [                                  // v1.7.0
        'menu' => env('MARTIS_BRAND_LOGO_HEIGHT_MENU'),
        'auth' => env('MARTIS_BRAND_LOGO_HEIGHT_AUTH'),
    ],
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string` | `'Martis'` | Displayed in the sidebar header and used as the `{brand}` interpolation for the bundled page title translation. |
| `logo` | `?string` | `null` | **Full horizontal lockup** (icon + wordmark in one asset). When set, the SPA renders the lockup alone — the separate `brand.name` text next to it is hidden in the sidebar / topbar / auth frame to avoid a duplicated wordmark. Relative paths resolve against `public/` (`/img/logo.svg`); full URLs pass through unchanged. |
| `icon` | `?string` | `null` | **Small square brand icon** (replaces just the bundled cube next to `brand.name`). Use this when you want to keep the brand text rendered by Martis but swap the mark. Independent from `logo` — Martis prefers `logo` when both are set. |
| `favicon` | `?string` | `null` | Path to a custom favicon (relative to `public/`). When `null`, Martis serves its own default favicon from the package — no `vendor:publish` step required. |
| `page_title` | `string \| array \| null` | `null` | Browser tab title shown in `<title>`. `null` uses the bundled translation (e.g. "Acme — Admin Control"). A string is the title itself, unless it names an invokable class: that class, or a `[Class::class, 'staticMethod']` array, receives the current `Request` and returns the title. See [Config keys that take a callable](https://getmartis.com/docs/reference/configuration#config-keys-that-take-a-callable). |
| `version` | `?string` | `null` | Optional version string printed in the sidebar footer (e.g. `v1.5.2`, `2026.04.29`). |
| `docs_url` | `?string` | `null` | Optional docs link rendered on the right-hand side of the sidebar footer. |

### Choosing your brand mode

Martis renders the brand row (sidebar header / topnav left / auth card top) in one of four modes. **Where to set each**: every knob is an `.env` key in your application's `.env` file. **No published config edits required** — once `config/martis.php` reads `env('MARTIS_BRAND_LOGO')` / `env('MARTIS_BRAND_ICON')` (default since v1.5.2 / v1.6.3), flipping modes is one line in `.env` plus `php artisan config:cache`.

#### Mode 1 — Logo only (recommended when your brand has a horizontal lockup)

```
MARTIS_BRAND_LOGO=/img/edgeflow-lockup.svg
# leave MARTIS_BRAND_ICON unset
```

Renders the lockup image alone. The `brand.name` text is **hidden** next to it (the wordmark inside your asset replaces it). Best for brands like EdgeFlow where the SVG already contains the mark + wordmark in one piece.

```
+------------------------+
| [EdgeFlow lockup]      |
+------------------------+
```

The image is sized to ~40px tall in the sidebar / topnav and ~40px tall in the auth card, scaling width to preserve the asset's aspect ratio (capped at the column width so a wide lockup never overflows).

#### Mode 2 — Icon + brand name (recommended when you only have a square mark)

```
MARTIS_BRAND_ICON=/img/edgeflow-icon.png
MARTIS_BRAND_NAME="EdgeFlow"
# leave MARTIS_BRAND_LOGO unset
```

Renders `[icon] {brand.name}`. Same shape as the bundled experience but with your icon. Best when the wordmark isn't part of your asset, or when the brand text needs to vary per locale (`MARTIS_BRAND_NAME` can be left empty so a translation file drives it).

```
+------------------------+
| [icon] EdgeFlow        |
+------------------------+
```

The icon is constrained to a 28×28 square in the sidebar / topnav, 32×32 in the auth card. Provide a square asset.

#### Mode 3 — Bundled Martis cube + your brand name (the default)

```
# both MARTIS_BRAND_LOGO and MARTIS_BRAND_ICON unset
MARTIS_BRAND_NAME="Acme"
```

Renders `[Martis cube] Acme`. The bundled cube is preserved; only `brand.name` is replaced. Ship-ready out of the box, useful while you do not yet have brand assets.

#### Mode 4 — Both set

```
MARTIS_BRAND_LOGO=/img/edgeflow-lockup.svg
MARTIS_BRAND_ICON=/img/edgeflow-icon.png
```

`logo` wins on every surface; `icon` sits queued. Flip from logo-only to icon mode by **clearing** `MARTIS_BRAND_LOGO` in `.env` — no code change, no rebuild.

### Where each mode appears

| Surface | Reads from |
| --- | --- |
| Sidebar header (`sidebar` preset) | `brand.logo` → `brand.icon` → bundled cube |
| Topnav header (`topnav` preset) | `brand.logo` → `brand.icon` → bundled cube |
| Auth card (Login, Register, Forgot password, Reset password, Invitation, 2FA challenge, error screens) | `brand.auth_logo` → `brand.logo` → `brand.icon` → bundled lockup (with wordmark) |
| Loader | `loader.logo` / `loader.logoDark` (independent — see [Loader](https://getmartis.com/docs/customization/loader)) |
| Browser tab favicon | `brand.favicon` (independent — see "Customising the favicon" below) |
| Web app manifest, Apple touch icon, theme color | `brand.manifest`, `brand.apple_touch_icon`, `brand.theme_color` (independent — see "Head tags" below) |
| Browser tab title | `brand.page_title` + `brand.name` (independent — see "Customising the page title" below) |

Three rows, three env vars, one consistent rendering rule. Pick the mode that matches the asset you have.

### Switching modes after deployment

After editing `.env`:

```
php artisan optimize:clear
php artisan config:cache
php artisan martis:cache:clear
```

The config cache holds the env values; the Martis cache holds the navigation payload (which embeds the brand). Both must be cleared for the mode swap to land. The frontend bundle does NOT need rebuilding — `window.MartisConfig` is rendered on every request.

### Theme-aware variants (v1.7.0)

Each brand asset can ship a separate **dark-theme** variant. The SPA renders both `<img>` tags in the DOM and CSS hides one based on `<html data-theme>` — the toggle is instant (no React re-render, no fetch).

```
MARTIS_BRAND_LOGO=/img/logo-light.svg
MARTIS_BRAND_LOGO_DARK=/img/logo-dark.svg
MARTIS_BRAND_ICON=/img/icon-light.png
MARTIS_BRAND_ICON_DARK=/img/icon-dark.png
```

Resolution rule per asset:

- Both set → render variant matching the active theme.
- Only one set → use it for both themes (backwards-compat with v1.6.x consumers that only have `MARTIS_BRAND_LOGO`).

The auth card honours the same rule. When the user toggles theme via the in-app PreferencesMenu, the variant flip is instant. Browser-OS dark mode (`prefers-color-scheme`) is not consulted because Martis's own toggle takes precedence over the OS.

### A lockup for the authentication card (v2.6.0)

A brand kit often ships a lockup per surface: say a 220×40 one for the menu and a 240×48 one for the sign-in card. Point the card at its own lockup, with its dark variant:

```
MARTIS_BRAND_AUTH_LOGO=/brand/auth-lockup-light.svg
MARTIS_BRAND_AUTH_LOGO_DARK=/brand/auth-lockup-dark.svg
```

The authentication card (login, register, forgot and reset password, invitation, two-factor challenge and the guest error pages) then renders it in place of `brand.logo`, at `MARTIS_BRAND_LOGO_HEIGHT_AUTH`. The light/dark rule is the same as above: if only one of the pair is set, it serves both themes. Unset, the card uses `brand.logo` / `brand.logo_dark` as before.

### Asset sizing (v1.7.0)

The SPA renders brand assets at fixed heights. Override per surface:

```
MARTIS_BRAND_LOGO_HEIGHT_MENU=40   # sidebar / topnav. Default 40. Range 20–56.
MARTIS_BRAND_LOGO_HEIGHT_AUTH=48   # auth card.        Default 48. Range 24–80.
```

The package clamps both values to the listed ranges — an absurd value cannot break the layout. The icon-mode size (28×28 in the menu, 32×32 in the auth card) is fixed and not configurable.

### Asset requirements (v1.8.0 — issue #127)

Brand assets must satisfy three constraints to render cleanly:

1. **Tight crop, no transparent padding.** The browser scales the entire canvas (visible + transparent) to the target height. A 200×200 PNG with the actual mark only occupying the centre 80×80 pixels will render at *less than half* the intended size. Crop to the visible bounds before exporting.
2. **Power-of-two PNGs OR scalable SVG.** SVG is preferred — it stays sharp at any height. For raster, export at 2× the largest target height (auth card is 48 px → ship at 96 px) so retina displays look crisp.
3. **Light- AND dark-theme variants.** When the user toggles theme, the inactive variant is hidden via CSS — both files ship in the bundle. If the brand mark works on both backgrounds, point `MARTIS_BRAND_LOGO` and `MARTIS_BRAND_LOGO_DARK` at the same file (zero overhead — same hash, dedup'd).

Recommended targets per surface:

| Surface | Logo height | Icon height |
| --- | --- | --- |
| Sidebar (expanded) | 40 px (`MARTIS_BRAND_LOGO_HEIGHT_MENU` default) | 28 px |
| Sidebar (collapsed rail) | n/a — icon wins | 28 px |
| Auth card | 48 px (`MARTIS_BRAND_LOGO_HEIGHT_AUTH` default) | 32 px |

Tracking issue: [#127](https://github.com/Real-Edge-FX/martis-package/issues/127).

**Widest lockup per menu height.** In the expanded sidebar the logo box is the sidebar width less 32 px of padding: 208 px in the default 240 px sidebar. In the top navigation it is 220 px. A lockup wider than its box at the configured height is scaled down to fit, keeping its proportions (v2.6.0; before, its right-hand side was cut off). So a 220×40 lockup at the default 40 px height renders about 208×38 in the sidebar. The collapsed rail is unchanged: without a `brand.icon` it shows the left-hand side of the lockup, cropped to the rail's square, so set `MARTIS_BRAND_ICON` for a proper rail mark. To show it at full size, lower `MARTIS_BRAND_LOGO_HEIGHT_MENU`, or widen the sidebar from a named theme (`:root { --sidebar-width: 252px; }`).

### Sidebar collapse behaviour (v1.7.0)

When the sidebar collapses (240 px → 64 px rail) **and** `brand.icon` is set, the icon wins regardless of `brand.logo`. A horizontal lockup at 64 px rail width gets distorted; the square icon fits cleanly. When only `brand.logo` is set, the logo shrinks (the v1.6.x fallback). The sidebar text (`brand.name`) is always hidden in collapsed mode.

### Customising the page title

Four levels of control:

**1. Bundled translation (no configuration).** The default title uses `martis::navigation.page_title_default` interpolated with `brand.name`:

| Locale | Rendered title |
| --- | --- |
| `en` | `Acme — Admin Control` |
| `pt_PT` | `Acme — Centro de Administração` |
| `pt_BR` | `Acme — Central de Administração` |

**2. Static override** — set a literal string in `.env` or config:

```
MARTIS_PAGE_TITLE="Acme Back Office"
```

```
'brand' => [
    'page_title' => 'Acme Back Office',
],
```

The same key also takes a resolver: the name of an invokable class or a `[Class::class, 'staticMethod']` array. Both survive `php artisan config:cache`. The resolver receives the current `Request` and returns the title; an empty string or `null` falls through to the inference below.

```
'brand' => [
    'page_title' => \App\Martis\PageTitle::class, // __invoke(Request $request): ?string
],
```

A string that names no invokable class is always the literal title. Martis never calls it, even when it names a PHP function (`Mail`, `Date`, `Link`). See [Config keys that take a callable](https://getmartis.com/docs/reference/configuration#config-keys-that-take-a-callable).

**3. Dynamic per-route title** — register a closure in your `AppServiceProvider::boot()`:

```
use Illuminate\Http\Request;
use Martis\Facades\Martis;
 
public function boot(): void
{
    Martis::pageTitleUsing(function (Request $request): string {
        return match (true) {
            str_starts_with($request->path(), 'martis/resources/clients') => 'Clients · Acme',
            str_starts_with($request->path(), 'martis/resources/invoices') => 'Invoices · Acme',
            default => 'Acme Admin',
        };
    });
}
```

> A closure cannot live in `config/martis.php`: `php artisan config:cache` fails to serialise it, even when a service provider sets it with `config()->set()`. Register it with `Martis::pageTitleUsing()` as above, or put a resolver class in the config.

**4. Automatic per-route inference (no configuration)** — Martis looks at the current request path and inserts the matching resource label, dashboard name, or section, e.g.:

| URL | Title rendered |
| --- | --- |
| `/martis` | Name of the first dashboard the user may see · brand (e.g. `Operations · Acme`) |
| `/martis/dashboards/support-team` | That dashboard's name · brand (e.g. `Team · Acme`) |
| `/martis/resources/clients` | `Clients · Acme` |
| `/martis/resources/clients/new` | `Create Client · Acme` |
| `/martis/resources/clients/42` | `Client · Acme` |
| `/martis/resources/clients/42/edit` | `Edit Client · Acme` |
| `/martis/profile` | `Profile · Acme` |

Resource labels honour the authenticated user's locale preference, so the first paint is already in the right language.

A dashboard is named from the dashboards the signed-in user may see (v2.19.0): its `name()`, never a guess from its address (`support-team` used to read `Support team` for a dashboard called `Team`). A dashboard the user cannot see, or an unknown key, gets the default title.

For **client-side navigation** inside the SPA (react-router), each page uses the [`usePageTitle`](https://getmartis.com/docs/customization/overrides#page-title-hook) hook to keep `document.title` in sync without a full reload.

Resolution precedence (highest first):

1. `Martis::pageTitleUsing(Closure)` — registered at runtime.
2. `config('martis.brand.page_title')` — literal string, invokable class name or `[Class::class, 'staticMethod']` array.
3. Automatic inference from the request path (resource label, dashboard name, profile).
4. `__('martis::navigation.page_title_default', ['brand' => config('martis.brand.name')])` — bundled fallback.

### Customising the favicon

1. Drop the file into your app's `public/` directory — any filename and extension are accepted (`.ico`, `.png`, `.svg`). Example: `public/brand/favicon.ico`.
2. Point the config at it, relative to `public/`: `MARTIS_FAVICON=brand/favicon.ico` Or set it directly in `config/martis.php`: `'brand' => [ 'favicon' => 'brand/favicon.ico',],`
3. Visit `/{martis-path}/favicon.ico` — the configured file is served. If the file is missing, the route falls back to the package default, so broken paths never 404.

The panel's `<head>` links the configured file once, with a `type` taken from its extension (v2.13.0): `.svg` → `image/svg+xml`, `.png` → `image/png`, `.ico` → `image/x-icon`, `.gif`, `.webp` and `.jpg` / `.jpeg` their image types; any other extension gets no `type` attribute. Up to v2.12 the file was linked twice, as `image/x-icon` and `image/png`, whatever it was, so a browser that trusts `type` could skip an SVG favicon.

> Path rules: values must stay inside `public/`. Absolute paths (`/etc/...`) and traversal (`../`) are rejected with `400 Bad Request`.

### Head tags (v2.6.0)

Beyond the favicon, the panel page can link a web app manifest and an Apple touch icon, and set the browser theme color. Each tag is emitted only when its value is set:

```
MARTIS_BRAND_MANIFEST=/brand/site.webmanifest          # <link rel="manifest">
MARTIS_BRAND_APPLE_TOUCH_ICON=/brand/apple-touch-icon.png  # <link rel="apple-touch-icon">
MARTIS_BRAND_THEME_COLOR="#0f172a"                     # <meta name="theme-color">
```

Quote a hex color in `.env`: unquoted, `#` starts a comment and the value is empty, so no tag is emitted.

A path is resolved with `asset()` (relative to `public/`); a full `https://` URL is used as written. The theme color is one value for both themes: Martis follows its own theme toggle, not the operating system's `prefers-color-scheme`, which a `media`-qualified `theme-color` would follow.

## Footer

```
'footer' => [
    'enabled' => true,
    'text' => env('MARTIS_FOOTER_TEXT'),
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `bool` | `true` | Set `false` to hide the footer entirely. |
| `text` | `?string` | `null` | Custom footer text. When `null`, the bundled translation renders (`© {brand.name} · Powered by Martis`). |

> **i18n trade-off.** `MARTIS_FOOTER_TEXT` is a single string that overrides every locale. For per-locale footer copy, leave the env var unset and publish the lang files with `php artisan vendor:publish --tag=martis-lang`, then edit each locale file directly.

## Dashboard greeting & welcome card

The dashboard renders TWO different welcome strings — make sure you edit the right one:

| Visible string | Translation key | Customisation |
| --- | --- | --- |
| `Hello, {name}` (greeting line) | `martis::resources.hello` | Lang files only (publish + edit) |
| **`Welcome to Martis Admin Engine.`** (subtitle under the greeting) | `martis::resources.welcome` | **Lang files only** (no env var) |
| `Welcome to Martis` (animated `<WelcomeCard>` heading, optional) | `martis::resources.welcome_card_heading` | `MARTIS_WELCOME_HEADING` env **or** lang files |
| Welcome card body | `martis::resources.welcome_card_description` | `MARTIS_WELCOME_DESCRIPTION` env **or** lang files |

The `<WelcomeCard>` is the animated hero shown when `dashboard.showWelcomeCard !== false` — it sits at the top of the dashboard and is what the env vars below control. The greeting line ("Hello, X" + "Welcome to Martis Admin Engine") is a **separate** simple text block; **only lang files override it**. Frequent confusion: setting `MARTIS_WELCOME_HEADING` does not change the greeting subtitle.

```
'welcome' => [
    'heading' => env('MARTIS_WELCOME_HEADING'),
    'description' => env('MARTIS_WELCOME_DESCRIPTION'),
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `heading` | `?string` | `null` | Custom heading for the dashboard's animated `<WelcomeCard>`. When `null`, uses the bundled `welcome_card_heading` translation. |
| `description` | `?string` | `null` | Custom description shown below the heading. When `null`, uses the bundled `welcome_card_description` translation. |

### Customising the greeting subtitle ("Welcome to Martis Admin Engine.")

Publish the lang files and edit the `welcome` key:

```
php artisan vendor:publish --tag=martis-lang --force
```

Then `resources/lang/vendor/martis/{en,pt_BR,pt_PT}/resources.php`:

```
<?php
return [
    'welcome' => 'Welcome to EdgeFlow — your market quality terminal.',
];
```

Laravel deep-merges with the package shipped file — only `welcome` changes; everything else falls through. After editing, `php artisan optimize:clear`.

### Resolution order for the <WelcomeCard>

(First non-null wins.)

1. Prop passed at render-time (rare — only if the host overrides the React component itself).
2. `welcome.heading` / `welcome.description` from `config/martis.php` (env-driven).
3. `martis::resources.welcome_card_heading` / `welcome_card_description` translations (which honour your published lang overrides if any).

> **i18n trade-off.** `MARTIS_WELCOME_HEADING` and `MARTIS_WELCOME_DESCRIPTION` are single strings that override every locale. For per-locale copy, leave both env vars unset and publish the lang files. The two paths are mutually exclusive — env wins when set.

### Example: branding the dashboard from .env

```
MARTIS_BRAND_NAME="EdgeFlow"
MARTIS_BRAND_LOGO="/img/edgeflow-logo.svg"
MARTIS_FAVICON="brand/favicon.ico"
MARTIS_PAGE_TITLE="EdgeFlow"
MARTIS_FOOTER_TEXT="© 2026 EdgeFlow. All rights reserved."
MARTIS_WELCOME_HEADING="Welcome to EdgeFlow"
MARTIS_WELCOME_DESCRIPTION="NQ/ES/YM regime intelligence for futures traders."
```

No code changes, no `vendor:publish`. The SPA picks all of this up at boot via `window.MartisConfig`.

## Layout

```
'layout' => [
    'preset' => env('MARTIS_LAYOUT', 'sidebar'),
    'header_first' => (bool) env('MARTIS_LAYOUT_HEADER_FIRST', false),  // v2.7.0
    'components' => [
        'shell'   => null,
        'sidebar' => null,
        'topbar'  => null,
        'footer'  => null,
        'topbar_start' => null,  // v2.7.0
        'topbar_end'   => null,  // v2.7.0
    ],
],
```

### Presets

| Preset | What it renders | When to pick it |
| --- | --- | --- |
| `sidebar` (default) | Left nav column, topbar, content, in-flow footer. The collapsible shell described in [theming.md](https://getmartis.com/docs/customization/theming) and [overrides.md](https://getmartis.com/docs/customization/overrides). | Most admin apps. Handles mobile drawer, collapsed rail, accent pill, density tokens. |
| `topnav` | Horizontal top navigation (no sidebar). Topbar renders the full menu inline. | Apps with ≤ 8 top-level resources or a focus-mode where the left column wastes space. |
| `minimal` | No chrome — just the route outlet. No topbar, no sidebar, no footer. | Embedded dashboards, marketing screens, print-friendly surfaces. |
| `custom` | Strict registry-only mode: requires a component registered under `layout:shell` (or referenced by `layout.components.shell`). If none is registered, Martis renders a red error panel instead of silently falling back to `sidebar` — so a missing registration fails loudly during development. | Apps shipping their own shell that want to guarantee the bundled fallback is never used. |

Pick the preset via `MARTIS_LAYOUT` env or `config('martis.layout.preset')`. The string must match one of the four above (typos fall back to `sidebar`).

### Header-first order (v2.7.0)

The sidebar preset renders, after its two skip links ("Skip to main content", "Skip to navigation"), the sidebar, then the top bar, then the main content, so the keyboard reaches the menu before the search, the notification bell and the user menu. Set `MARTIS_LAYOUT_HEADER_FIRST=true` (`martis.layout.header_first`) to render the top bar before the sidebar instead: the Tab order becomes skip links, top bar, menu, main content, the order of most application shells. The grid places each piece by its own row and column, so the page looks the same either way. The topnav preset already starts with its bar; the option does not apply to it, nor to a custom shell.

### Piece-by-piece component overrides

`layout.components` lets the PHP config point each shell piece at a specific registry key. Each value is either `null` (use the bundled default) or a string matching a key registered via `componentRegistry.register(...)` in the frontend.

```
'layout' => [
    'preset' => 'sidebar',
    'components' => [
        'shell'   => null,              // whole shell override; skips the grid + mobile drawer
        'sidebar' => null,              // only the left column
        'topbar'  => 'tenant-topbar',   // custom topbar auto-discovered from `resources/js/martis-extensions/overrides/`
        'footer'  => 'tenant-footer',
    ],
],
```

Resolution order per piece: `config.layout.components.<piece>` → `layout:<piece>` (default registry key) → bundled component.

`topbar_start` and `topbar_end` (v2.7.0) are not pieces but slots: they add a component to the top bar instead of replacing it, and render nothing when nothing is registered. Their default registry keys are `topbar:start` (after the menu and collapse buttons, before the breadcrumbs and the search) and `topbar:end` (after the preferences menu, before the user menu). The sidebar and topnav presets both render them. On a phone (v2.14.0) they leave the bar: the sidebar preset renders them in its navigation drawer, the topnav preset at the end of its breadcrumb strip, and the component receives its `placement`. See [Top-bar slots](https://getmartis.com/docs/customization/overrides#top-bar-slots-v270) and [On a phone](https://getmartis.com/docs/customization/overrides#on-a-phone-v2140).

Full wiring examples, prop contracts, and the rationale for piece-by-piece vs full-shell overrides live in [overrides.md](https://getmartis.com/docs/customization/overrides#shell-piece-by-piece-overrides).

## Navigation

```
'navigation' => [
    'counts' => [
        'enabled' => env('MARTIS_NAV_COUNTS', true),
        'report_failures' => env('MARTIS_NAV_COUNTS_REPORT_FAILURES', true),  // v1.32.4
        'compact_threshold' => env('MARTIS_NAV_COUNT_COMPACT_THRESHOLD', 10000),  // v1.8.0
    ],
    'badges_poll_interval' => (int) env('MARTIS_NAV_BADGES_POLL_MS', 300000),  // v1.8.8
],
```

- `counts.enabled` — master switch for the resource count badge (`Users 1,284`) rendered in the sidebar and top-nav dropdowns. When true, every resource publishes a count by default; per-resource opt-out via `showMenuCount(): bool` on the `Resource` class.
- `counts.report_failures` (v1.32.4) — a `menuCount()` that throws never breaks the sidebar or the badges endpoint (that badge is skipped). When true, the failure is also sent through `report()` as a `Martis\Exceptions\MenuCountFailedException` (naming the Resource / Tool class, original exception as `previous`) so it reaches your exception handler instead of vanishing. Set to false to go back to silent skipping. With `dev.tools_enabled` on, the badges payload additionally lists failed counters under a reserved `_failed` key. See [Menus → When a badge is missing](https://getmartis.com/docs/core/menus#when-a-badge-is-missing).
- `counts.compact_threshold` (v1.8.0) — value at or above which the badge switches from full digits to compact notation:  Below threshold: `1,284` (locale-aware separators). At or above: `Intl.NumberFormat` compact notation — `10K`, `123.5K`, `1.2M`, `25M`. Default `10000` keeps everyday counts readable while preventing badges from blowing up the sidebar at 50 K+. Set to `null` to always show full digits, `0` to always compact, or any positive integer for a custom cutoff.
- `badges_poll_interval` (v1.8.8) — how often (in milliseconds) the sidebar and top-nav re-fetch the **lightweight** badges endpoint (`/api/navigation/badges`). Default: 300000 (5 minutes). Set to `0` to disable badge polling. The full navigation tree is fetched once per session + on route mutations and is **not auto-polled** — menu structure rarely changes in production. The badges payload is a flat `{ uriKey: count }` map and is 5–10× cheaper server-side than the full tree. React Query pauses the interval when the tab is hidden and refetches on window focus independently.

> **Breaking change in v1.8.8.** The previous `MARTIS_NAV_POLL_MS` (which auto-polled `/api/navigation`) was removed. The full navigation tree is no longer auto-polled at all — only `/api/navigation/badges` is, on the new `badges_poll_interval`. Apps that still set `MARTIS_NAV_POLL_MS` will see the value silently ignored; rename to `MARTIS_NAV_BADGES_POLL_MS` (and pick a longer cadence — the default jumped from 60 s to 5 min because badges-only is cheap and menu structure rarely changes mid-session).

See [menus.md](https://getmartis.com/docs/core/menus#count-badges) for the badge API (including `menuCount()` for custom values) and [menus.md](https://getmartis.com/docs/core/menus#sections) for the section heading API.

## Localization

```
'locale' => env('MARTIS_LOCALE', env('APP_LOCALE', 'en')),
```

Locale the blade shell uses **only when preferences are disabled** (`preferences.enabled = false`). With preferences enabled (the default), the panel language comes from the preferences resolver: the user's saved preference, else `preferences.defaults.locale` (`MARTIS_DEFAULT_LOCALE`, default `en`, which must be listed in `preferences.locales`, else the first listed locale applies). The `martis.locale` middleware applies that value on every authenticated Martis route, so set `MARTIS_DEFAULT_LOCALE`, not `APP_LOCALE` / `MARTIS_LOCALE`, to change the default panel language.

Martis UI strings come from the package's `resources/lang/{locale}/` files, overridden by the published copies in `lang/vendor/martis/{locale}/` (see [i18n](https://getmartis.com/docs/customization/i18n)). Publish translations with:

```
php artisan vendor:publish --tag=martis-lang
```

Shipped locales: `en` (English), `pt_BR` (Brazilian Portuguese), `pt_PT` (European Portuguese).

## API Throttle

```
'throttle' => [
    'enabled' => env('MARTIS_THROTTLE_ENABLED', true),
    'max_attempts' => (int) env('MARTIS_THROTTLE_MAX', 120),
    'decay_minutes' => (int) env('MARTIS_THROTTLE_DECAY', 1),
    'metrics_max_attempts' => env('MARTIS_THROTTLE_METRICS_MAX'),     // null: metrics stay in the API bucket (v2.23.0)
    'metrics_decay_minutes' => env('MARTIS_THROTTLE_METRICS_DECAY'),  // null: decay_minutes
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `bool` | `true` | Set `false` to disable API rate limiting. |
| `max_attempts` | `int` | `120` | Maximum requests per window, per signed-in user of the Martis guard, counted across the Martis API and every Tool's routes (v2.0). |
| `decay_minutes` | `int` | `1` | Rate limit window in minutes. |
| `metrics_max_attempts` | `int\|null` | `null` | A bucket of its own for the metric and dashboard read routes (v2.23.0). `null` keeps them in the API bucket, as before. |
| `metrics_decay_minutes` | `int\|null` | `null` | Window of the metrics bucket in minutes; `null` takes `decay_minutes`. |

The limit is Laravel's `throttle` middleware with a key prefix, `throttle:{max},{decay},martis-api:{guard}:` (`RouteMiddleware::throttlePrefix('api')`, v2.0.1). Laravel keys a signed-in user's bucket on `sha1()` of the identifier alone, so without the prefix the Martis guard's user 5 shared a bucket with a site route's plain `throttle` for the site user 5 (an `admins` guard beside the site's `users`), or for the same person. The verification email resend (`martis-verification:{guard}:`) keeps its own limit in its own bucket: before v2.0.1 it counted in the API's, so a resend answered `429` after three API requests in the same minute. The 2FA challenge has a named limiter of its own since v2.4.0 (`martis-2fa-challenge`, see [2FA challenge throttle](https://getmartis.com/docs/reference/configuration#2fa-challenge-throttle)), with the Martis guard in its keys.

**A bucket for the metrics (v2.23.0).** A dashboard of many cards computes one request per card and refreshes them on a timer, so it can use up the API bucket and make the rest of the panel answer `429`. Set `MARTIS_THROTTLE_METRICS_MAX` (and, if the window differs, `MARTIS_THROTTLE_METRICS_DECAY`) and the routes that read a dashboard or compute a metric (`GET /api/dashboards`, `/api/dashboards/{dashboard}`, `/api/dashboards/{dashboard}/cards/{card}` and `/api/resources/{resource}/cards/{card}`) count against `throttle:{max},{decay},martis-metrics:{guard}:` (`RouteMiddleware::throttlePrefix('metrics')`) and only against it: they leave the API throttle, so a metric read never also counts in the API bucket, and the API bucket never sees them. Every other route keeps the API bucket. Unset (the default) changes nothing. `MARTIS_THROTTLE_ENABLED=false` turns both buckets off. An empty variable (`MARTIS_THROTTLE_METRICS_MAX=`) is read as unset, like every other empty variable of the package; a value that is set and is not a positive whole number (`0`, `-5`, `lots`) throws an `InvalidArgumentException` naming the key when the routes are registered.

## Theme

```
'theme' => [
    'default' => env('MARTIS_THEME', 'dark'),
    'allowToggle' => true,
    'name' => env('MARTIS_THEME_NAME', null),
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `default` | `string` | `'dark'` | Default theme: `'dark'` or `'light'`. |
| `allowToggle` | `bool` | `true` | When `false`, the theme picker is hidden everywhere — the Theme section disappears from the preferences overlay and the theme cycle button is suppressed on every pre-login surface (login, register, 2FA, password reset). Use this to lock the entire shell to a single theme without removing the rest of the preferences. Since v2.12.0 it is the same as leaving `theme` out of `preferences.editable`: the server ignores a stored theme and refuses a theme change, and everyone gets `preferences.defaults.theme` (`MARTIS_DEFAULT_THEME`). |
| `name` | `?string` | `null` | Active custom theme. The panel loads `public/vendor/martis/themes/<name>.css`, which `martis:publish-assets` publishes from `resources/css/martis/<name>.css`. `null` keeps the built-in theme. |

Custom themes are scaffolded via `php artisan martis:theme`, which writes the source and sets `name`. See [Theming → Theme files](https://getmartis.com/docs/customization/theming#theme-files).

## Keyboard Shortcuts

```
'keyboard_shortcuts' => [
    'enabled' => env('MARTIS_KEYBOARD_SHORTCUTS_ENABLED', true),
    'helpOverlay' => env('MARTIS_KEYBOARD_SHORTCUTS_HELP_OVERLAY', true),
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `bool` | `true` | Master switch for the entire keyboard-shortcuts registry. When `false`, every `addShortcut()` call returns a no-op disposer; no event listener is bound and the bundled `mod+k`, `/`, and `shift+?` combos do nothing. Use it when the host app ships a custom keyboard layer. |
| `helpOverlay` | `bool` | `true` | When `false`, the bundled `Shift+?` help overlay is not registered. `addShortcut()` itself stays live for every other combo, so this is the right knob if the host app wants to expose its own help UI instead of the dialog. |

Both flags are read live, so mid-session toggles via `window.MartisConfig` take effect on the next registration. See [Keyboard Shortcuts](https://getmartis.com/docs/reference/keyboard-shortcuts) for the full API.

## User Menu

```
'user_menu' => [
    'showThemeToggle' => true,
    'showProfile' => env('MARTIS_SHOW_PROFILE_MENU', true),
    // 'customItems' => [],
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `showThemeToggle` | `bool` | `true` | Show dark/light mode toggle. |
| `showProfile` | `bool` | `true` | Show profile page link. Override with `MARTIS_SHOW_PROFILE_MENU=false`. |
| `customItems` | `?array` | `null` | Array of custom menu items (see example below). |

> **The notifications bell** next to the profile is not a `user_menu` option — it is controlled by the `notifications.enabled` master switch (see the Notifications section). Set `MARTIS_NOTIFICATIONS_ENABLED=false` to hide it.

### Custom Menu Items

Each item accepts `label` (i18n key or literal), `icon` (**Phosphor icon name** — as of v1.29.0, not a PrimeIcons class), `url` (internal path or external URL), and an optional `position` (`'before'` / `'after'` the built-in Profile entry). Full reference in [Authentication → User Menu](https://getmartis.com/docs/auth/authentication#user-menu).

```
'customItems' => [
    ['label' => 'Settings', 'icon' => 'gear', 'url' => '/settings'],
    ['separator' => true],
    ['label' => 'Documentation', 'icon' => 'book-open', 'url' => 'https://docs.example.com'],
],
```

## Global Search

```
'search' => [
    'enabled' => true,
    'placeholder' => null,
    'mode' => env('MARTIS_SEARCH_MODE', 'bar'),
    'mobileMode' => env('MARTIS_SEARCH_MOBILE_MODE', 'icon'),
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `bool` | `true` | Enable/disable global search. |
| `placeholder` | `?string` | `null` | Custom placeholder text (`null` = i18n default). |
| `mode` | `string` | `'bar'` | Desktop mode: `'bar'`, `'icon'`, or `'disabled'`. |
| `mobileMode` | `string` | `'icon'` | Mobile mode: `'bar'`, `'icon'`, or `'disabled'`. |

## Dashboard

```
'dashboard' => [
    'showGreeting' => env('MARTIS_DASHBOARD_SHOW_GREETING', true),
    'showWelcome' => env('MARTIS_DASHBOARD_SHOW_WELCOME', true),
    'showWelcomeCard' => env('MARTIS_DASHBOARD_SHOW_WELCOME_CARD', true),
    'showMetrics' => true,
    'showResourceCards' => true,
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `showGreeting` | `bool` | `true` | Show personalised greeting (`Hello, {name}`). Override with `MARTIS_DASHBOARD_SHOW_GREETING=false`. |
| `showWelcome` | `bool` | `true` | Show welcome subtitle (`Welcome to Martis Admin Engine.`) below the greeting. Override with `MARTIS_DASHBOARD_SHOW_WELCOME=false`. |
| `showWelcomeCard` | `bool` | `true` | Show the animated WelcomeCard hero at the top of the dashboard. Override with `MARTIS_DASHBOARD_SHOW_WELCOME_CARD=false`. |
| `showMetrics` | `bool` | `true` | Show summary metrics row (total resources, groups). |
| `showResourceCards` | `bool` | `true` | Show resource quick-access cards grid. |

## Toast Notifications

```
'toast' => [
    'position' => env('MARTIS_TOAST_POSITION', 'bottom-right'),
    'durations' => [
        'success' => 5000,
        'info' => 5000,
        'warning' => 5000,
        'error' => 5000,
    ],
],
```

Options for `position`: `'top-right'`, `'top-left'`, `'bottom-right'`, `'bottom-left'`, `'top-center'`, `'bottom-center'`.

`durations` (v2.21.0) is how long a toast stays, per severity, in milliseconds. `null` keeps the toast until the person closes it (every toast has a close button, reachable by keyboard). The defaults match the behaviour before v2.21.0. A toast with a safe link stays at least 10000 ms, unless its duration is `null`. A value that is neither `null` nor a positive integer of at most 2147483647 (the longest delay a browser timer holds; a longer one makes the toast vanish at once) throws an `InvalidArgumentException` naming the key, for example `martis.toast.durations.error`. A string of digits is accepted as that integer, which is what `env()` yields (`'error' => env('MARTIS_TOAST_ERROR_MS')` with `8000` in `.env`). A sticky severity (`null`) has no cap: a toast raised in a loop (a retry or a poll that fails every few seconds) stacks without bound until the person closes them, so keep `null` for severities that fire rarely.

```
// Errors stay until closed, successes go after 3 seconds.
'durations' => ['success' => 3000, 'error' => null],
```

A single call overrides the config: see `addToast` options in [Components](https://getmartis.com/docs/customization/components).

## Index (Resource Listing)

```
'index' => [
    'default_row_actions' => [
        'enabled' => env('MARTIS_DEFAULT_ROW_ACTIONS', true),
        'view' => env('MARTIS_DEFAULT_ROW_ACTION_VIEW', true),
        'edit' => env('MARTIS_DEFAULT_ROW_ACTION_EDIT', true),
        'delete' => env('MARTIS_DEFAULT_ROW_ACTION_DELETE', true),
    ],
    'row_click_opens_detail' => env('MARTIS_ROW_CLICK_OPENS_DETAIL', true),
    'default_trashed_filter' => env('MARTIS_DEFAULT_TRASHED_FILTER', 'active'),
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `default_row_actions.enabled` | `bool` | `true` | Master switch for the default row actions column (view/edit/delete and restore/force-delete on soft-delete models). When `false`, Martis never renders the column. |
| `default_row_actions.view` | `bool` | `true` | Per-action global kill-switch for the View icon. Flip to `false` to hide it across every resource. `MARTIS_DEFAULT_ROW_ACTION_VIEW`. |
| `default_row_actions.edit` | `bool` | `true` | Per-action global kill-switch for the Edit icon. `MARTIS_DEFAULT_ROW_ACTION_EDIT`. |
| `default_row_actions.delete` | `bool` | `true` | Per-action global kill-switch for the Delete icon. `MARTIS_DEFAULT_ROW_ACTION_DELETE`. |
| `row_click_opens_detail` | `bool` | `true` | When default row actions expose a "view" icon, clicking the row body becomes redundant. Set to `false` to disable row-click and keep the row informational. Override per resource with `rowClickOpensDetail(Request $request): ?bool`. |
| `default_trashed_filter` | `string` | `'active'` | Initial state of the trashed-filter dropdown on soft-delete resources (main index **and** relationship panels). One of `'active'` (hide deleted), `'with'` (include deleted alongside live), `'only'` (only deleted). Visibility of the dropdown itself is gated by [`Resource::canViewTrashed()`](https://getmartis.com/docs/core/resources#restricting-trashed-visibility-by-role). |

The three per-action sub-keys are global kill-switches. A resource-level `Resource::defaultRowActions(Request $request): bool|array` can subtract further but never force a globally-disabled action back on. It can return:

- `true` (default) — show all four (view, edit, delete, plus restore/force-delete on soft-delete models)
- `false` — opt the resource out entirely
- a subset array — e.g. `['view', 'edit']` to hide the destructive actions
- a closure-aware decision per request

See [Default Row Actions](https://getmartis.com/docs/core/default-row-actions) for the full guide.

## Pagination

```
'pagination' => [
    'default_per_page' => 25,
    'max_per_page' => 100,
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `default_per_page` | `int` | `25` | Fallback for `Resource::perPage()` and `Lens::perPage()` when the resource/lens does not override the method. |
| `max_per_page` | `int` | `100` | Upper bound enforced by the ResourceController when the client passes `?perPage=N` on the URL. Protects against pathological page sizes. |

Override per-resource via `perPage()` and `perPageOptions()` on the resource class. When the returned `perPage()` is not present in `perPageOptions()`, Martis silently clamps to `perPageOptions()[0]` — see [resources.md — Effective per-page](https://getmartis.com/docs/core/resources#effective-per-page).

## Storage

```
'storage' => [
    'disk' => env('MARTIS_STORAGE_DISK', 'public'),
],
```

Default filesystem disk for file uploads. Individual `File` and `Image` fields can override this via `->disk('s3')`.

## Resources path and namespace

```
'resources_path' => app_path('Martis'),
'resources_namespace' => null,
```

Directory where auto-discovery looks for resource classes (scanned recursively for classes that extend `Martis\Resource`) and the PHP namespace that directory maps to.

`resources_namespace` (since v1.36.0) resolves in this order:

1. A non-empty string is used verbatim.
2. `null`: the namespace is derived from Composer's PSR-4 autoload map (every registered class loader, so root `composer.json` entries, merge plugins and runtime `addPsr4()` registrations all count). `app/Martis` under `"App\\": "app/"` gives `App\Martis`; `app/Martis/Customer` gives `App\Martis\Customer`; `modules/Billing/Martis` under `"Modules\\": "modules/"` gives `Modules\Billing\Martis`. The longest matching root wins.
3. No PSR-4 root contains the directory: `App\Martis`, the historical convention, so a non-autoloaded folder keeps working through the discovery's own `require_once` fallback.

Set the key explicitly when the directory is not autoloaded or when you want to pin the value regardless of the autoload map. A config published before v1.36.0 has no `resources_namespace` key, which behaves like `null`.

## Policy Namespace

```
'policy_namespace' => 'App\\Martis\\Policies',
```

Namespace for auto-discovery of resource policies. When a resource does not define an explicit `$policy` property, Martis looks for `{policy_namespace}\{ResourceBaseName}Policy`.

## Tools Auto-Discovery

```
'tools_path' => app_path('Martis/Tools'),
'tools_namespace' => null,
'discovery' => [
    'tools' => env('MARTIS_DISCOVERY_TOOLS', true),
],
```

Where Martis scans for `Martis\Tools\Tool` subclasses (since v1.8.20). `tools_namespace` follows the same precedence as `resources_namespace` (explicit string, then Composer's PSR-4 map, then `App\Martis\Tools`); a config published before v1.36.0 still carries the literal `'App\\Martis\\Tools'`, which keeps working. Discovery merges with any `Martis::tools([...])` registration via dedup by class-string, so adopting it does not break apps that already registered Tools manually. Set `discovery.tools` to `false` (or `MARTIS_DISCOVERY_TOOLS=false`) to opt out and keep manual registration.

## Extension bundles: versioned URLs and stale chunks (v2.20.0)

```
// config/martis.php
'extensions_versioned_urls' => (bool) env('MARTIS_EXTENSIONS_VERSIONED_URLS', true),
'extensions_reload_on_stale' => (bool) env('MARTIS_EXTENSIONS_RELOAD_ON_STALE', true),
```

| Key | Default | Description |
| --- | --- | --- |
| `extensions_versioned_urls` | `true` | A root-relative URL in `extensions` that is a file under `public/` is emitted as `/vendor/martis-user/extensions.js?v=<12 hex of the content hash>`. The URL changes only when the file does, so the bundle can be cached for a year (`immutable`). Absolute `http(s)://` and protocol-relative URLs, URLs that already have a query string or a fragment, and paths of missing files are emitted as configured. `false` emits every URL exactly as listed. |
| `extensions_reload_on_stale` | `true` | A lazily loaded extension chunk (`componentRegistry.registerLazy`) that cannot be fetched because a deploy replaced it reloads the tab once, then shows an error state. `false` never reloads. Surfaced to the SPA as `window.MartisConfig.extensionsReloadOnStale`. |

See [Extensions: caching](https://getmartis.com/docs/customization/extensions-caching) for the response headers and [Overrides](https://getmartis.com/docs/customization/overrides#lazy-registration-v2200) for lazy registration.

## Attachments

```
'attachments' => [
    'allowed_mimes' => ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', ...],
    'max_size' => 10240,       // KB (10MB)
    'throttle_max' => 20,      // uploads per user...
    'throttle_decay' => 1,     // ...per this many minutes
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `allowed_mimes` | `array` | See config | Allowed extensions for Trix/Markdown file uploads (`MARTIS_ATTACHMENT_MIMES`, comma-separated). |
| `max_size` | `int` | `10240` | Maximum file size in KB (`MARTIS_ATTACHMENT_MAX_SIZE`). |
| `throttle_max` | `int` | `20` | Uploads one user may make per `throttle_decay` minutes, on top of the API limit (`MARTIS_ATTACHMENT_THROTTLE_MAX`, v2.4.0). Not applied when `throttle.enabled` is false. |
| `throttle_decay` | `int` | `1` | The window of `throttle_max` in minutes (`MARTIS_ATTACHMENT_THROTTLE_DECAY`, v2.4.0). |

An upload names the resource, the field and (editing) the record, is authorised like the form it comes from, and goes to the disk the field declares with `withFiles($disk)` (the `storage.disk` above without one), never to a disk the request names; see [Resources → Attachment Uploads](https://getmartis.com/docs/core/resources#attachment-uploads-trix--markdown). The `allowed_disks` key of earlier versions is no longer read: the request chose the disk from that list, and it does not choose any more. `php artisan martis:attachments:prune` deletes the uploaded files no record references.

## Action Events (Audit Log)

```
'action_events' => [
    'enabled' => true,
    'resource' => true,
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `enabled` | `bool` | `true` | Record action events to the database. |
| `resource` | `bool` | `true` | Register ActionEvent as a browsable resource in the sidebar. |

Individual actions can opt out via `->withoutActionEvents()`.

The registered resource is closed until you define the `view-martis-action-events` gate (or an `ActionEvent` policy), v2.0.1+: see [Actions → Who can read the audit log](https://getmartis.com/docs/core/actions#who-can-read-the-audit-log-v201).

## Code-side registrations: app/Providers/MartisServiceProvider.php

`config/martis.php` cannot hold closures — Laravel's `config:cache` fails to serialize them. So Martis splits configuration into two layers:

| Layer | What it holds | File |
| --- | --- | --- |
| **Static config** | Paths, throttle, theme, profile, cache TTLs, drawer widths, sticky-views scope, notifications poll interval, … | `config/martis.php` |
| **Code registrations** | Main menu resolver, dashboards, custom cache layers, gate definitions, page-title closures | `app/Providers/MartisServiceProvider.php` |

`martis:install` publishes the provider stub to `app/Providers/MartisServiceProvider.php` and wires it into `bootstrap/providers.php` automatically, in the file's own style (with a `use` import when the file imports its providers). A file whose providers array the installer cannot find gets a warning with the line to add by hand. Re-running `martis:install` is idempotent — the file is preserved and the bootstrap entry is not duplicated. `--force` does not touch it: use `--force-provider` to refresh the stub (this overwrites your registered dashboards, menu and gates).

The stub ships every section commented-out, so an unmodified provider registers nothing and Martis runs on its built-in defaults. Uncomment what you need:

```
// app/Providers/MartisServiceProvider.php
 
protected function registerMainMenu(): void
{
    // Layout-agnostic — feeds sidebar / topnav / minimal presets equally.
    Martis::mainMenu(function ($request, $menu) {
        return $menu->sections([
            MenuSection::make('Operations', [
                MenuItem::resource(\App\Martis\Resources\ClientResource::class),
            ])->icon('briefcase'),
        ]);
    });
}
 
protected function registerDashboards(): void
{
    Martis::dashboards([
        \App\Martis\Dashboards\OperationsDashboard::class,
    ]);
}
 
protected function registerCacheLayers(): void
{
    MartisCache::extend('orders', enabled: true, ttl: 30);
}
 
protected function registerGates(): void
{
    Gate::define('manage-martis-cache', fn ($user) => $user->is_admin);
}
```

Why a dedicated provider instead of `AppServiceProvider`?

- Separation of concerns — Martis bootstrap stays out of host-app providers.
- Self-documenting — section names (`registerMainMenu`, `registerDashboards`, `registerCacheLayers`, `registerGates`) act as a catalogue of every code-side hook Martis exposes.
- Easier upgrades — when Martis adds a new registration point, the stub gains a section; `AppServiceProvider` is never touched.
- Mirrors the dedicated-provider pattern other admin engines use.

To republish the stub manually (e.g. after package upgrade adds a new section): `php artisan vendor:publish --tag=martis-provider --force`.

### Config keys that take a callable

Six keys in `config/martis.php` take a callable instead of a value:

| Key | Called with | Returns |
| --- | --- | --- |
| `brand.page_title` | `Request $request` | The `<title>` (`?string`). A plain string is the title itself. |
| `gates.plan_resolver` | `?Authenticatable $user` | The user's plan (`?string`). See [Soft-gates](https://getmartis.com/docs/core/gates#plan-rank-shortcut--for-linear-tier-saas). |
| `profile.avatar.url_resolver` | `string $storedPath` | The avatar's public URL (`string`). |
| `auth.sso.providers.{provider}.role_source_callable` | `string $externalId, string $accessToken` | The external role names (`array<int, string>`). See [SSO](https://getmartis.com/docs/auth/sso#role_source--where-external-role-names-come-from). |
| `auth.sso.providers.{provider}.role_callable` | `array $externalRoles, ?User $user, string $provider` | The local roles (`Collection`). See [SSO](https://getmartis.com/docs/auth/sso#role_strategy--how-to-map-external-names-to-local-roles). |
| `auth.sso.issuer.claims` | `Authenticatable $user, string $audience` | The claims to add to, or replace in, the signed SSO token (`array<string, mixed>`, v2.21.0). See [SSO](https://getmartis.com/docs/auth/sso#18-signed-token-between-two-martis-apps-v2210-v2220). |

`php artisan config:cache` writes the merged config with `var_export()`, so only strings, numbers, booleans and arrays survive it. Two forms of callable do, so use one of them:

```
// The name of an invokable class. Martis builds it through the
// container, so its constructor can take dependencies.
'plan_resolver' => \App\Martis\PlanResolver::class,
 
// A [Class::class, 'method'] array naming a public static method.
'plan_resolver' => [\App\Martis\PlanResolver::class, 'resolve'],
```

```
namespace App\Martis;
 
use Illuminate\Contracts\Auth\Authenticatable;
 
class PlanResolver
{
    public function __invoke(?Authenticatable $user): ?string
    {
        return $user?->plan_name;
    }
 
    public static function resolve(?Authenticatable $user): ?string
    {
        return $user?->plan_name;
    }
}
```

A closure or an object (`new PlanResolver`) works only while the config is not cached. `config:cache` fails on it with `Your configuration files could not be serialized because the value at "martis.gates.plan_resolver" is non-serializable`. Setting it from a service provider's `boot()` with `config()->set()` does not help: `config:cache` boots every provider before it writes the cache, so the value set there is exported too.

A key set to anything that does not resolve to a callable throws an `InvalidArgumentException` naming the key and the reason, instead of silently falling back (a locked plan gate for every user, the disk URL, no SSO roles). That covers a class that does not exist, a class without a public `__invoke()`, a method that is not public and static (`[PlanResolver::class, 'handle']` when `handle()` is an instance method), and Laravel's `Class@method` string. `null`, `false` and an empty string leave the key unset. `brand.page_title` is the exception for strings: a string that names no invokable class is the literal title.

## Preferences (v1.7.0 defaults)

The preferences subsystem has been env-driven since v0.10. v1.7.0 surfaces three more knobs so a host can pick the look-and-feel that every brand-new user sees on first sign-in.

```
MARTIS_DEFAULT_THEME=dark           # dark | light | system
MARTIS_DEFAULT_ACCENT=martis        # martis | blue | teal | violet | amber | <custom name>
MARTIS_DEFAULT_DENSITY=comfortable  # comfortable | dense
MARTIS_DEFAULT_LOCALE=en            # one of MARTIS_UI_LOCALES (else the first listed)
MARTIS_UI_LOCALES=en,pt_PT,pt_BR    # locales the language picker offers (v2.10.0)
MARTIS_UI_LOCALE_LABELS="en_GB:English (UK)"  # code:label pairs (v2.10.0)
```

Invalid values fall through to the safe defaults — a typo in `.env` never crashes the request.

### Custom accent colours

The accent swatches in the PreferencesMenu can be extended with arbitrary brand colours via a single env var:

```
MARTIS_CUSTOM_ACCENTS="edgeflow:#1a73e8,sunset:#ff6b35,emerald:#10b981"
```

Format: comma-separated `name:hex` pairs, each with an optional third segment `name:hex:contrastHex` naming the text / icon colour painted on top of that accent (`--martis-accent-contrast`). Whitespace around the separators is tolerated.

```
# Bright lime with an explicit navy text colour; the others derive theirs.
MARTIS_CUSTOM_ACCENTS="edgeflow:#1a73e8,lime:#c6f135:#071726"
```

**Validation rules** (invalid entries are dropped silently with a `Log::warning`):

| Field | Pattern / rule |
| --- | --- |
| `name` | `[a-z][a-z0-9_-]{0,31}` — lowercase, alphanumeric + dash / underscore. Must NOT collide with a bundled enum value (`martis`, `blue`, `teal`, `violet`, `amber`, `custom`). |
| `hex` | `#RRGGBB` (6-digit hex with leading `#`). |
| `contrastHex` | Optional. `#RRGGBB`. When absent Martis derives it from the accent's luminance: white while white reaches the WCAG 3:1 floor against the accent, a near-black navy (`#0b1220`) below it. An invalid value drops the whole entry. |
| Duplicates | Last-wins (env-override semantics). |
| Limit | Up to 24 custom accents. Beyond that the parser truncates. |

Each registered accent:

- Adds an extra swatch at the end of the PreferencesMenu accent picker.
- Becomes a valid value for `MARTIS_DEFAULT_ACCENT` (so a new user lands on it without having to click).
- Becomes a valid value for the user's persisted `accent` preference column.

### How the colour is rendered

Martis injects an inline `<style>` block at boot for every custom accent:

```
html[data-accent="edgeflow"] {
  --martis-accent:          #1a73e8;
  --martis-accent-hover:    color-mix(in srgb, #1a73e8 88%, black);
  --martis-accent-active:   color-mix(in srgb, #1a73e8 78%, black);
  --martis-accent-bg-light: color-mix(in srgb, #1a73e8 14%, transparent);
  --martis-accent-bg:       color-mix(in srgb, #1a73e8 24%, transparent);
  --martis-focus-ring:      color-mix(in srgb, #1a73e8 45%, transparent);
  --martis-accent-contrast: #ffffff;   /* third segment, or derived */
}
```

Hover / active / bg / focus-ring variants are derived from the base hex via `color-mix(in srgb, …)`, and the contrast colour from its luminance (`Martis\Preferences\AccentContrast`), so the consumer only ships one colour per accent. Browser support: Chrome 111+, Safari 16.2+, Firefox 113+. The same derivation runs client-side for a per-user `brandColor` (see [Preferences](https://getmartis.com/docs/reference/preferences)).

### Removing a custom accent

Drop the entry from `MARTIS_CUSTOM_ACCENTS` and `php artisan config:cache`. Users who had picked the removed name keep the value in their `user_preferences.accent` row, but the resolver falls back to `martis` on the next request — no migration needed.

## Cache

```
'cache' => [
    'enabled'    => true,
    'metrics'    => ['enabled' => true, 'ttl' => 5],
    'navigation' => ['enabled' => true, 'ttl' => 1],
    'dashboards' => ['enabled' => true, 'ttl' => 5],
    'schema'     => ['enabled' => true, 'ttl' => null],
    'admin_ui'   => true,
    'admin_ui_order' => 1000,
],
```

`admin_ui_order` (v1.38.0+) positions the "System cache" link inside the bundled System sidebar section, which sorts its entries by weight (System-section resources and Tools default to `100`, so `1000` keeps the link last). See [Menus → Order inside the System section](https://getmartis.com/docs/core/menus#order-inside-the-system-section-v1380).

Per-subsystem cache layer with three control planes (config / env / runtime), bypass header, and admin page. See [Cache](https://getmartis.com/docs/reference/cache) for the full reference.

## Profile

```
'profile' => [
    'enabled' => true,
    'resource' => null,
    'menu' => ['label' => null, 'icon' => 'user'],
    'avatar' => [
        'enabled' => true,
        'disk' => 'public',
        'path' => 'avatars',
        'max_size_kb' => 2048,
        'column' => 'profile_picture',
        // Invokable class name or [Class::class, 'staticMethod'] array:
        // (string $storedPath) => public URL. Null uses the disk's URL.
        'url_resolver' => null,
    ],
    'two_factor' => [
        'enabled' => true,
        'recovery_codes' => 8,
        // "Keep me signed in" survives the challenge, as in Nova (v2.15.0;
        // env MARTIS_2FA_TRUST_REMEMBER_ME). False: every new session meets
        // the challenge again.
        'trust_remember_me' => true,
    ],
    'account' => [
        // When false, the e-mail cannot be changed from the profile: the
        // field is read-only, a request for another address answers 422
        // and a link mailed before is refused (env
        // MARTIS_PROFILE_EMAIL_EDITABLE).
        'email_editable' => true,
    ],
    'email_change' => [
        // Minutes the confirmation link mailed to a new address stays
        // valid (env MARTIS_PROFILE_EMAIL_CHANGE_TTL).
        'ttl_minutes' => 60,
        // Confirmation mails one user may ask for, and one address may be
        // sent, per throttle_minutes; past it the profile answers 429 (env
        // MARTIS_PROFILE_EMAIL_CHANGE_ATTEMPTS / _ATTEMPTS_MINUTES; 0 = off).
        'throttle_attempts' => 5,
        'throttle_minutes' => 60,
    ],
    'sections' => ['avatar', 'account', 'password', 'security', 'sessions'],
],
```

**The switches hold on the server (v2.8.0).** `avatar.enabled`, `two_factor.enabled` and `account.email_editable` used to hide their part of the page only, and the endpoints behind it kept answering. Now, as Fortify does with its features, a switch that is off removes its routes (they answer `404`), and:

| Switch off | Routes not registered | Also |
| --- | --- | --- |
| `avatar.enabled` (`MARTIS_AVATAR_ENABLED`) | `POST` and `DELETE /api/profile/avatar` | The profile payload carries no `avatar_url`. |
| `two_factor.enabled` (`MARTIS_2FA_ENABLED`) | `POST /api/profile/2fa/setup`, `/confirm`, `/recovery-codes`, `DELETE /api/profile/2fa`, `POST /api/2fa/challenge` | Nobody meets the 2FA challenge at sign-in, an account enrolled earlier included: its secret stays in the database, unused until the switch is on again. |
| `account.email_editable` (`MARTIS_PROFILE_EMAIL_EDITABLE`) | none | `PATCH /api/profile` with another address answers `422` and saves and mails nothing; the name still saves. A confirmation link mailed earlier is refused as `invalid`. |

`enabled` (`MARTIS_PROFILE_ENABLED`) still removes the whole profile, name and password change included. `sections` only orders and shows the parts of the page: leaving one out does not remove its routes.

**With `php artisan route:cache`, cache the routes again after you change a switch** (`php artisan optimize` caches the config and the routes together). The controllers check the avatar and 2FA switches too, so a route cache built while a switch was on still answers `404` once it is off. The other way round is not covered: with routes cached while 2FA was off, turning it on challenges enrolled users at a `POST /api/2fa/challenge` the cache does not hold, and they cannot sign in until the routes are cached again.

See [Authentication](https://getmartis.com/docs/auth/authentication#user-profile) for full profile documentation.

## User Preferences

```
'preferences' => [
    'enabled' => env('MARTIS_PREFERENCES_ENABLED', true),
    'allowBrandColor' => env('MARTIS_ALLOW_BRAND_COLOR', false),
    'locales' => array_values(array_filter(array_map('trim', explode(',', (string) env('MARTIS_UI_LOCALES', 'en,pt_PT,pt_BR'))))),
    'locale_labels' => [
        'en' => 'English',
        'en_GB' => 'English (UK)',
        'pt_PT' => 'Português (PT)',
        'pt_BR' => 'Português (BR)',
    ],
    'locale_labels_env' => env('MARTIS_UI_LOCALE_LABELS'),
    'editable' => array_values(array_filter(array_map('trim', explode(',', (string) env('MARTIS_PREFERENCES_EDITABLE', 'theme,accent,density,locale,reducedMotion'))))),
],
```

| Key | Type | Default | Effect |
| --- | --- | --- | --- |
| `enabled` | bool | `true` | Master switch for the per-user preferences panel (theme, density, locale, accent). |
| `allowBrandColor` | bool | `false` | When true, and `accent` is in `editable`, exposes a custom-hex brand colour picker in the preferences panel. Applied by the server too (v2.12.0): with it off, a hex is refused by `PUT /api/preferences` and a stored one is ignored. |
| `editable` | array | all five | The preferences users may change (v2.12.0: `MARTIS_PREFERENCES_EDITABLE`, comma-separated, any of `theme`, `accent`, `density`, `locale`, `reducedMotion`). One left out is hidden from the menu and the sign-in pages, refused by `PUT /api/preferences` (`422`) when the request changes it, ignored in a stored row and a URL preset, and resolves to its default. `theme.allowToggle => false` drops `theme`; the brand-colour hex follows `accent`. An unknown name throws an `InvalidArgumentException` naming the variable; unset or empty means all five, `none` locks every one; set with a published config that lacks the key, it throws. See [Preferences → Choosing which preferences users may change](https://getmartis.com/docs/reference/preferences#choosing-which-preferences-users-may-change-v2120). |
| `locales` | array | `['en','pt_PT','pt_BR']` | The exact locale codes the language picker offers, on **both** the login screen and the in-app Preferences panel (v2.10.0: from `MARTIS_UI_LOCALES`, comma-separated, e.g. `MARTIS_UI_LOCALES=en_GB,pt_PT`). A code must look like a locale (`en`, `en_GB`, `pt-BR`); an invalid one throws an `InvalidArgumentException` naming `MARTIS_UI_LOCALES`. A code without bundled strings (`en_GB`) translates through `locales.fallback_chain`. When empty, the three bundled locales apply. A saved, preset or default locale outside the list falls back to `defaults.locale`, or to the first listed code ([Preferences](https://getmartis.com/docs/reference/preferences#locale-normalisation-v2100)). |
| `locale_labels` | array | `en`, `en_GB`, `pt_PT`, `pt_BR` labels | The human-readable labels surfaced in the locale dropdown. A locale without a label shows its code. |
| `locale_labels_env` | string\|null | `null` | `MARTIS_UI_LOCALE_LABELS` (v2.10.0): comma-separated `code:label` pairs merged over `locale_labels`, the env entries winning, e.g. `MARTIS_UI_LOCALE_LABELS="en_GB:English (UK),pt_PT:Português (Portugal)"`. The first colon splits, so a label may contain colons but not commas. A malformed segment throws an `InvalidArgumentException` naming `MARTIS_UI_LOCALE_LABELS`; the value is parsed where it is read, so `artisan` keeps working while you fix it. |

See [User Preferences](https://getmartis.com/docs/reference/preferences) for the D1/D2/D3 spec and [i18n](https://getmartis.com/docs/customization/i18n) for the locale layer.

## Sticky Views

```
'sticky_views' => [
    'enabled' => env('MARTIS_STICKY_VIEWS_ENABLED', true),
    'scope' => env('MARTIS_STICKY_VIEWS_SCOPE', 'session'),
    'persist' => [
        'filters' => true,
        'sorting' => true,
        'pagination' => true,
        'per_page' => true,
        'columns' => true,
        'scroll' => false,
    ],
],
```

| Key | Type | Default | Effect |
| --- | --- | --- | --- |
| `enabled` | bool | `true` | Master switch. Falsey disables the entire feature. |
| `scope` | enum | `session` | Where state lives: `session` (sessionStorage, wipes on tab close) or `local` (localStorage, survives). |
| `persist.filters` | bool | `true` | Remember filter values per resource. |
| `persist.sorting` | bool | `true` | Remember sort column + direction. |
| `persist.pagination` | bool | `true` | Remember current page. |
| `persist.per_page` | bool | `true` | Remember per-page selector. |
| `persist.columns` | bool | `true` | Remember visible columns toggle. |
| `persist.scroll` | bool | `false` | Remember scroll position on the index. |

See [Sticky Views](https://getmartis.com/docs/core/sticky-views) for the full behaviour spec.

## Loader

```
'loader' => [
    'logo' => env('MARTIS_LOADER_LOGO'),          // v2.6.0
    'logoDark' => env('MARTIS_LOADER_LOGO_DARK'), // v2.6.0
    'disabled' => env('MARTIS_LOADER_DISABLED', false),
],
```

| Key | Type | Default | Effect |
| --- | --- | --- | --- |
| `logo` / `logoDark` | `?string` | `null` | Image shown in place of the spinner, with its dark theme variant (if only one is set it serves both themes). Path or URL. |
| `disabled` | bool | `false` | Globally suppress the page loader. Useful in tests or for apps that ship their own loading UI. |

See [Loader](https://getmartis.com/docs/customization/loader) for the surface-by-surface behaviour matrix.

## Impersonation

```
'impersonation' => [
    'enabled' => env('MARTIS_IMPERSONATION_ENABLED', false),
    'guard' => env('MARTIS_IMPERSONATION_GUARD'), // null: the Martis guard
    'session_key' => env('MARTIS_IMPERSONATION_SESSION_KEY', 'martis.impersonation'),
],
```

| Key | Type | Default | Effect |
| --- | --- | --- | --- |
| `enabled` | bool | `false` | Master switch. Default OFF — opt-in. When false, the impersonation REST endpoints return 503 and the banner short-circuits its mount-time fetch. |
| `guard` | string | `web` | Auth guard the impersonation operates on. |
| `session_key` | string | `martis.impersonation` | Session bag where the operator's id is stashed. Change for cross-tenant isolation. |

The package ships **no default Gate**. Define `martis-impersonate` in your `AuthServiceProvider` to gate which users can start an impersonation session. See [Impersonation](https://getmartis.com/docs/auth/impersonation).

## Audit (v1.8.8)

The package can record three categories of administrative events into the `martis_action_events` table.

```
'audit' => [
    'role_changes'   => env('MARTIS_AUDIT_ROLE_CHANGES', true),
    'impersonation'  => env('MARTIS_AUDIT_IMPERSONATION', true),
    'authz_denials'  => env('MARTIS_AUDIT_AUTHZ_DENIALS', false),
    'authz_denials_include_viewany' => env('MARTIS_AUDIT_AUTHZ_DENIALS_INCLUDE_VIEWANY', false),
],
```

| Key | Default | Effect |
| --- | --- | --- |
| `role_changes` | `true` | Logs `role.attached` / `role.detached` rows whenever Spatie attaches or detaches a role. Spatie fires those events only with `events_enabled` set to `true` in `config/permission.php` (off by default); see [Roles: audit log](https://getmartis.com/docs/auth/roles#audit-log-of-role--permission-changes). |
| `impersonation` | `true` | Logs `impersonation.started` / `impersonation.stopped`. |
| `authz_denials` | `false` | Records denied gate decisions as `authz.denied`, while the Martis guard is the request's guard. Off by default: turning it on can be noisy on a busy app. |
| `authz_denials_include_viewany` | `false` | When `authz_denials` is on, also record `viewAny` denials. Off by default because index pages probe `viewAny` on every request. |

The denial listener dedupes the same `(ability, model_class, model_id)` tuple within one request, so a sidebar that probes the same gate three times only emits one row.

## Authorization tuning (v1.8.8)

```
'authz' => [
    'request_cache'              => env('MARTIS_AUTHZ_REQUEST_CACHE', false),
    'revoke_sessions_on_demote'  => env('MARTIS_AUTHZ_REVOKE_SESSIONS_ON_DEMOTE', false),
],
```

| Key | Default | Effect |
| --- | --- | --- |
| `request_cache` | `false` | Memoises `(user, ability, every argument)` gate results for the current request (each model by class and key, so an `attach{Model}($user, $parent, $related)` ability keeps one answer per pair). Wins when a single request evaluates the same gate from many surfaces (sidebar, schema authorization block, action visibility). Per-request only — never crosses request boundaries. A call with an argument that cannot be keyed (a model with no key, an array, a closure) is skipped. |
| `revoke_sessions_on_demote` | `false` | When a role or a permission is detached from a user, or a permission from a role (the users who hold it, v2.4.0), force-logs out their existing browser sessions, the request's own one excepted. Useful when promoting/demoting between admin tiers. Skipped, with a warning, when the session guards sign in users of more than one table (a custom `MARTIS_GUARD` with its own model): the session rows cannot be told apart by id. See [Authorization → Revoke sessions on demote](https://getmartis.com/docs/auth/authorization#revoke-sessions-on-demote). |

## Magic-link sign-in (v1.8.8)

Passwordless login via signed email links.

```
'auth' => [
    'magic_link' => [
        'enabled'       => env('MARTIS_AUTH_MAGIC_LINK_ENABLED', false),
        'ttl_minutes'   => (int) env('MARTIS_AUTH_MAGIC_LINK_TTL', 15),
        'auto_register' => env('MARTIS_AUTH_MAGIC_LINK_AUTO_REGISTER', false),
    ],
],
```

| Key | Default | Effect |
| --- | --- | --- |
| `enabled` | `false` | Master switch. When on, the login page exposes a "Send magic link" form. |
| `ttl_minutes` | `15` | Validity window of the signed link. |
| `auto_register` | `false` | When on, an unknown email triggers a fresh user creation. Combine with a custom user model that handles defaults. |

## Email verification

```
'auth' => [
    'email_verification' => [
        'enabled'    => env('MARTIS_AUTH_EMAIL_VERIFICATION_ENABLED', false),
        'notice_url' => env('MARTIS_AUTH_EMAIL_VERIFICATION_NOTICE_URL'),
    ],
],
```

When enabled, the package wires Laravel's `verified` middleware into the panel guards and exposes the verification notice page. `notice_url` lets you redirect to a custom page if you don't want the bundled one.

## Forced password change

```
'auth' => [
    'password_change' => [
        'enabled' => env('MARTIS_AUTH_PASSWORD_CHANGE_ENABLED', false),
        'column'  => env('MARTIS_AUTH_PASSWORD_CHANGE_COLUMN', 'must_change_password'),
        'url'     => env('MARTIS_AUTH_PASSWORD_CHANGE_URL'),
    ],
],
```

When enabled, a user flagged by the `column` (or by the `Martis\Contracts\MustChangePassword` contract) is held on the change page until they choose a new password; `url` sends them to a page of your own instead. See [Authentication → Forced password change](https://getmartis.com/docs/auth/authentication#forced-password-change).

The SPA reads the state from `window.MartisConfig.auth.passwordChange` (`enabled`, `url`) and the password policy from `window.MartisConfig.auth.passwordRequirements`; see [Authentication → Password rules](https://getmartis.com/docs/auth/authentication#passwordpolicy).

## Auth screen copy (v1.8.5)

Every auth surface (login, register, password forgot, password reset, invitation accept) lets you override the title and subtitle without touching translations.

| Variable | Default | Surface |
| --- | --- | --- |
| `MARTIS_AUTH_LOGIN_TITLE` | translation | Login title |
| `MARTIS_AUTH_LOGIN_SUBTITLE` | translation | Login subtitle (password mode) |
| `MARTIS_AUTH_LOGIN_SUBTITLE_SSO` | translation | Login subtitle (SSO-only mode) |
| `MARTIS_AUTH_REGISTER_TITLE` | translation | Register title |
| `MARTIS_AUTH_REGISTER_SUBTITLE` | translation | Register subtitle |
| `MARTIS_AUTH_FORGOT_TITLE` | translation | Forgot password title |
| `MARTIS_AUTH_FORGOT_SUBTITLE` | translation | Forgot password subtitle |
| `MARTIS_AUTH_RESET_TITLE` | translation | Reset password title |
| `MARTIS_AUTH_RESET_SUBTITLE` | translation | Reset password subtitle |
| `MARTIS_AUTH_INVITATION_ACCEPT_TITLE` | translation | Invitation accept title |
| `MARTIS_AUTH_INVITATION_ACCEPT_SUBTITLE` | translation | Invitation accept subtitle |

Translations remain the recommended path for multi-locale apps. Use these env vars only when you want a single hard-coded string for every locale (e.g. an internal tool).

## Auth controls

```
'auth' => [
    'controls' => [
        'theme'  => env('MARTIS_AUTH_CONTROL_THEME', true),
        'locale' => env('MARTIS_AUTH_CONTROL_LOCALE', true),
    ],
],
```

Toggles the theme switcher and locale picker that float on the auth pages.

## Session guards (v2.23.0)

```
'auth' => [
    'session_guards' => null, // or a list of guard names, e.g. ['site']
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `auth.session_guards` | `array<string>\|null` | `null` | The guards, besides the Martis guard, that sign in people and write Laravel's `sessions.user_id`. `null` (default): every `session` guard of `config/auth.php` and the app's default guard count, as before. A list: only the Martis guard and the guards named there count. `[]` means the Martis guard only. The Martis guard is always counted. A value that is not a list of names, a name that is not in `auth.guards`, or a name whose guard does not use the `session` driver (a token or custom-driver guard never writes the column) throws an `InvalidArgumentException` naming the key, the first time a revocation, a demotion sweep or the profile reads it (it is not read at boot). |

Why it exists: when the guards that write the column sign in users of more than one table, one id can be two people, so Martis neither deletes the session rows (SSO `revokeFor()`, `authz.revoke_sessions_on_demote`) nor lists them (the profile's Browser sessions) and logs a warning. Laravel merges its own `web` guard, on the `users` provider, into every application's `auth.guards`, so an admin-only app whose people live in another table reads as ambiguous although nothing signs in through `web`. Declare the guards that really write the column to lift that.

**The list must be complete.** A guard that signs in people but is left out of it can make Martis delete, or list in the profile, the session of another person who has the same id in another table. That is why the default stays `null`: the risk moves to the application only by an explicit setting. The tables the guards read are still compared by connection and table, so guards that share one table never count as ambiguous. See [SSO → revocation](https://getmartis.com/docs/auth/sso#ending-an-sso-session-age-revocation-revalidation-v2210) and [Authorization → Revoke sessions on demote](https://getmartis.com/docs/auth/authorization#revoke-sessions-on-demote).

## Password sign-in switch (v2.21.0)

```
'auth' => [
    'password_login' => [
        'enabled'            => env('MARTIS_PASSWORD_LOGIN_ENABLED', true),
        'break_glass_emails' => [], // env: MARTIS_PASSWORD_LOGIN_BREAK_GLASS_EMAILS (comma-separated)
    ],
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `auth.password_login.enabled` | `bool` | `true` | `false` turns email and password sign-in off for an SSO-only panel: `POST /login` and `POST /api/auth/login` answer 403 for every address before the controller reads a credential, the password reset flow is off with it, and the login page shows the SSO buttons only (`?password_login=1` still shows the form when `break_glass_emails` is not empty, v2.23.0). An empty string (`MARTIS_PASSWORD_LOGIN_ENABLED=`) is "unset": on. Anything else that is not a boolean or a boolean string throws, naming the key, on a sign-in request or the login page (the provider boot stays up). Turning it off does not end existing sessions or remember-me cookies. |
| `auth.password_login.break_glass_emails` | `array<string>` | `[]` | Addresses (trimmed, case-insensitive) that may still sign in with a password while it is off; the account is looked up by the trimmed, lower-cased address, so store it that way. A failed break-glass sign-in answers with the same 403 as any other address. Never published to the browser: only whether it holds an address (`passwordLogin.breakGlass`, v2.23.0), which is what lets the login page open the form on `?password_login=1`. A value that is not an array of strings throws, naming the key. |

Magic-link sign-in, invitations and registration keep their own switches: an SSO-only panel turns them off. One warning a day (with a persistent cache store) is logged when password sign-in is off and no SSO provider is enabled and magic link is off. See [Authentication → Password sign-in switch](https://getmartis.com/docs/auth/authentication#password-sign-in-switch-v2210).

## Challenge (captcha) hook (v2.21.0)

```
'auth' => [
    'challenge' => [
        'enabled'    => env('MARTIS_AUTH_CHALLENGE_ENABLED', false),
        'verifier'   => env('MARTIS_AUTH_CHALLENGE_VERIFIER'),
        'site_key'   => env('MARTIS_AUTH_CHALLENGE_SITE_KEY'),
        'applies_to' => ['login', 'forgot', 'magic_link', 'register'],
    ],
],
```

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `auth.challenge.enabled` | `bool` | `false` | Turns the hook on. An empty string (`MARTIS_AUTH_CHALLENGE_ENABLED=`) is "unset": off. |
| `auth.challenge.verifier` | `?class-string` | `null` | A class implementing `Martis\Contracts\ChallengeVerifier`, resolved from the container. Required while enabled; never published. |
| `auth.challenge.site_key` | `?string` | `null` | Public key, published as `window.MartisConfig.auth.challenge.siteKey` for the `auth:challenge` component. |
| `auth.challenge.applies_to` | `array<string>` | `['login', 'forgot', 'magic_link', 'register']` | Surfaces covered. Allowed: `login`, `forgot`, `reset`, `magic_link`, `register`, `invitation`. |

A POST to a covered route without a token the verifier accepts answers 422 `errors.challenge_token`, before its controller (a flow that is switched off answers its own 404 or 503 and the verifier is not called); a verifier that throws refuses too (fail closed). An enabled hook with a missing, unknown or wrong-type `verifier`, or an unknown name in `applies_to`, throws naming the key. The `auth` entry the page publishes is an allow-list since v2.21.0. See [Authentication → Challenge (captcha) hook](https://getmartis.com/docs/auth/authentication#challenge-captcha-hook-v2210), which has a complete Cloudflare Turnstile example, and [What reaches the browser](https://getmartis.com/docs/auth/authentication#what-reaches-the-browser-v2210).

## Password reset

```
'auth' => [
    'passwordReset' => [
        'enabled' => env('MARTIS_AUTH_PASSWORD_RESET_ENABLED', false),
        'url'     => env('MARTIS_AUTH_PASSWORD_RESET_URL'),
        'broker'  => env('MARTIS_AUTH_PASSWORD_BROKER'),
    ],
],
```

Off by default. Enable to expose the "Forgot your password?" flow. `broker` names a broker of `config/auth.php` (`passwords`) whose provider is the Martis guard's; unset (the default since v2.0.1), Martis picks one: the app's default broker when it reads the Martis guard's users, else the first that does. A broker of another provider, or none that fits, throws naming `martis.auth.passwordReset.broker`. See [Authentication → Which password broker resets a password](https://getmartis.com/docs/auth/authentication#which-password-broker-resets-a-password).

## Registration

```
'auth' => [
    'registration' => [
        'enabled'      => env('MARTIS_AUTH_REGISTRATION_ENABLED', false),
        'url'          => env('MARTIS_AUTH_REGISTRATION_URL'),
        'default_role' => env('MARTIS_AUTH_REGISTRATION_DEFAULT_ROLE'),
    ],
],
```

Off by default. When on, exposes the register link and form. `default_role` (Spatie role name) is attached to fresh users.

## Login throttle

```
'throttle' => [
    'login_attempts' => (int) env('MARTIS_LOGIN_THROTTLE_ATTEMPTS', 20),
    'login_minutes'  => (int) env('MARTIS_LOGIN_THROTTLE_MINUTES', 1),
    'login_email_attempts' => (int) env('MARTIS_LOGIN_THROTTLE_EMAIL_ATTEMPTS', 100),
    'login_email_minutes'  => (int) env('MARTIS_LOGIN_THROTTLE_EMAIL_MINUTES', 15),
],
```

These keys live in the same `throttle` block as the global panel limits (`MARTIS_THROTTLE_*`), but the `login_*` bucket is a separate brute-force guard on the login endpoints. Defaults to 20 attempts per minute, per IP.

`login_email_attempts` / `login_email_minutes` (v2.4.0) are the per-account limit of the sign-in: at most 100 wrong passwords per 15 minutes for one account, whatever the source IPs. It bounds guessing at one account from many addresses, which the per-IP limit cannot, and has a higher threshold over a longer window than `login_*`, so a user who mistypes a password never reaches it. Only wrong passwords count (a good sign-in clears the run), the bucket follows the account the email matches rather than its spelling, and the magic-link request counts its own requests in a bucket of its own. `0` attempts turns it off. See [Authentication → Per-email throttle](https://getmartis.com/docs/auth/authentication#per-email-throttle) for what each limit stops and the cost of a per-account limit. It applies to `POST /{martis-path}/login` (new in v2.4.0), `POST /api/auth/login` and the magic-link request. Add the new variables to a published `config/martis.php` (or republish it).

## 2FA challenge throttle

```
'throttle' => [
    'two_factor_attempts'        => (int) env('MARTIS_2FA_THROTTLE_ATTEMPTS', 5),
    'two_factor_ip_attempts'     => (int) env('MARTIS_2FA_THROTTLE_IP_ATTEMPTS', 15),
    'two_factor_minutes'         => (int) env('MARTIS_2FA_THROTTLE_MINUTES', 1),
    'two_factor_lockout_attempts' => (int) env('MARTIS_2FA_LOCKOUT_ATTEMPTS', 5),
    'two_factor_lockout_minutes'  => (int) env('MARTIS_2FA_LOCKOUT_MINUTES', 15),
],
```

`POST /api/2fa/challenge` guards a 6-digit code, so since v2.4.0 it has a limiter of its own (`throttle:martis-2fa-challenge`, registered in `MartisServiceProvider::registerRateLimiters()`), tighter than the login throttle it used to share (20 a minute per user, with no ceiling):

| Key | Default | Effect |
| --- | --- | --- |
| `two_factor_attempts` | `5` | Requests per user per `two_factor_minutes`: the account being guessed at. Past it the route answers `429`, without checking the code. |
| `two_factor_ip_attempts` | `15` | Requests per IP per `two_factor_minutes`: one machine guessing at many accounts. Higher than the per-user limit so a team behind one NAT signing in together is not stopped. |
| `two_factor_minutes` | `1` | The window of both limits. |
| `two_factor_lockout_attempts` | `5` | Consecutive wrong codes (TOTP or recovery) that lock the user out. `0` turns the lockout off. |
| `two_factor_lockout_minutes` | `15` | How long the lockout lasts. |

The lockout is what sets a ceiling on guessing for someone who holds the user's password and can sign in again and again: see [Authentication → The 2FA challenge is rate limited and locks out](https://getmartis.com/docs/auth/authentication#the-2fa-challenge-is-rate-limited-and-locks-out-v240).

## Impersonation extras

In addition to `MARTIS_IMPERSONATION_ENABLED` (covered above), the impersonation subsystem exposes:

| Variable | Default | Effect |
| --- | --- | --- |
| `MARTIS_IMPERSONATION_GUARD` | the Martis guard | Auth guard the impersonation operates on (v2.0.0+: unset follows `MARTIS_GUARD`, then the app's default guard; it was `web`). |
| `MARTIS_IMPERSONATION_SESSION_KEY` | `martis.impersonation` | Session bag where the operator's id is stashed. |
| `MARTIS_IMPERSONATION_MAX_DURATION` | `0` | Maximum session length in minutes. `0` disables the timeout. |
| `MARTIS_IMPERSONATION_POLL_MS` | `120000` | Banner status poll interval in ms. Default 2 min — sessions change rarely. Set to `0` to disable polling (banner still mounts and reads state once per page load). v1.8.8. |

## API docs (Scramble)

```
'api_docs' => [
    'enabled' => env('MARTIS_API_DOCS_ENABLED', false),
    'path'    => env('MARTIS_API_DOCS_PATH', 'api-docs'),
],
```

Off by default. When enabled, mounts the Scramble-generated OpenAPI 3.1 surface at `/{path}` (default `/api-docs`). `api_docs.middleware` defaults to `null` (v2.4.0), the Martis protected stack (Martis guard, 2FA, email verification, `viewMartis`, forced password change). A list you set is kept and completed with the guards it leaves out: the plain `auth` middleware reads the app's default guard, not `MARTIS_GUARD`, so it never stands in for them. Set a list only to add to the stack (a rate limit, an IP allow-list). See [API → Overview](https://getmartis.com/docs/reference/api) for the worker-restart caveat: PHP-FPM caches parsed env in process memory, so flipping the env requires restarting workers (or the PHP container) before the gate sees the new value.

## Panel access

```
'panel_access' => [
    'open_environments' => ['local', 'testing'], // env MARTIS_PANEL_OPEN_ENVIRONMENTS, comma-separated
],
```

Used only while the app does **not** define the `viewMartis` gate (v2.4.0): the panel is open to every user the Martis guard signs in in those environments and answers `403` anywhere else. A defined gate decides per user in every environment. See [Authorization → Panel access](https://getmartis.com/docs/auth/authorization#panel-access-viewmartis).

## Notifications

```
'notifications' => [
    'enabled'         => env('MARTIS_NOTIFICATIONS_ENABLED', true),
    'max_in_dropdown' => (int) env('MARTIS_NOTIFICATIONS_MAX_DROPDOWN', 10),
    // v1.8.8 — bumped from 60 s to 90 s. Single COUNT query, but no
    // need to poll faster than the average user notices a new entry.
    'poll_interval'   => (int) env('MARTIS_NOTIFICATIONS_POLL_INTERVAL', 90000),
],
```

Controls the in-app notifications subsystem. `enabled` is the master switch: set it to `false` (env `MARTIS_NOTIFICATIONS_ENABLED=false`) to hide the topbar bell icon next to the user profile entirely, stop polling, and make the REST endpoints return empty payloads. This is the single toggle for "show / enable system notifications" — there is no separate `user_menu` option for the bell.

## Cache (per-type tuning)

Beyond the global `MARTIS_CACHE_ENABLED` master switch, every surface that caches has its own enabled flag and TTL (in minutes; `null` = forever).

| Variable | Default | Surface |
| --- | --- | --- |
| `MARTIS_CACHE_DASHBOARDS_ENABLED` | `true` | Dashboard renders |
| `MARTIS_CACHE_DASHBOARDS` / `MARTIS_CACHE_DASHBOARDS_TTL` | `5` | Dashboard TTL (the list key is also fingerprinted by the user's authorized set, so membership changes never wait for the TTL) |
| `MARTIS_CACHE_METRICS_ENABLED` | `true` | Metric cards |
| `MARTIS_CACHE_METRICS` / `MARTIS_CACHE_METRICS_TTL` | `5` | Metrics TTL |
| `MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS` | `null` | Metrics TTL in **seconds** for a metric that sets none (v2.20.0); `null` keeps the minutes above. See [Cache](https://getmartis.com/docs/reference/cache#badge-counts-and-metric-ttls-in-seconds-v2200) |
| `MARTIS_CACHE_NAVIGATION_ENABLED` | `true` | Sidebar navigation tree |
| `MARTIS_CACHE_NAVIGATION` / `MARTIS_CACHE_NAVIGATION_TTL` | `1` | Navigation TTL |
| `MARTIS_CACHE_BADGE_TTL_SECONDS` | `null` | TTL in **seconds** of each user's sidebar badge map (v2.20.0); `null` keeps the navigation TTL in minutes |
| `MARTIS_CACHE_SCHEMA_ENABLED` | `true` | Resource schema payload |
| `MARTIS_CACHE_SCHEMA` / `MARTIS_CACHE_SCHEMA_TTL` | `1440` | Schema TTL in minutes (one day since v2.0; `null` before, see [Cache → Invalidation](https://getmartis.com/docs/reference/cache#invalidation)) |
| `MARTIS_CACHE_ADMIN_UI` | `true` | Cache admin UI in the System sidebar group |

The shorter names (`MARTIS_CACHE_DASHBOARDS`, etc.) and the explicit `_TTL` variants resolve to the same value — the `_TTL` form just makes intent unambiguous. Pick whichever reads better in your `.env`.

## Drawer

```
'drawer' => [
    'expandable' => env('MARTIS_DRAWER_EXPANDABLE', true),
],
```

When on, the drawer that opens record detail can be expanded into a full overlay. Set to `false` to lock the drawer to its standard width.

`width` (default `720px`) and `expanded_width` (default `960px`) size every drawer on desktop. A drawer is never wider than the viewport, and at 768px or less it takes the whole screen (v2.18.2), whatever these values say; see [Overrides → Drawer overrides](https://getmartis.com/docs/customization/overrides).

## Sticky views

```
'sticky_views' => [
    'enabled' => env('MARTIS_STICKY_VIEWS_ENABLED', true),
    'scope'   => env('MARTIS_STICKY_VIEWS_SCOPE', 'session'),
],
```

| Key | Default | Effect |
| --- | --- | --- |
| `enabled` | `true` | Remember the last index URL (filter, sort, page) per resource so users return to where they left off. |
| `scope` | `session` | Where state is persisted: `session` — sessionStorage, wipes on tab close; `local` — localStorage, survives tab close. |

## Loader

```
'loader' => [
    'disabled' => env('MARTIS_LOADER_DISABLED', false),
],
```

Hides the global pre-hydration loader. Useful when you embed Martis inside an outer shell that already shows its own splash screen.

## Search defaults

```
'search' => [
    'mode'          => env('MARTIS_SEARCH_MODE', 'bar'),
    'mobile_mode'   => env('MARTIS_SEARCH_MOBILE_MODE', 'icon'),
    'default_limit' => (int) env('MARTIS_SEARCH_DEFAULT_LIMIT', 5),
    'min_query'     => (int) env('MARTIS_SEARCH_MIN_QUERY', 2),
    'match_mode'    => env('MARTIS_SEARCH_MATCH_MODE', 'contains'),
],
```

| Key | Default | Effect |
| --- | --- | --- |
| `mode` | `bar` | Topbar search mode on desktop. `bar`, `icon`, or `disabled`. |
| `mobile_mode` | `icon` | Equivalent for mobile viewports. |
| `default_limit` | `5` | Result rows per resource in the omnisearch dropdown. |
| `min_query` | `2` | Don't trigger a search until the query has this many characters. |
| `match_mode` | `contains` | How the database search matches a term for every resource without its own `searchMode()`: `contains` (`ILIKE '%term%'`), `prefix` (`ILIKE 'term%'`) or `columns` (only `col:term` tokens). An unknown value throws. See [search-performance.md](https://getmartis.com/docs/core/search-performance). |

## Default row actions

```
'index' => [
    'default_row_actions' => [
        'enabled' => env('MARTIS_DEFAULT_ROW_ACTIONS', true),
        'view'    => env('MARTIS_DEFAULT_ROW_ACTION_VIEW', true),
        'edit'    => env('MARTIS_DEFAULT_ROW_ACTION_EDIT', true),
        'delete'  => env('MARTIS_DEFAULT_ROW_ACTION_DELETE', true),
    ],
    'row_click_opens_detail' => env('MARTIS_ROW_CLICK_OPENS_DETAIL', true),
],
```

The View / Edit / Delete row actions appear by default on every Resource. Toggle individual ones globally, or disable the whole `default_row_actions` block and re-add per Resource via `actions()`. `row_click_opens_detail` controls whether clicking a row opens detail (or just selects the row).

## Locales and i18n

```
'locales' => [
    'app_namespaces' => env('MARTIS_APP_LOCALE_NAMESPACES', ''),
    'fallback_chain' => env('MARTIS_LOCALE_FALLBACK_CHAIN', 'en'),
    'rtl_locales'    => env('MARTIS_RTL_LOCALES', 'ar,fa,he,ur'),
],
```

Each env var is a comma-separated string; Martis splits it into an array at boot.

| Key | Default | Effect |
| --- | --- | --- |
| `locales.fallback_chain` | `en` | Comma-separated chain consulted when a translation is missing in the active locale. |
| `locales.app_namespaces` | `''` | Comma-separated list of consumer translation namespaces published into Martis surfaces (so your app can override package strings). |
| `locales.rtl_locales` | `ar,fa,he,ur` | Locales rendered right-to-left. |

## Dev tools (v1.8.8)

```
'dev' => [
    'tools_enabled' => env('MARTIS_DEV_TOOLS', in_array(env('APP_ENV', 'production'), ['local', 'testing'], true)),
    'prevent_lazy_loading_in_listings' => env('MARTIS_DEV_PREVENT_LAZY_LOADING_IN_LISTINGS', false),
],
```

Master switch for the Component Inspector overlay (Cmd/Ctrl+Shift+I) and other in-page debugging surfaces. Defaults to ON in `local` and `testing`, OFF in `production`. Force ON in production with `MARTIS_DEV_TOOLS=true` for short-lived diagnostic windows.

`prevent_lazy_loading_in_listings` (v2.20.0, off by default) makes a relationship lazy loaded while a listing's rows are serialized throw in `local` and `testing` and log a warning elsewhere, without turning on Laravel's global `Model::preventLazyLoading()`. See [Resources → Avoiding per-row queries on index pages](https://getmartis.com/docs/core/resources#avoiding-per-row-queries-on-index-pages-v2200).

## Content Security Policy (v2.4.0)

The panel shell renders an inline `<script>` (the boot configuration `window.MartisConfig` and the pre-paint theme and accent resolver), inline `<style>` blocks (the logo heights and your custom accents) and the module `<script>` and stylesheet `<link>` tags of the bundle. A policy that drops `'unsafe-inline'` blocks the inline ones unless they carry the request's nonce, so the shell takes the nonce from Laravel's Vite integration and stamps `nonce="..."` on every one of those tags. With no nonce set, the tags are rendered as before and there is no `nonce` attribute at all.

It also publishes the nonce in `<meta name="csp-nonce" content="...">`, which the SPA reads for the `<style>` elements it injects at run time: PrimeReact's component styles and CodeMirror's theme (the Trix editor reads the same meta tag itself). An extension of yours that injects a `<style>` element reads the nonce from that tag too.

Set the nonce in a middleware that runs before the panel renders, and send the header with the same value:

```
// app/Http/Middleware/ContentSecurityPolicy.php
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
 
class ContentSecurityPolicy
{
    public function handle(Request $request, Closure $next)
    {
        // Generates a random nonce and keeps it for every tag Vite and Martis render.
        // Already have one (spatie/laravel-csp, say)? Pass it: Vite::useCspNonce($nonce).
        $nonce = Vite::useCspNonce();
 
        $response = $next($request);
        $response->headers->set('Content-Security-Policy', implode('; ', [
            "default-src 'self'",
            "script-src 'self' 'nonce-{$nonce}'",
            "style-src 'self' 'nonce-{$nonce}'",
            "img-src 'self' data: blob: https:",   // avatars, Gravatar, uploads and local previews
            "media-src 'self' blob:",
            "font-src 'self' data:",
            "connect-src 'self'",                   // add the host of your real-time transport (Echo, Pusher, SSE)
            "object-src 'none'",
            "base-uri 'self'",
            "frame-ancestors 'self'",
        ]));
 
        return $response;
    }
}
```

Roll it out with `Content-Security-Policy-Report-Only` first and watch the reports. What the panel needs, and why:

| Directive | Needs | Because |
| --- | --- | --- |
| `script-src` | `'self'` and the nonce | The bundle is a same-origin module graph (its chunks load by dynamic `import()`), and the boot script is inline. No `'unsafe-eval'` is needed. |
| `style-src` | `'self'` and the nonce | The stylesheets are same-origin files, the shell has two inline `<style>` blocks, and PrimeReact and CodeMirror inject their own at run time. The SPA applies its inline `style` values through the CSSOM, which this directive allows. |
| `img-src` | `data:` and `blob:` besides `'self'` | Image and avatar fields preview a picked file as a `blob:` URL, and some fields embed `data:` images. Add the hosts of your disk and of Gravatar, or allow `https:`. |
| `connect-src` | `'self'` | The SPA talks to its own `/api` only. Add the host of a real-time transport you wired to `martisEventBus`. |

HTML that a field renders from stored content cannot carry inline `style` attributes under such a policy: the Markdown and Trix displays strip them anyway (see [Markdown](https://getmartis.com/docs/core/fields#markdown) and [Trix](https://getmartis.com/docs/core/fields#trix)), and a `help()` text that uses inline `style` should use a class instead. If you published the shell with `vendor:publish --tag=martis-views`, merge the nonce attributes of the new `app.blade.php` into your copy, or the boot script stays blocked. The Scramble API docs page (`api_docs.enabled`) is a separate view with its own needs and is not covered here.

## Environment variables (auto-generated)

The table below is generated by `php artisan martis:list-env-vars` from the live `config/martis.php` and reflects **130 env vars** in the current build. Run the command yourself to refresh it; CI runs it during release-cut. The command also takes `--json` for machine consumption.

```
php artisan martis:list-env-vars             # markdown table
php artisan martis:list-env-vars --json      # JSON array
```

| Variable | Default |
| --- | --- |
| `MARTIS_2FA_ENABLED` | `true` |
| `MARTIS_2FA_RECOVERY_CODES` | `8` |
| `MARTIS_2FA_TRUST_REMEMBER_ME` | `true` |
| `MARTIS_ACTION_EVENTS_ENABLED` | `true` |
| `MARTIS_ACTION_EVENTS_RESOURCE` | `true` |
| `MARTIS_ALLOW_BRAND_COLOR` | `false` |
| `MARTIS_API_DOCS_ENABLED` | `false` |
| `MARTIS_API_DOCS_PATH` | `'api-docs'` |
| `MARTIS_APP_LOCALE_NAMESPACES` | `''` |
| `MARTIS_ATTACHMENT_MAX_SIZE` | `10240` |
| `MARTIS_ATTACHMENT_MIMES` | `'jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip,mp4,mp3'` |
| `MARTIS_AUDIT_AUTHZ_DENIALS` | `false` |
| `MARTIS_AUDIT_AUTHZ_DENIALS_INCLUDE_VIEWANY` | `false` |
| `MARTIS_AUDIT_IMPERSONATION` | `true` |
| `MARTIS_AUDIT_ROLE_CHANGES` | `true` |
| `MARTIS_AUTHZ_REQUEST_CACHE` | `false` |
| `MARTIS_AUTHZ_REVOKE_SESSIONS_ON_DEMOTE` | `false` |
| `MARTIS_AUTH_CONTROL_LOCALE` | `true` |
| `MARTIS_AUTH_CONTROL_THEME` | `true` |
| `MARTIS_AUTH_EMAIL_VERIFICATION_ENABLED` | `false` |
| `MARTIS_AUTH_EMAIL_VERIFICATION_NOTICE_URL` | `(no default)` |
| `MARTIS_AUTH_FORGOT_SUBTITLE` | `(no default)` |
| `MARTIS_AUTH_FORGOT_TITLE` | `(no default)` |
| `MARTIS_AUTH_LOGIN_SUBTITLE` | `(no default)` |
| `MARTIS_AUTH_LOGIN_SUBTITLE_SSO` | `(no default)` |
| `MARTIS_AUTH_LOGIN_TITLE` | `(no default)` |
| `MARTIS_AUTH_MAGIC_LINK_AUTO_REGISTER` | `false` |
| `MARTIS_AUTH_MAGIC_LINK_ENABLED` | `false` |
| `MARTIS_AUTH_MAGIC_LINK_TTL` | `15` |
| `MARTIS_AUTH_PASSWORD_BROKER` | unset (the broker of the Martis guard's provider) |
| `MARTIS_AUTH_PASSWORD_CHANGE_COLUMN` | `must_change_password` |
| `MARTIS_AUTH_PASSWORD_CHANGE_ENABLED` | `false` |
| `MARTIS_AUTH_PASSWORD_CHANGE_URL` | `(no default)` |
| `MARTIS_AUTH_PASSWORD_RESET_ENABLED` | `false` |
| `MARTIS_AUTH_PASSWORD_RESET_URL` | `(no default)` |
| `MARTIS_AUTH_REGISTER_SUBTITLE` | `(no default)` |
| `MARTIS_AUTH_REGISTER_TITLE` | `(no default)` |
| `MARTIS_AUTH_REGISTRATION_DEFAULT_ROLE` | `(no default)` |
| `MARTIS_AUTH_REGISTRATION_ENABLED` | `false` |
| `MARTIS_AUTH_REGISTRATION_URL` | `(no default)` |
| `MARTIS_AUTH_RESET_SUBTITLE` | `(no default)` |
| `MARTIS_AUTH_RESET_TITLE` | `(no default)` |
| `MARTIS_AVATAR_COLUMN` | `'profile_picture'` |
| `MARTIS_AVATAR_DISK` | `'public'` |
| `MARTIS_AVATAR_ENABLED` | `true` |
| `MARTIS_AVATAR_MAX_SIZE` | `2048` |
| `MARTIS_AVATAR_PATH` | `'avatars'` |
| `MARTIS_BRAND_APPLE_TOUCH_ICON` | `(no default)` |
| `MARTIS_BRAND_AUTH_LOGO` | `(no default)` |
| `MARTIS_BRAND_AUTH_LOGO_DARK` | `(no default)` |
| `MARTIS_BRAND_DOCS_URL` | `(no default)` |
| `MARTIS_BRAND_ICON` | `(no default)` |
| `MARTIS_BRAND_ICON_DARK` | `(no default)` |
| `MARTIS_BRAND_LOGO` | `(no default)` |
| `MARTIS_BRAND_LOGO_DARK` | `(no default)` |
| `MARTIS_BRAND_LOGO_HEIGHT_AUTH` | `(no default)` |
| `MARTIS_BRAND_LOGO_HEIGHT_MENU` | `(no default)` |
| `MARTIS_BRAND_MANIFEST` | `(no default)` |
| `MARTIS_BRAND_NAME` | `'Martis'` |
| `MARTIS_BRAND_THEME_COLOR` | `(no default)` |
| `MARTIS_BRAND_VERSION` | `(no default)` |
| `MARTIS_CACHE_ADMIN_UI` | `true` |
| `MARTIS_CACHE_BADGE_TTL_SECONDS` | `(no default)` |
| `MARTIS_CACHE_DASHBOARDS` | `5` |
| `MARTIS_CACHE_DASHBOARDS_ENABLED` | `true` |
| `MARTIS_CACHE_DASHBOARDS_TTL` | `env('MARTIS_CACHE_DASHBOARDS', 5)` |
| `MARTIS_CACHE_ENABLED` | `true` |
| `MARTIS_CACHE_METRICS` | `5` |
| `MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS` | `(no default)` |
| `MARTIS_CACHE_METRICS_ENABLED` | `true` |
| `MARTIS_CACHE_METRICS_TTL` | `env('MARTIS_CACHE_METRICS', 5)` |
| `MARTIS_CACHE_NAVIGATION` | `1` |
| `MARTIS_CACHE_NAVIGATION_ENABLED` | `true` |
| `MARTIS_CACHE_NAVIGATION_TTL` | `env('MARTIS_CACHE_NAVIGATION', 1)` |
| `MARTIS_CACHE_SCHEMA` | `null` |
| `MARTIS_CACHE_SCHEMA_ENABLED` | `true` |
| `MARTIS_CACHE_SCHEMA_TTL` | `env('MARTIS_CACHE_SCHEMA', 1440)` |
| `MARTIS_CUSTOM_ACCENTS` | `(no default)` |
| `MARTIS_DASHBOARD_SHOW_GREETING` | `true` |
| `MARTIS_DASHBOARD_SHOW_WELCOME` | `true` |
| `MARTIS_DASHBOARD_SHOW_WELCOME_CARD` | `true` |
| `MARTIS_DEFAULT_ACCENT` | `'martis'` |
| `MARTIS_DEFAULT_DENSITY` | `'comfortable'` |
| `MARTIS_DEFAULT_LOCALE` | `'en'` |
| `MARTIS_DEFAULT_ROW_ACTIONS` | `true` |
| `MARTIS_DEFAULT_ROW_ACTION_DELETE` | `true` |
| `MARTIS_DEFAULT_ROW_ACTION_EDIT` | `true` |
| `MARTIS_DEFAULT_ROW_ACTION_VIEW` | `true` |
| `MARTIS_DEFAULT_THEME` | `'dark'` |
| `MARTIS_DEFAULT_TRASHED_FILTER` | `'active'` |
| `MARTIS_DEV_TOOLS` | `in_array(env('APP_ENV', 'production'), ['local', 'testing'], true),` |
| `MARTIS_DRAWER_EXPANDABLE` | `true` |
| `MARTIS_FAVICON` | `null` |
| `MARTIS_FOOTER_TEXT` | `(no default)` |
| `MARTIS_GUARD` | `null` |
| `MARTIS_IMPERSONATION_ENABLED` | `false` |
| `MARTIS_IMPERSONATION_GUARD` | `null` (the Martis guard) |
| `MARTIS_IMPERSONATION_MAX_DURATION` | `0` |
| `MARTIS_IMPERSONATION_POLL_MS` | `120000` |
| `MARTIS_IMPERSONATION_SESSION_KEY` | `'martis.impersonation'` |
| `MARTIS_INDEX_COLUMN_DEFAULTS` | `true` |
| `MARTIS_KEYBOARD_SHORTCUTS_ENABLED` | `true` |
| `MARTIS_KEYBOARD_SHORTCUTS_HELP_OVERLAY` | `true` |
| `MARTIS_LAYOUT` | `'sidebar'` |
| `MARTIS_LAYOUT_HEADER_FIRST` | `false` |
| `MARTIS_LOADER_DISABLED` | `false` |
| `MARTIS_LOADER_LOGO` | `(no default)` |
| `MARTIS_LOADER_LOGO_DARK` | `(no default)` |
| `MARTIS_LOCALE` | `env('APP_LOCALE', 'en')` |
| `MARTIS_LOCALE_FALLBACK_CHAIN` | `'en'` |
| `MARTIS_2FA_LOCKOUT_ATTEMPTS` | `5` |
| `MARTIS_2FA_LOCKOUT_MINUTES` | `15` |
| `MARTIS_2FA_THROTTLE_ATTEMPTS` | `5` |
| `MARTIS_2FA_THROTTLE_IP_ATTEMPTS` | `15` |
| `MARTIS_2FA_THROTTLE_MINUTES` | `1` |
| `MARTIS_LOGIN_THROTTLE_ATTEMPTS` | `20` |
| `MARTIS_LOGIN_THROTTLE_EMAIL_ATTEMPTS` | `100` |
| `MARTIS_LOGIN_THROTTLE_EMAIL_MINUTES` | `15` |
| `MARTIS_LOGIN_THROTTLE_MINUTES` | `1` |
| `MARTIS_NAV_BADGES_POLL_MS` | `300000` |
| `MARTIS_NAV_COUNTS` | `true` |
| `MARTIS_NAV_COUNTS_REPORT_FAILURES` | `true` |
| `MARTIS_NAV_COUNT_COMPACT_THRESHOLD` | `10000` |
| `MARTIS_NOTIFICATIONS_ENABLED` | `true` |
| `MARTIS_NOTIFICATIONS_MAX_DROPDOWN` | `10` |
| `MARTIS_NOTIFICATIONS_POLL_INTERVAL` | `90000` |
| `MARTIS_PAGE_TITLE` | `(no default)` |
| `MARTIS_PATH` | `'martis'` |
| `MARTIS_PREFERENCES_EDITABLE` | `'theme,accent,density,locale,reducedMotion'` |
| `MARTIS_PREFERENCES_ENABLED` | `true` |
| `MARTIS_PANEL_OPEN_ENVIRONMENTS` | `'local,testing'` |
| `MARTIS_PROFILE_EMAIL_CHANGE_TTL` | `60` |
| `MARTIS_PROFILE_EMAIL_EDITABLE` | `true` |
| `MARTIS_PROFILE_ENABLED` | `true` |
| `MARTIS_ROW_CLICK_OPENS_DETAIL` | `true` |
| `MARTIS_RTL_LOCALES` | `'ar,fa,he,ur'` |
| `MARTIS_SEARCH_DEFAULT_LIMIT` | `5` |
| `MARTIS_SEARCH_MATCH_MODE` | `'contains'` |
| `MARTIS_SEARCH_MIN_QUERY` | `2` |
| `MARTIS_SEARCH_MOBILE_MODE` | `'icon'` |
| `MARTIS_SEARCH_MODE` | `'bar'` |
| `MARTIS_SHOW_PROFILE_MENU` | `true` |
| `MARTIS_SSO_AZURE_ENABLED` | `false` |
| `MARTIS_SSO_AZURE_LOGOUT_URL` | `(no default)` |
| `MARTIS_SSO_CACHE_STORE` | `(no default)` |
| `MARTIS_SSO_ENABLED` | `false` |
| `MARTIS_SSO_ISSUER` | `(no default)` |
| `MARTIS_SSO_ISSUER_AUDIENCE_PANEL_B_CALLBACK_URL` | `(no default)` |
| `MARTIS_SSO_ISSUER_ENABLED` | `false` |
| `MARTIS_SSO_ISSUER_ENVIRONMENT` | `(no default)` |
| `MARTIS_SSO_ISSUER_PRIVATE_KEY` | `(no default)` |
| `MARTIS_SSO_PANEL_A_AUDIENCE` | `(no default)` |
| `MARTIS_SSO_PANEL_A_AUTHORIZE_URL` | `(no default)` |
| `MARTIS_SSO_PANEL_A_ENABLED` | `false` |
| `MARTIS_SSO_PANEL_A_ISSUER` | `(no default)` |
| `MARTIS_SSO_PANEL_A_PUBLIC_KEYS` | `''` |
| `MARTIS_STICKY_VIEWS_ENABLED` | `true` |
| `MARTIS_STICKY_VIEWS_SCOPE` | `'session'` |
| `MARTIS_STORAGE_DISK` | `'public'` |
| `MARTIS_THEME` | `'dark'` |
| `MARTIS_THEME_NAME` | `null` |
| `MARTIS_THROTTLE_DECAY` | `1` |
| `MARTIS_THROTTLE_ENABLED` | `true` |
| `MARTIS_THROTTLE_MAX` | `120` |
| `MARTIS_THROTTLE_METRICS_DECAY` | `null` (the API decay) |
| `MARTIS_THROTTLE_METRICS_MAX` | `null` (metrics stay in the API bucket) |
| `MARTIS_TOAST_POSITION` | `'bottom-right'` |
| `MARTIS_UI_LOCALES` | `'en,pt_PT,pt_BR'` |
| `MARTIS_UI_LOCALE_LABELS` | `(no default)` |
| `MARTIS_USER_ID_COLUMN_TYPE` | `(no default)` |
| `MARTIS_WELCOME_DESCRIPTION` | `(no default)` |
| `MARTIS_WELCOME_HEADING` | `(no default)` |

## Next Steps

- [Installation Guide](https://getmartis.com/docs/getting-started/installation) — Initial setup
- [Authentication](https://getmartis.com/docs/auth/authentication) — Login, 2FA, profile
- [Resources](https://getmartis.com/docs/core/resources) — Resource configuration
- [Override System](https://getmartis.com/docs/customization/overrides) — Customize the UI

REST API

Cache
