Documentation menu
HomeDocsReferenceConfiguration

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.

Authentication

'guard' => env('MARTIS_GUARD', null),
'middleware' => ['web'],
'auth_middleware' => ['martis.auth'],
KeyTypeDefaultDescription
guard?stringnullAuthentication 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.
middlewarearray['web']Applied to all Martis routes (public and protected).
auth_middlewarearray['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'),
],
],
KeyTypeDefaultDescription
namestring'Martis'Displayed in the sidebar header and used as the {brand} interpolation for the bundled page title translation.
logo?stringnullFull 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?stringnullSmall 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?stringnullPath to a custom favicon (relative to public/). When null, Martis serves its own default favicon from the package — no vendor:publish step required.
page_titlestring | array | nullnullBrowser 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.
version?stringnullOptional version string printed in the sidebar footer (e.g. v1.5.2, 2026.04.29).
docs_url?stringnullOptional 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.

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).

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

SurfaceReads 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)
Loaderloader.logo / loader.logoDark (independent — see Loader)
Browser tab faviconbrand.favicon (independent — see "Customising the favicon" below)
Web app manifest, Apple touch icon, theme colorbrand.manifest, brand.apple_touch_icon, brand.theme_color (independent — see "Head tags" below)
Browser tab titlebrand.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:

SurfaceLogo heightIcon height
Sidebar (expanded)40 px (MARTIS_BRAND_LOGO_HEIGHT_MENU default)28 px
Sidebar (collapsed rail)n/a — icon wins28 px
Auth card48 px (MARTIS_BRAND_LOGO_HEIGHT_AUTH default)32 px

Tracking issue: #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; }).

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:

LocaleRendered title
enAcme — Admin Control
pt_PTAcme — Centro de Administração
pt_BRAcme — 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.

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.:

URLTitle rendered
/martisName of the first dashboard the user may see · brand (e.g. Operations · Acme)
/martis/dashboards/support-teamThat dashboard's name · brand (e.g. Team · Acme)
/martis/resources/clientsClients · Acme
/martis/resources/clients/newCreate Client · Acme
/martis/resources/clients/42Client · Acme
/martis/resources/clients/42/editEdit Client · Acme
/martis/profileProfile · 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 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' => [
'enabled' => true,
'text' => env('MARTIS_FOOTER_TEXT'),
],
KeyTypeDefaultDescription
enabledbooltrueSet false to hide the footer entirely.
text?stringnullCustom 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 stringTranslation keyCustomisation
Hello, {name} (greeting line)martis::resources.helloLang files only (publish + edit)
Welcome to Martis Admin Engine. (subtitle under the greeting)martis::resources.welcomeLang files only (no env var)
Welcome to Martis (animated <WelcomeCard> heading, optional)martis::resources.welcome_card_headingMARTIS_WELCOME_HEADING env or lang files
Welcome card bodymartis::resources.welcome_card_descriptionMARTIS_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'),
],
KeyTypeDefaultDescription
heading?stringnullCustom heading for the dashboard's animated <WelcomeCard>. When null, uses the bundled welcome_card_heading translation.
description?stringnullCustom 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

PresetWhat it rendersWhen to pick it
sidebar (default)Left nav column, topbar, content, in-flow footer. The collapsible shell described in theming.md and overrides.md.Most admin apps. Handles mobile drawer, collapsed rail, accent pill, density tokens.
topnavHorizontal 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.
minimalNo chrome — just the route outlet. No topbar, no sidebar, no footer.Embedded dashboards, marketing screens, print-friendly surfaces.
customStrict 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 and On a phone.

Full wiring examples, prop contracts, and the rationale for piece-by-piece vs full-shell overrides live in overrides.md.

'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.
  • 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 for the badge API (including menuCount() for custom values) and menus.md 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). 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
],
KeyTypeDefaultDescription
enabledbooltrueSet false to disable API rate limiting.
max_attemptsint120Maximum requests per window, per signed-in user of the Martis guard, counted across the Martis API and every Tool's routes (v2.0).
decay_minutesint1Rate limit window in minutes.
metrics_max_attemptsint|nullnullA 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_minutesint|nullnullWindow 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), 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),
],
KeyTypeDefaultDescription
defaultstring'dark'Default theme: 'dark' or 'light'.
allowTogglebooltrueWhen 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?stringnullActive 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.

Keyboard Shortcuts

'keyboard_shortcuts' => [
'enabled' => env('MARTIS_KEYBOARD_SHORTCUTS_ENABLED', true),
'helpOverlay' => env('MARTIS_KEYBOARD_SHORTCUTS_HELP_OVERLAY', true),
],
KeyTypeDefaultDescription
enabledbooltrueMaster 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.
helpOverlaybooltrueWhen 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 for the full API.

User Menu

'user_menu' => [
'showThemeToggle' => true,
'showProfile' => env('MARTIS_SHOW_PROFILE_MENU', true),
// 'customItems' => [],
],
KeyTypeDefaultDescription
showThemeTogglebooltrueShow dark/light mode toggle.
showProfilebooltrueShow profile page link. Override with MARTIS_SHOW_PROFILE_MENU=false.
customItems?arraynullArray 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.

'customItems' => [
['label' => 'Settings', 'icon' => 'gear', 'url' => '/settings'],
['separator' => true],
['label' => 'Documentation', 'icon' => 'book-open', 'url' => 'https://docs.example.com'],
],
'search' => [
'enabled' => true,
'placeholder' => null,
'mode' => env('MARTIS_SEARCH_MODE', 'bar'),
'mobileMode' => env('MARTIS_SEARCH_MOBILE_MODE', 'icon'),
],
KeyTypeDefaultDescription
enabledbooltrueEnable/disable global search.
placeholder?stringnullCustom placeholder text (null = i18n default).
modestring'bar'Desktop mode: 'bar', 'icon', or 'disabled'.
mobileModestring'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,
],
KeyTypeDefaultDescription
showGreetingbooltrueShow personalised greeting (Hello, {name}). Override with MARTIS_DASHBOARD_SHOW_GREETING=false.
showWelcomebooltrueShow welcome subtitle (Welcome to Martis Admin Engine.) below the greeting. Override with MARTIS_DASHBOARD_SHOW_WELCOME=false.
showWelcomeCardbooltrueShow the animated WelcomeCard hero at the top of the dashboard. Override with MARTIS_DASHBOARD_SHOW_WELCOME_CARD=false.
showMetricsbooltrueShow summary metrics row (total resources, groups).
showResourceCardsbooltrueShow 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.

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'),
],
KeyTypeDefaultDescription
default_row_actions.enabledbooltrueMaster 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.viewbooltruePer-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.editbooltruePer-action global kill-switch for the Edit icon. MARTIS_DEFAULT_ROW_ACTION_EDIT.
default_row_actions.deletebooltruePer-action global kill-switch for the Delete icon. MARTIS_DEFAULT_ROW_ACTION_DELETE.
row_click_opens_detailbooltrueWhen 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_filterstring'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().

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 for the full guide.

Pagination

'pagination' => [
'default_per_page' => 25,
'max_per_page' => 100,
],
KeyTypeDefaultDescription
default_per_pageint25Fallback for Resource::perPage() and Lens::perPage() when the resource/lens does not override the method.
max_per_pageint100Upper 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.

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),
KeyDefaultDescription
extensions_versioned_urlstrueA 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_staletrueA 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 for the response headers and Overrides 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
],
KeyTypeDefaultDescription
allowed_mimesarraySee configAllowed extensions for Trix/Markdown file uploads (MARTIS_ATTACHMENT_MIMES, comma-separated).
max_sizeint10240Maximum file size in KB (MARTIS_ATTACHMENT_MAX_SIZE).
throttle_maxint20Uploads 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_decayint1The 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. 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,
],
KeyTypeDefaultDescription
enabledbooltrueRecord action events to the database.
resourcebooltrueRegister 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.

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:

LayerWhat it holdsFile
Static configPaths, throttle, theme, profile, cache TTLs, drawer widths, sticky-views scope, notifications poll interval, …config/martis.php
Code registrationsMain menu resolver, dashboards, custom cache layers, gate definitions, page-title closuresapp/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:

KeyCalled withReturns
brand.page_titleRequest $requestThe <title> (?string). A plain string is the title itself.
gates.plan_resolver?Authenticatable $userThe user's plan (?string). See Soft-gates.
profile.avatar.url_resolverstring $storedPathThe avatar's public URL (string).
auth.sso.providers.{provider}.role_source_callablestring $externalId, string $accessTokenThe external role names (array<int, string>). See SSO.
auth.sso.providers.{provider}.role_callablearray $externalRoles, ?User $user, string $providerThe local roles (Collection). See SSO.
auth.sso.issuer.claimsAuthenticatable $user, string $audienceThe claims to add to, or replace in, the signed SSO token (array<string, mixed>, v2.21.0). See SSO.

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):

FieldPattern / 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 #).
contrastHexOptional. #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.
DuplicatesLast-wins (env-override semantics).
LimitUp 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).

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.

Per-subsystem cache layer with three control planes (config / env / runtime), bypass header, and admin page. See 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 offRoutes not registeredAlso
avatar.enabled (MARTIS_AVATAR_ENABLED)POST and DELETE /api/profile/avatarThe 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/challengeNobody 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)nonePATCH /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 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'))))),
],
KeyTypeDefaultEffect
enabledbooltrueMaster switch for the per-user preferences panel (theme, density, locale, accent).
allowBrandColorboolfalseWhen 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.
editablearrayall fiveThe 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.
localesarray['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).
locale_labelsarrayen, en_GB, pt_PT, pt_BR labelsThe human-readable labels surfaced in the locale dropdown. A locale without a label shows its code.
locale_labels_envstring|nullnullMARTIS_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 for the D1/D2/D3 spec and 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,
],
],
KeyTypeDefaultEffect
enabledbooltrueMaster switch. Falsey disables the entire feature.
scopeenumsessionWhere state lives: session (sessionStorage, wipes on tab close) or local (localStorage, survives).
persist.filtersbooltrueRemember filter values per resource.
persist.sortingbooltrueRemember sort column + direction.
persist.paginationbooltrueRemember current page.
persist.per_pagebooltrueRemember per-page selector.
persist.columnsbooltrueRemember visible columns toggle.
persist.scrollboolfalseRemember scroll position on the index.

See 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),
],
KeyTypeDefaultEffect
logo / logoDark?stringnullImage shown in place of the spinner, with its dark theme variant (if only one is set it serves both themes). Path or URL.
disabledboolfalseGlobally suppress the page loader. Useful in tests or for apps that ship their own loading UI.

See 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'),
],
KeyTypeDefaultEffect
enabledboolfalseMaster switch. Default OFF — opt-in. When false, the impersonation REST endpoints return 503 and the banner short-circuits its mount-time fetch.
guardstringwebAuth guard the impersonation operates on.
session_keystringmartis.impersonationSession 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.

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),
],
KeyDefaultEffect
role_changestrueLogs 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.
impersonationtrueLogs impersonation.started / impersonation.stopped.
authz_denialsfalseRecords 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_viewanyfalseWhen 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),
],
KeyDefaultEffect
request_cachefalseMemoises (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_demotefalseWhen 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.

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),
],
],
KeyDefaultEffect
enabledfalseMaster switch. When on, the login page exposes a "Send magic link" form.
ttl_minutes15Validity window of the signed link.
auto_registerfalseWhen 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.

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.

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.

VariableDefaultSurface
MARTIS_AUTH_LOGIN_TITLEtranslationLogin title
MARTIS_AUTH_LOGIN_SUBTITLEtranslationLogin subtitle (password mode)
MARTIS_AUTH_LOGIN_SUBTITLE_SSOtranslationLogin subtitle (SSO-only mode)
MARTIS_AUTH_REGISTER_TITLEtranslationRegister title
MARTIS_AUTH_REGISTER_SUBTITLEtranslationRegister subtitle
MARTIS_AUTH_FORGOT_TITLEtranslationForgot password title
MARTIS_AUTH_FORGOT_SUBTITLEtranslationForgot password subtitle
MARTIS_AUTH_RESET_TITLEtranslationReset password title
MARTIS_AUTH_RESET_SUBTITLEtranslationReset password subtitle
MARTIS_AUTH_INVITATION_ACCEPT_TITLEtranslationInvitation accept title
MARTIS_AUTH_INVITATION_ACCEPT_SUBTITLEtranslationInvitation 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']
],
KeyTypeDefaultDescription
auth.session_guardsarray<string>|nullnullThe 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 and 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)
],
],
KeyTypeDefaultDescription
auth.password_login.enabledbooltruefalse 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_emailsarray<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.

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'],
],
],
KeyTypeDefaultDescription
auth.challenge.enabledboolfalseTurns the hook on. An empty string (MARTIS_AUTH_CHALLENGE_ENABLED=) is "unset": off.
auth.challenge.verifier?class-stringnullA class implementing Martis\Contracts\ChallengeVerifier, resolved from the container. Required while enabled; never published.
auth.challenge.site_key?stringnullPublic key, published as window.MartisConfig.auth.challenge.siteKey for the auth:challenge component.
auth.challenge.applies_toarray<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, which has a complete Cloudflare Turnstile example, and What reaches the browser.

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.

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 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):

KeyDefaultEffect
two_factor_attempts5Requests per user per two_factor_minutes: the account being guessed at. Past it the route answers 429, without checking the code.
two_factor_ip_attempts15Requests 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_minutes1The window of both limits.
two_factor_lockout_attempts5Consecutive wrong codes (TOTP or recovery) that lock the user out. 0 turns the lockout off.
two_factor_lockout_minutes15How 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.

Impersonation extras

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

VariableDefaultEffect
MARTIS_IMPERSONATION_GUARDthe Martis guardAuth guard the impersonation operates on (v2.0.0+: unset follows MARTIS_GUARD, then the app's default guard; it was web).
MARTIS_IMPERSONATION_SESSION_KEYmartis.impersonationSession bag where the operator's id is stashed.
MARTIS_IMPERSONATION_MAX_DURATION0Maximum session length in minutes. 0 disables the timeout.
MARTIS_IMPERSONATION_POLL_MS120000Banner 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 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.

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).

VariableDefaultSurface
MARTIS_CACHE_DASHBOARDS_ENABLEDtrueDashboard renders
MARTIS_CACHE_DASHBOARDS / MARTIS_CACHE_DASHBOARDS_TTL5Dashboard TTL (the list key is also fingerprinted by the user's authorized set, so membership changes never wait for the TTL)
MARTIS_CACHE_METRICS_ENABLEDtrueMetric cards
MARTIS_CACHE_METRICS / MARTIS_CACHE_METRICS_TTL5Metrics TTL
MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDSnullMetrics TTL in seconds for a metric that sets none (v2.20.0); null keeps the minutes above. See Cache
MARTIS_CACHE_NAVIGATION_ENABLEDtrueSidebar navigation tree
MARTIS_CACHE_NAVIGATION / MARTIS_CACHE_NAVIGATION_TTL1Navigation TTL
MARTIS_CACHE_BADGE_TTL_SECONDSnullTTL in seconds of each user's sidebar badge map (v2.20.0); null keeps the navigation TTL in minutes
MARTIS_CACHE_SCHEMA_ENABLEDtrueResource schema payload
MARTIS_CACHE_SCHEMA / MARTIS_CACHE_SCHEMA_TTL1440Schema TTL in minutes (one day since v2.0; null before, see Cache → Invalidation)
MARTIS_CACHE_ADMIN_UItrueCache 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.

Sticky views

'sticky_views' => [
'enabled' => env('MARTIS_STICKY_VIEWS_ENABLED', true),
'scope' => env('MARTIS_STICKY_VIEWS_SCOPE', 'session'),
],
KeyDefaultEffect
enabledtrueRemember the last index URL (filter, sort, page) per resource so users return to where they left off.
scopesessionWhere 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'),
],
KeyDefaultEffect
modebarTopbar search mode on desktop. bar, icon, or disabled.
mobile_modeiconEquivalent for mobile viewports.
default_limit5Result rows per resource in the omnisearch dropdown.
min_query2Don't trigger a search until the query has this many characters.
match_modecontainsHow 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.

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.

KeyDefaultEffect
locales.fallback_chainenComma-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_localesar,fa,he,urLocales 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.

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:

DirectiveNeedsBecause
script-src'self' and the nonceThe 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 nonceThe 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-srcdata: 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 and 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
VariableDefault
MARTIS_2FA_ENABLEDtrue
MARTIS_2FA_RECOVERY_CODES8
MARTIS_2FA_TRUST_REMEMBER_MEtrue
MARTIS_ACTION_EVENTS_ENABLEDtrue
MARTIS_ACTION_EVENTS_RESOURCEtrue
MARTIS_ALLOW_BRAND_COLORfalse
MARTIS_API_DOCS_ENABLEDfalse
MARTIS_API_DOCS_PATH'api-docs'
MARTIS_APP_LOCALE_NAMESPACES''
MARTIS_ATTACHMENT_MAX_SIZE10240
MARTIS_ATTACHMENT_MIMES'jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip,mp4,mp3'
MARTIS_AUDIT_AUTHZ_DENIALSfalse
MARTIS_AUDIT_AUTHZ_DENIALS_INCLUDE_VIEWANYfalse
MARTIS_AUDIT_IMPERSONATIONtrue
MARTIS_AUDIT_ROLE_CHANGEStrue
MARTIS_AUTHZ_REQUEST_CACHEfalse
MARTIS_AUTHZ_REVOKE_SESSIONS_ON_DEMOTEfalse
MARTIS_AUTH_CONTROL_LOCALEtrue
MARTIS_AUTH_CONTROL_THEMEtrue
MARTIS_AUTH_EMAIL_VERIFICATION_ENABLEDfalse
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_REGISTERfalse
MARTIS_AUTH_MAGIC_LINK_ENABLEDfalse
MARTIS_AUTH_MAGIC_LINK_TTL15
MARTIS_AUTH_PASSWORD_BROKERunset (the broker of the Martis guard's provider)
MARTIS_AUTH_PASSWORD_CHANGE_COLUMNmust_change_password
MARTIS_AUTH_PASSWORD_CHANGE_ENABLEDfalse
MARTIS_AUTH_PASSWORD_CHANGE_URL(no default)
MARTIS_AUTH_PASSWORD_RESET_ENABLEDfalse
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_ENABLEDfalse
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_ENABLEDtrue
MARTIS_AVATAR_MAX_SIZE2048
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_UItrue
MARTIS_CACHE_BADGE_TTL_SECONDS(no default)
MARTIS_CACHE_DASHBOARDS5
MARTIS_CACHE_DASHBOARDS_ENABLEDtrue
MARTIS_CACHE_DASHBOARDS_TTLenv('MARTIS_CACHE_DASHBOARDS', 5)
MARTIS_CACHE_ENABLEDtrue
MARTIS_CACHE_METRICS5
MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS(no default)
MARTIS_CACHE_METRICS_ENABLEDtrue
MARTIS_CACHE_METRICS_TTLenv('MARTIS_CACHE_METRICS', 5)
MARTIS_CACHE_NAVIGATION1
MARTIS_CACHE_NAVIGATION_ENABLEDtrue
MARTIS_CACHE_NAVIGATION_TTLenv('MARTIS_CACHE_NAVIGATION', 1)
MARTIS_CACHE_SCHEMAnull
MARTIS_CACHE_SCHEMA_ENABLEDtrue
MARTIS_CACHE_SCHEMA_TTLenv('MARTIS_CACHE_SCHEMA', 1440)
MARTIS_CUSTOM_ACCENTS(no default)
MARTIS_DASHBOARD_SHOW_GREETINGtrue
MARTIS_DASHBOARD_SHOW_WELCOMEtrue
MARTIS_DASHBOARD_SHOW_WELCOME_CARDtrue
MARTIS_DEFAULT_ACCENT'martis'
MARTIS_DEFAULT_DENSITY'comfortable'
MARTIS_DEFAULT_LOCALE'en'
MARTIS_DEFAULT_ROW_ACTIONStrue
MARTIS_DEFAULT_ROW_ACTION_DELETEtrue
MARTIS_DEFAULT_ROW_ACTION_EDITtrue
MARTIS_DEFAULT_ROW_ACTION_VIEWtrue
MARTIS_DEFAULT_THEME'dark'
MARTIS_DEFAULT_TRASHED_FILTER'active'
MARTIS_DEV_TOOLSin_array(env('APP_ENV', 'production'), ['local', 'testing'], true),
MARTIS_DRAWER_EXPANDABLEtrue
MARTIS_FAVICONnull
MARTIS_FOOTER_TEXT(no default)
MARTIS_GUARDnull
MARTIS_IMPERSONATION_ENABLEDfalse
MARTIS_IMPERSONATION_GUARDnull (the Martis guard)
MARTIS_IMPERSONATION_MAX_DURATION0
MARTIS_IMPERSONATION_POLL_MS120000
MARTIS_IMPERSONATION_SESSION_KEY'martis.impersonation'
MARTIS_INDEX_COLUMN_DEFAULTStrue
MARTIS_KEYBOARD_SHORTCUTS_ENABLEDtrue
MARTIS_KEYBOARD_SHORTCUTS_HELP_OVERLAYtrue
MARTIS_LAYOUT'sidebar'
MARTIS_LAYOUT_HEADER_FIRSTfalse
MARTIS_LOADER_DISABLEDfalse
MARTIS_LOADER_LOGO(no default)
MARTIS_LOADER_LOGO_DARK(no default)
MARTIS_LOCALEenv('APP_LOCALE', 'en')
MARTIS_LOCALE_FALLBACK_CHAIN'en'
MARTIS_2FA_LOCKOUT_ATTEMPTS5
MARTIS_2FA_LOCKOUT_MINUTES15
MARTIS_2FA_THROTTLE_ATTEMPTS5
MARTIS_2FA_THROTTLE_IP_ATTEMPTS15
MARTIS_2FA_THROTTLE_MINUTES1
MARTIS_LOGIN_THROTTLE_ATTEMPTS20
MARTIS_LOGIN_THROTTLE_EMAIL_ATTEMPTS100
MARTIS_LOGIN_THROTTLE_EMAIL_MINUTES15
MARTIS_LOGIN_THROTTLE_MINUTES1
MARTIS_NAV_BADGES_POLL_MS300000
MARTIS_NAV_COUNTStrue
MARTIS_NAV_COUNTS_REPORT_FAILUREStrue
MARTIS_NAV_COUNT_COMPACT_THRESHOLD10000
MARTIS_NOTIFICATIONS_ENABLEDtrue
MARTIS_NOTIFICATIONS_MAX_DROPDOWN10
MARTIS_NOTIFICATIONS_POLL_INTERVAL90000
MARTIS_PAGE_TITLE(no default)
MARTIS_PATH'martis'
MARTIS_PREFERENCES_EDITABLE'theme,accent,density,locale,reducedMotion'
MARTIS_PREFERENCES_ENABLEDtrue
MARTIS_PANEL_OPEN_ENVIRONMENTS'local,testing'
MARTIS_PROFILE_EMAIL_CHANGE_TTL60
MARTIS_PROFILE_EMAIL_EDITABLEtrue
MARTIS_PROFILE_ENABLEDtrue
MARTIS_ROW_CLICK_OPENS_DETAILtrue
MARTIS_RTL_LOCALES'ar,fa,he,ur'
MARTIS_SEARCH_DEFAULT_LIMIT5
MARTIS_SEARCH_MATCH_MODE'contains'
MARTIS_SEARCH_MIN_QUERY2
MARTIS_SEARCH_MOBILE_MODE'icon'
MARTIS_SEARCH_MODE'bar'
MARTIS_SHOW_PROFILE_MENUtrue
MARTIS_SSO_AZURE_ENABLEDfalse
MARTIS_SSO_AZURE_LOGOUT_URL(no default)
MARTIS_SSO_CACHE_STORE(no default)
MARTIS_SSO_ENABLEDfalse
MARTIS_SSO_ISSUER(no default)
MARTIS_SSO_ISSUER_AUDIENCE_PANEL_B_CALLBACK_URL(no default)
MARTIS_SSO_ISSUER_ENABLEDfalse
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_ENABLEDfalse
MARTIS_SSO_PANEL_A_ISSUER(no default)
MARTIS_SSO_PANEL_A_PUBLIC_KEYS''
MARTIS_STICKY_VIEWS_ENABLEDtrue
MARTIS_STICKY_VIEWS_SCOPE'session'
MARTIS_STORAGE_DISK'public'
MARTIS_THEME'dark'
MARTIS_THEME_NAMEnull
MARTIS_THROTTLE_DECAY1
MARTIS_THROTTLE_ENABLEDtrue
MARTIS_THROTTLE_MAX120
MARTIS_THROTTLE_METRICS_DECAYnull (the API decay)
MARTIS_THROTTLE_METRICS_MAXnull (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