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-configThis 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-varsand compare. The command parsesconfig/martis.phpdirectly, 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'],| Key | Type | Default | Description |
|---|---|---|---|
guard | ?string | null | Authentication guard. null uses Laravel's default guard. The panel's requests run as that guard (it becomes the request's guard), and the auth flows, the Martis migrations and martis:user use its provider: see Upgrading → A custom Martis guard. |
middleware | array | ['web'] | Applied to all Martis routes (public and protected). |
auth_middleware | array | ['martis.auth'] | Applied to protected routes only. martis.auth implements Laravel's AuthenticatesRequests, so the router's middleware priority runs it where it runs Laravel's auth: before the throttle, the route bindings and any middleware outside the priority list (v2.0.0+). |
| guard | ?string | null | Authentication guard. null uses Laravel's default guard. |
| middleware | array | ['web'] | Applied to all Martis routes (public and protected) and to a Tool's routes. |
| auth_middleware | array | ['martis.auth'] | Applied to protected routes only, a Tool's routes included (v2.0). A middleware name is accepted in place of a list; null means the default; any other value throws an InvalidArgumentException naming the key. |
Brand
'brand' => [ 'name' => env('MARTIS_BRAND_NAME', 'Martis'), 'logo' => env('MARTIS_BRAND_LOGO'), 'logo_dark' => env('MARTIS_BRAND_LOGO_DARK'), // v1.7.0 'icon' => env('MARTIS_BRAND_ICON'), 'icon_dark' => env('MARTIS_BRAND_ICON_DARK'), // v1.7.0 'auth_logo' => env('MARTIS_BRAND_AUTH_LOGO'), // v2.6.0 'auth_logo_dark' => env('MARTIS_BRAND_AUTH_LOGO_DARK'), // v2.6.0 'favicon' => env('MARTIS_FAVICON', null), 'manifest' => env('MARTIS_BRAND_MANIFEST'), // v2.6.0 'apple_touch_icon' => env('MARTIS_BRAND_APPLE_TOUCH_ICON'), // v2.6.0 'theme_color' => env('MARTIS_BRAND_THEME_COLOR'), // v2.6.0 'page_title' => env('MARTIS_PAGE_TITLE'), // null | title | resolver class 'version' => env('MARTIS_BRAND_VERSION'), 'docs_url' => env('MARTIS_BRAND_DOCS_URL'), 'logo_height' => [ // v1.7.0 'menu' => env('MARTIS_BRAND_LOGO_HEIGHT_MENU'), 'auth' => env('MARTIS_BRAND_LOGO_HEIGHT_AUTH'), ],],| Key | Type | Default | Description |
|---|---|---|---|
name | string | 'Martis' | Displayed in the sidebar header and used as the {brand} interpolation for the bundled page title translation. |
logo | ?string | null | Full horizontal lockup (icon + wordmark in one asset). When set, the SPA renders the lockup alone — the separate brand.name text next to it is hidden in the sidebar / topbar / auth frame to avoid a duplicated wordmark. Relative paths resolve against public/ (/img/logo.svg); full URLs pass through unchanged. |
icon | ?string | null | Small square brand icon (replaces just the bundled cube next to brand.name). Use this when you want to keep the brand text rendered by Martis but swap the mark. Independent from logo — Martis prefers logo when both are set. |
favicon | ?string | null | Path to a custom favicon (relative to public/). When null, Martis serves its own default favicon from the package — no vendor:publish step required. |
page_title | string | array | null | null | Browser tab title shown in <title>. null uses the bundled translation (e.g. "Acme — Admin Control"). A string is the title itself, unless it names an invokable class: that class, or a [Class::class, 'staticMethod'] array, receives the current Request and returns the title. See Config keys that take a callable. |
version | ?string | null | Optional version string printed in the sidebar footer (e.g. v1.5.2, 2026.04.29). |
docs_url | ?string | null | Optional docs link rendered on the right-hand side of the sidebar footer. |
Choosing your brand mode
Martis renders the brand row (sidebar header / topnav left / auth card top) in one of four modes. Where to set each: every knob is an .env key in your application's .env file. No published config edits required — once config/martis.php reads env('MARTIS_BRAND_LOGO') / env('MARTIS_BRAND_ICON') (default since v1.5.2 / v1.6.3), flipping modes is one line in .env plus php artisan config:cache.
Mode 1 — Logo only (recommended when your brand has a horizontal lockup)
MARTIS_BRAND_LOGO=/img/edgeflow-lockup.svg# leave MARTIS_BRAND_ICON unsetRenders the lockup image alone. The brand.name text is hidden next to it (the wordmark inside your asset replaces it). Best for brands like EdgeFlow where the SVG already contains the mark + wordmark in one piece.
+------------------------+| [EdgeFlow lockup] |+------------------------+The image is sized to ~40px tall in the sidebar / topnav and ~40px tall in the auth card, scaling width to preserve the asset's aspect ratio (capped at the column width so a wide lockup never overflows).
Mode 2 — Icon + brand name (recommended when you only have a square mark)
MARTIS_BRAND_ICON=/img/edgeflow-icon.pngMARTIS_BRAND_NAME="EdgeFlow"# leave MARTIS_BRAND_LOGO unsetRenders [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 unsetMARTIS_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.svgMARTIS_BRAND_ICON=/img/edgeflow-icon.pnglogo wins on every surface; icon sits queued. Flip from logo-only to icon mode by clearing MARTIS_BRAND_LOGO in .env — no code change, no rebuild.
Where each mode appears
| Surface | Reads from |
|---|---|
Sidebar header (sidebar preset) | brand.logo → brand.icon → bundled cube |
Topnav header (topnav preset) | brand.logo → brand.icon → bundled cube |
| Auth card (Login, Register, Forgot password, Reset password, Invitation, 2FA challenge, error screens) | brand.auth_logo → brand.logo → brand.icon → bundled lockup (with wordmark) |
| Loader | loader.logo / loader.logoDark (independent — see Loader) |
| Browser tab favicon | brand.favicon (independent — see "Customising the favicon" below) |
| Web app manifest, Apple touch icon, theme color | brand.manifest, brand.apple_touch_icon, brand.theme_color (independent — see "Head tags" below) |
| Browser tab title | brand.page_title + brand.name (independent — see "Customising the page title" below) |
Three rows, three env vars, one consistent rendering rule. Pick the mode that matches the asset you have.
Switching modes after deployment
After editing .env:
php artisan optimize:clearphp artisan config:cachephp artisan martis:cache:clearThe 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.svgMARTIS_BRAND_LOGO_DARK=/img/logo-dark.svgMARTIS_BRAND_ICON=/img/icon-light.pngMARTIS_BRAND_ICON_DARK=/img/icon-dark.pngResolution 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.svgMARTIS_BRAND_AUTH_LOGO_DARK=/brand/auth-lockup-dark.svgThe 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:
- 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.
- 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.
- 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_LOGOandMARTIS_BRAND_LOGO_DARKat the same file (zero overhead — same hash, dedup'd).
Recommended targets per surface:
| Surface | Logo height | Icon height |
|---|---|---|
| Sidebar (expanded) | 40 px (MARTIS_BRAND_LOGO_HEIGHT_MENU default) | 28 px |
| Sidebar (collapsed rail) | n/a — icon wins | 28 px |
| Auth card | 48 px (MARTIS_BRAND_LOGO_HEIGHT_AUTH default) | 32 px |
Tracking issue: #127.
Widest lockup per menu height. In the expanded sidebar the logo box is the sidebar width less 32 px of padding: 208 px in the default 240 px sidebar. In the top navigation it is 220 px. A lockup wider than its box at the configured height is scaled down to fit, keeping its proportions (v2.6.0; before, its right-hand side was cut off). So a 220×40 lockup at the default 40 px height renders about 208×38 in the sidebar. The collapsed rail is unchanged: without a brand.icon it shows the left-hand side of the lockup, cropped to the rail's square, so set MARTIS_BRAND_ICON for a proper rail mark. To show it at full size, lower MARTIS_BRAND_LOGO_HEIGHT_MENU, or widen the sidebar from a named theme (:root { --sidebar-width: 252px; }).
Sidebar collapse behaviour (v1.7.0)
When the sidebar collapses (240 px → 64 px rail) and brand.icon is set, the icon wins regardless of brand.logo. A horizontal lockup at 64 px rail width gets distorted; the square icon fits cleanly. When only brand.logo is set, the logo shrinks (the v1.6.x fallback). The sidebar text (brand.name) is always hidden in collapsed mode.
Customising the page title
Four levels of control:
1. Bundled translation (no configuration). The default title uses martis::navigation.page_title_default interpolated with brand.name:
| Locale | Rendered title |
|---|---|
en | Acme — Admin Control |
pt_PT | Acme — Centro de Administração |
pt_BR | Acme — Central de Administração |
2. Static override — set a literal string in .env or config:
MARTIS_PAGE_TITLE="Acme Back Office"'brand' => [ 'page_title' => 'Acme Back Office',],The same key also takes a resolver: the name of an invokable class or a [Class::class, 'staticMethod'] array. Both survive php artisan config:cache. The resolver receives the current Request and returns the title; an empty string or null falls through to the inference below.
'brand' => [ 'page_title' => \App\Martis\PageTitle::class, // __invoke(Request $request): ?string],A string that names no invokable class is always the literal title. Martis never calls it, even when it names a PHP function (Mail, Date, Link). See Config keys that take a callable.
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:cachefails to serialise it, even when a service provider sets it withconfig()->set(). Register it withMartis::pageTitleUsing()as above, or put a resolver class in the config.
4. Automatic per-route inference (no configuration) — Martis looks at the current request path and inserts the matching resource label, dashboard name, or section, e.g.:
| URL | Title rendered |
|---|---|
/martis | Name of the first dashboard the user may see · brand (e.g. Operations · Acme) |
/martis/dashboards/support-team | That dashboard's name · brand (e.g. Team · Acme) |
/martis/resources/clients | Clients · Acme |
/martis/resources/clients/new | Create Client · Acme |
/martis/resources/clients/42 | Client · Acme |
/martis/resources/clients/42/edit | Edit Client · Acme |
/martis/profile | Profile · Acme |
Resource labels honour the authenticated user's locale preference, so the first paint is already in the right language.
A dashboard is named from the dashboards the signed-in user may see (v2.19.0): its name(), never a guess from its address (support-team used to read Support team for a dashboard called Team). A dashboard the user cannot see, or an unknown key, gets the default title.
For client-side navigation inside the SPA (react-router), each page uses the usePageTitle hook to keep document.title in sync without a full reload.
Resolution precedence (highest first):
Martis::pageTitleUsing(Closure)— registered at runtime.config('martis.brand.page_title')— literal string, invokable class name or[Class::class, 'staticMethod']array.- Automatic inference from the request path (resource label, dashboard name, profile).
__('martis::navigation.page_title_default', ['brand' => config('martis.brand.name')])— bundled fallback.
Customising the favicon
-
Drop the file into your app's
public/directory — any filename and extension are accepted (.ico,.png,.svg). Example:public/brand/favicon.ico. -
Point the config at it, relative to
public/:MARTIS_FAVICON=brand/favicon.icoOr set it directly in
config/martis.php:'brand' => ['favicon' => 'brand/favicon.ico',], -
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 with400 Bad Request.
Head tags (v2.6.0)
Beyond the favicon, the panel page can link a web app manifest and an Apple touch icon, and set the browser theme color. Each tag is emitted only when its value is set:
MARTIS_BRAND_MANIFEST=/brand/site.webmanifest # <link rel="manifest">MARTIS_BRAND_APPLE_TOUCH_ICON=/brand/apple-touch-icon.png # <link rel="apple-touch-icon">MARTIS_BRAND_THEME_COLOR="#0f172a" # <meta name="theme-color">Quote a hex color in .env: unquoted, # starts a comment and the value is empty, so no tag is emitted.
A path is resolved with asset() (relative to public/); a full https:// URL is used as written. The theme color is one value for both themes: Martis follows its own theme toggle, not the operating system's prefers-color-scheme, which a media-qualified theme-color would follow.
Footer
'footer' => [ 'enabled' => true, 'text' => env('MARTIS_FOOTER_TEXT'),],| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Set false to hide the footer entirely. |
text | ?string | null | Custom footer text. When null, the bundled translation renders (© {brand.name} · Powered by Martis). |
i18n trade-off.
MARTIS_FOOTER_TEXTis a single string that overrides every locale. For per-locale footer copy, leave the env var unset and publish the lang files withphp artisan vendor:publish --tag=martis-lang, then edit each locale file directly.
Dashboard greeting & welcome card
The dashboard renders TWO different welcome strings — make sure you edit the right one:
| Visible string | Translation key | Customisation |
|---|---|---|
Hello, {name} (greeting line) | martis::resources.hello | Lang files only (publish + edit) |
Welcome to Martis Admin Engine. (subtitle under the greeting) | martis::resources.welcome | Lang files only (no env var) |
Welcome to Martis (animated <WelcomeCard> heading, optional) | martis::resources.welcome_card_heading | MARTIS_WELCOME_HEADING env or lang files |
| Welcome card body | martis::resources.welcome_card_description | MARTIS_WELCOME_DESCRIPTION env or lang files |
The <WelcomeCard> is the animated hero shown when dashboard.showWelcomeCard !== false — it sits at the top of the dashboard and is what the env vars below control. The greeting line ("Hello, X" + "Welcome to Martis Admin Engine") is a separate simple text block; only lang files override it. Frequent confusion: setting MARTIS_WELCOME_HEADING does not change the greeting subtitle.
'welcome' => [ 'heading' => env('MARTIS_WELCOME_HEADING'), 'description' => env('MARTIS_WELCOME_DESCRIPTION'),],| Key | Type | Default | Description |
|---|---|---|---|
heading | ?string | null | Custom heading for the dashboard's animated <WelcomeCard>. When null, uses the bundled welcome_card_heading translation. |
description | ?string | null | Custom description shown below the heading. When null, uses the bundled welcome_card_description translation. |
Customising the greeting subtitle ("Welcome to Martis Admin Engine.")
Publish the lang files and edit the welcome key:
php artisan vendor:publish --tag=martis-lang --forceThen resources/lang/vendor/martis/{en,pt_BR,pt_PT}/resources.php:
<?phpreturn [ '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.)
- Prop passed at render-time (rare — only if the host overrides the React component itself).
welcome.heading/welcome.descriptionfromconfig/martis.php(env-driven).martis::resources.welcome_card_heading/welcome_card_descriptiontranslations (which honour your published lang overrides if any).
i18n trade-off.
MARTIS_WELCOME_HEADINGandMARTIS_WELCOME_DESCRIPTIONare single strings that override every locale. For per-locale copy, leave both env vars unset and publish the lang files. The two paths are mutually exclusive — env wins when set.
Example: branding the dashboard from .env
MARTIS_BRAND_NAME="EdgeFlow"MARTIS_BRAND_LOGO="/img/edgeflow-logo.svg"MARTIS_FAVICON="brand/favicon.ico"MARTIS_PAGE_TITLE="EdgeFlow"MARTIS_FOOTER_TEXT="© 2026 EdgeFlow. All rights reserved."MARTIS_WELCOME_HEADING="Welcome to EdgeFlow"MARTIS_WELCOME_DESCRIPTION="NQ/ES/YM regime intelligence for futures traders."No code changes, no vendor:publish. The SPA picks all of this up at boot via window.MartisConfig.
Layout
'layout' => [ 'preset' => env('MARTIS_LAYOUT', 'sidebar'), 'header_first' => (bool) env('MARTIS_LAYOUT_HEADER_FIRST', false), // v2.7.0 'components' => [ 'shell' => null, 'sidebar' => null, 'topbar' => null, 'footer' => null, 'topbar_start' => null, // v2.7.0 'topbar_end' => null, // v2.7.0 ],],Presets
| Preset | What it renders | When to pick it |
|---|---|---|
sidebar (default) | Left nav column, topbar, content, in-flow footer. The collapsible shell described in theming.md and overrides.md. | Most admin apps. Handles mobile drawer, collapsed rail, accent pill, density tokens. |
topnav | Horizontal top navigation (no sidebar). Topbar renders the full menu inline. | Apps with ≤ 8 top-level resources or a focus-mode where the left column wastes space. |
minimal | No chrome — just the route outlet. No topbar, no sidebar, no footer. | Embedded dashboards, marketing screens, print-friendly surfaces. |
custom | Strict registry-only mode: requires a component registered under layout:shell (or referenced by layout.components.shell). If none is registered, Martis renders a red error panel instead of silently falling back to sidebar — so a missing registration fails loudly during development. | Apps shipping their own shell that want to guarantee the bundled fallback is never used. |
Pick the preset via MARTIS_LAYOUT env or config('martis.layout.preset'). The string must match one of the four above (typos fall back to sidebar).
Header-first order (v2.7.0)
The sidebar preset renders, after its two skip links ("Skip to main content", "Skip to navigation"), the sidebar, then the top bar, then the main content, so the keyboard reaches the menu before the search, the notification bell and the user menu. Set MARTIS_LAYOUT_HEADER_FIRST=true (martis.layout.header_first) to render the top bar before the sidebar instead: the Tab order becomes skip links, top bar, menu, main content, the order of most application shells. The grid places each piece by its own row and column, so the page looks the same either way. The topnav preset already starts with its bar; the option does not apply to it, nor to a custom shell.
Piece-by-piece component overrides
layout.components lets the PHP config point each shell piece at a specific registry key. Each value is either null (use the bundled default) or a string matching a key registered via componentRegistry.register(...) in the frontend.
'layout' => [ 'preset' => 'sidebar', 'components' => [ 'shell' => null, // whole shell override; skips the grid + mobile drawer 'sidebar' => null, // only the left column 'topbar' => 'tenant-topbar', // custom topbar auto-discovered from `resources/js/martis-extensions/overrides/` 'footer' => 'tenant-footer', ],],Resolution order per piece: config.layout.components.<piece> → layout:<piece> (default registry key) → bundled component.
topbar_start and topbar_end (v2.7.0) are not pieces but slots: they add a component to the top bar instead of replacing it, and render nothing when nothing is registered. Their default registry keys are topbar:start (after the menu and collapse buttons, before the breadcrumbs and the search) and topbar:end (after the preferences menu, before the user menu). The sidebar and topnav presets both render them. On a phone (v2.14.0) they leave the bar: the sidebar preset renders them in its navigation drawer, the topnav preset at the end of its breadcrumb strip, and the component receives its placement. See Top-bar slots 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
'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 viashowMenuCount(): boolon theResourceclass.counts.report_failures(v1.32.4) — amenuCount()that throws never breaks the sidebar or the badges endpoint (that badge is skipped). When true, the failure is also sent throughreport()as aMartis\Exceptions\MenuCountFailedException(naming the Resource / Tool class, original exception asprevious) so it reaches your exception handler instead of vanishing. Set to false to go back to silent skipping. Withdev.tools_enabledon, the badges payload additionally lists failed counters under a reserved_failedkey. 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.NumberFormatcompact notation —10K,123.5K,1.2M,25M. - Default
10000keeps everyday counts readable while preventing badges from blowing up the sidebar at 50 K+. Set tonullto always show full digits,0to always compact, or any positive integer for a custom cutoff.
- Below threshold:
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 to0to 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/badgesis, on the newbadges_poll_interval. Apps that still setMARTIS_NAV_POLL_MSwill see the value silently ignored; rename toMARTIS_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-langShipped locales: en (English), pt_BR (Brazilian Portuguese), pt_PT (European Portuguese).
API Throttle
'throttle' => [ 'enabled' => env('MARTIS_THROTTLE_ENABLED', true), 'max_attempts' => (int) env('MARTIS_THROTTLE_MAX', 120), 'decay_minutes' => (int) env('MARTIS_THROTTLE_DECAY', 1), 'metrics_max_attempts' => env('MARTIS_THROTTLE_METRICS_MAX'), // null: metrics stay in the API bucket (v2.23.0) 'metrics_decay_minutes' => env('MARTIS_THROTTLE_METRICS_DECAY'), // null: decay_minutes],| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Set false to disable API rate limiting. |
max_attempts | int | 120 | Maximum requests per window, per signed-in user of the Martis guard, counted across the Martis API and every Tool's routes (v2.0). |
decay_minutes | int | 1 | Rate limit window in minutes. |
metrics_max_attempts | int|null | null | A bucket of its own for the metric and dashboard read routes (v2.23.0). null keeps them in the API bucket, as before. |
metrics_decay_minutes | int|null | null | Window of the metrics bucket in minutes; null takes decay_minutes. |
The limit is Laravel's throttle middleware with a key prefix, throttle:{max},{decay},martis-api:{guard}: (RouteMiddleware::throttlePrefix('api'), v2.0.1). Laravel keys a signed-in user's bucket on sha1() of the identifier alone, so without the prefix the Martis guard's user 5 shared a bucket with a site route's plain throttle for the site user 5 (an admins guard beside the site's users), or for the same person. The verification email resend (martis-verification:{guard}:) keeps its own limit in its own bucket: before v2.0.1 it counted in the API's, so a resend answered 429 after three API requests in the same minute. The 2FA challenge has a named limiter of its own since v2.4.0 (martis-2fa-challenge, see 2FA challenge throttle), with the Martis guard in its keys.
A bucket for the metrics (v2.23.0). A dashboard of many cards computes one request per card and refreshes them on a timer, so it can use up the API bucket and make the rest of the panel answer 429. Set MARTIS_THROTTLE_METRICS_MAX (and, if the window differs, MARTIS_THROTTLE_METRICS_DECAY) and the routes that read a dashboard or compute a metric (GET /api/dashboards, /api/dashboards/{dashboard}, /api/dashboards/{dashboard}/cards/{card} and /api/resources/{resource}/cards/{card}) count against throttle:{max},{decay},martis-metrics:{guard}: (RouteMiddleware::throttlePrefix('metrics')) and only against it: they leave the API throttle, so a metric read never also counts in the API bucket, and the API bucket never sees them. Every other route keeps the API bucket. Unset (the default) changes nothing. MARTIS_THROTTLE_ENABLED=false turns both buckets off. An empty variable (MARTIS_THROTTLE_METRICS_MAX=) is read as unset, like every other empty variable of the package; a value that is set and is not a positive whole number (0, -5, lots) throws an InvalidArgumentException naming the key when the routes are registered.
Theme
'theme' => [ 'default' => env('MARTIS_THEME', 'dark'), 'allowToggle' => true, 'name' => env('MARTIS_THEME_NAME', null),],| Key | Type | Default | Description |
|---|---|---|---|
default | string | 'dark' | Default theme: 'dark' or 'light'. |
allowToggle | bool | true | When false, the theme picker is hidden everywhere — the Theme section disappears from the preferences overlay and the theme cycle button is suppressed on every pre-login surface (login, register, 2FA, password reset). Use this to lock the entire shell to a single theme without removing the rest of the preferences. Since v2.12.0 it is the same as leaving theme out of preferences.editable: the server ignores a stored theme and refuses a theme change, and everyone gets preferences.defaults.theme (MARTIS_DEFAULT_THEME). |
name | ?string | null | Active custom theme. The panel loads public/vendor/martis/themes/<name>.css, which martis:publish-assets publishes from resources/css/martis/<name>.css. null keeps the built-in theme. |
Custom themes are scaffolded via php artisan martis:theme, which writes the source and sets name. See Theming → Theme files.
Keyboard Shortcuts
'keyboard_shortcuts' => [ 'enabled' => env('MARTIS_KEYBOARD_SHORTCUTS_ENABLED', true), 'helpOverlay' => env('MARTIS_KEYBOARD_SHORTCUTS_HELP_OVERLAY', true),],| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Master switch for the entire keyboard-shortcuts registry. When false, every addShortcut() call returns a no-op disposer; no event listener is bound and the bundled mod+k, /, and shift+? combos do nothing. Use it when the host app ships a custom keyboard layer. |
helpOverlay | bool | true | When false, the bundled Shift+? help overlay is not registered. addShortcut() itself stays live for every other combo, so this is the right knob if the host app wants to expose its own help UI instead of the dialog. |
Both flags are read live, so mid-session toggles via window.MartisConfig take effect on the next registration. See Keyboard Shortcuts for the full API.
User Menu
'user_menu' => [ 'showThemeToggle' => true, 'showProfile' => env('MARTIS_SHOW_PROFILE_MENU', true), // 'customItems' => [],],| Key | Type | Default | Description |
|---|---|---|---|
showThemeToggle | bool | true | Show dark/light mode toggle. |
showProfile | bool | true | Show profile page link. Override with MARTIS_SHOW_PROFILE_MENU=false. |
customItems | ?array | null | Array of custom menu items (see example below). |
The notifications bell next to the profile is not a
user_menuoption — it is controlled by thenotifications.enabledmaster switch (see the Notifications section). SetMARTIS_NOTIFICATIONS_ENABLED=falseto 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'],],Global Search
'search' => [ 'enabled' => true, 'placeholder' => null, 'mode' => env('MARTIS_SEARCH_MODE', 'bar'), 'mobileMode' => env('MARTIS_SEARCH_MOBILE_MODE', 'icon'),],| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable/disable global search. |
placeholder | ?string | null | Custom placeholder text (null = i18n default). |
mode | string | 'bar' | Desktop mode: 'bar', 'icon', or 'disabled'. |
mobileMode | string | 'icon' | Mobile mode: 'bar', 'icon', or 'disabled'. |
Dashboard
'dashboard' => [ 'showGreeting' => env('MARTIS_DASHBOARD_SHOW_GREETING', true), 'showWelcome' => env('MARTIS_DASHBOARD_SHOW_WELCOME', true), 'showWelcomeCard' => env('MARTIS_DASHBOARD_SHOW_WELCOME_CARD', true), 'showMetrics' => true, 'showResourceCards' => true,],| Key | Type | Default | Description |
|---|---|---|---|
showGreeting | bool | true | Show personalised greeting (Hello, {name}). Override with MARTIS_DASHBOARD_SHOW_GREETING=false. |
showWelcome | bool | true | Show welcome subtitle (Welcome to Martis Admin Engine.) below the greeting. Override with MARTIS_DASHBOARD_SHOW_WELCOME=false. |
showWelcomeCard | bool | true | Show the animated WelcomeCard hero at the top of the dashboard. Override with MARTIS_DASHBOARD_SHOW_WELCOME_CARD=false. |
showMetrics | bool | true | Show summary metrics row (total resources, groups). |
showResourceCards | bool | true | Show resource quick-access cards grid. |
Toast Notifications
'toast' => [ 'position' => env('MARTIS_TOAST_POSITION', 'bottom-right'), 'durations' => [ 'success' => 5000, 'info' => 5000, 'warning' => 5000, 'error' => 5000, ],],Options for position: 'top-right', 'top-left', 'bottom-right', 'bottom-left', 'top-center', 'bottom-center'.
durations (v2.21.0) is how long a toast stays, per severity, in milliseconds. null keeps the toast until the person closes it (every toast has a close button, reachable by keyboard). The defaults match the behaviour before v2.21.0. A toast with a safe link stays at least 10000 ms, unless its duration is null. A value that is neither null nor a positive integer of at most 2147483647 (the longest delay a browser timer holds; a longer one makes the toast vanish at once) throws an InvalidArgumentException naming the key, for example martis.toast.durations.error. A string of digits is accepted as that integer, which is what env() yields ('error' => env('MARTIS_TOAST_ERROR_MS') with 8000 in .env). A sticky severity (null) has no cap: a toast raised in a loop (a retry or a poll that fails every few seconds) stacks without bound until the person closes them, so keep null for severities that fire rarely.
// Errors stay until closed, successes go after 3 seconds.'durations' => ['success' => 3000, 'error' => null],A single call overrides the config: see addToast options in Components.
Index (Resource Listing)
'index' => [ 'default_row_actions' => [ 'enabled' => env('MARTIS_DEFAULT_ROW_ACTIONS', true), 'view' => env('MARTIS_DEFAULT_ROW_ACTION_VIEW', true), 'edit' => env('MARTIS_DEFAULT_ROW_ACTION_EDIT', true), 'delete' => env('MARTIS_DEFAULT_ROW_ACTION_DELETE', true), ], 'row_click_opens_detail' => env('MARTIS_ROW_CLICK_OPENS_DETAIL', true), 'default_trashed_filter' => env('MARTIS_DEFAULT_TRASHED_FILTER', 'active'),],| Key | Type | Default | Description |
|---|---|---|---|
default_row_actions.enabled | bool | true | Master switch for the default row actions column (view/edit/delete and restore/force-delete on soft-delete models). When false, Martis never renders the column. |
default_row_actions.view | bool | true | Per-action global kill-switch for the View icon. Flip to false to hide it across every resource. MARTIS_DEFAULT_ROW_ACTION_VIEW. |
default_row_actions.edit | bool | true | Per-action global kill-switch for the Edit icon. MARTIS_DEFAULT_ROW_ACTION_EDIT. |
default_row_actions.delete | bool | true | Per-action global kill-switch for the Delete icon. MARTIS_DEFAULT_ROW_ACTION_DELETE. |
row_click_opens_detail | bool | true | When default row actions expose a "view" icon, clicking the row body becomes redundant. Set to false to disable row-click and keep the row informational. Override per resource with rowClickOpensDetail(Request $request): ?bool. |
default_trashed_filter | string | 'active' | Initial state of the trashed-filter dropdown on soft-delete resources (main index and relationship panels). One of 'active' (hide deleted), 'with' (include deleted alongside live), 'only' (only deleted). Visibility of the dropdown itself is gated by Resource::canViewTrashed(). |
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,],| Key | Type | Default | Description |
|---|---|---|---|
default_per_page | int | 25 | Fallback for Resource::perPage() and Lens::perPage() when the resource/lens does not override the method. |
max_per_page | int | 100 | Upper bound enforced by the ResourceController when the client passes ?perPage=N on the URL. Protects against pathological page sizes. |
Override per-resource via perPage() and perPageOptions() on the resource class. When the returned perPage() is not present in perPageOptions(), Martis silently clamps to perPageOptions()[0] — see resources.md — Effective per-page.
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:
- A non-empty string is used verbatim.
null: the namespace is derived from Composer's PSR-4 autoload map (every registered class loader, so rootcomposer.jsonentries, merge plugins and runtimeaddPsr4()registrations all count).app/Martisunder"App\\": "app/"givesApp\Martis;app/Martis/CustomergivesApp\Martis\Customer;modules/Billing/Martisunder"Modules\\": "modules/"givesModules\Billing\Martis. The longest matching root wins.- No PSR-4 root contains the directory:
App\Martis, the historical convention, so a non-autoloaded folder keeps working through the discovery's ownrequire_oncefallback.
Set the key explicitly when the directory is not autoloaded or when you want to pin the value regardless of the autoload map. A config published before v1.36.0 has no resources_namespace key, which behaves like null.
Policy Namespace
'policy_namespace' => 'App\\Martis\\Policies',Namespace for auto-discovery of resource policies. When a resource does not define an explicit $policy property, Martis looks for {policy_namespace}\{ResourceBaseName}Policy.
Tools Auto-Discovery
'tools_path' => app_path('Martis/Tools'),'tools_namespace' => null,'discovery' => [ 'tools' => env('MARTIS_DISCOVERY_TOOLS', true),],Where Martis scans for Martis\Tools\Tool subclasses (since v1.8.20). tools_namespace follows the same precedence as resources_namespace (explicit string, then Composer's PSR-4 map, then App\Martis\Tools); a config published before v1.36.0 still carries the literal 'App\\Martis\\Tools', which keeps working. Discovery merges with any Martis::tools([...]) registration via dedup by class-string, so adopting it does not break apps that already registered Tools manually. Set discovery.tools to false (or MARTIS_DISCOVERY_TOOLS=false) to opt out and keep manual registration.
Extension bundles: versioned URLs and stale chunks (v2.20.0)
// config/martis.php'extensions_versioned_urls' => (bool) env('MARTIS_EXTENSIONS_VERSIONED_URLS', true),'extensions_reload_on_stale' => (bool) env('MARTIS_EXTENSIONS_RELOAD_ON_STALE', true),| Key | Default | Description |
|---|---|---|
extensions_versioned_urls | true | A root-relative URL in extensions that is a file under public/ is emitted as /vendor/martis-user/extensions.js?v=<12 hex of the content hash>. The URL changes only when the file does, so the bundle can be cached for a year (immutable). Absolute http(s):// and protocol-relative URLs, URLs that already have a query string or a fragment, and paths of missing files are emitted as configured. false emits every URL exactly as listed. |
extensions_reload_on_stale | true | A lazily loaded extension chunk (componentRegistry.registerLazy) that cannot be fetched because a deploy replaced it reloads the tab once, then shows an error state. false never reloads. Surfaced to the SPA as window.MartisConfig.extensionsReloadOnStale. |
See Extensions: caching 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],| Key | Type | Default | Description |
|---|---|---|---|
allowed_mimes | array | See config | Allowed extensions for Trix/Markdown file uploads (MARTIS_ATTACHMENT_MIMES, comma-separated). |
max_size | int | 10240 | Maximum file size in KB (MARTIS_ATTACHMENT_MAX_SIZE). |
throttle_max | int | 20 | Uploads one user may make per throttle_decay minutes, on top of the API limit (MARTIS_ATTACHMENT_THROTTLE_MAX, v2.4.0). Not applied when throttle.enabled is false. |
throttle_decay | int | 1 | The window of throttle_max in minutes (MARTIS_ATTACHMENT_THROTTLE_DECAY, v2.4.0). |
An upload names the resource, the field and (editing) the record, is authorised like the form it comes from, and goes to the disk the field declares with withFiles($disk) (the storage.disk above without one), never to a disk the request names; see Resources → Attachment Uploads. The allowed_disks key of earlier versions is no longer read: the request chose the disk from that list, and it does not choose any more. php artisan martis:attachments:prune deletes the uploaded files no record references.
Action Events (Audit Log)
'action_events' => [ 'enabled' => true, 'resource' => true,],| Key | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Record action events to the database. |
resource | bool | true | Register ActionEvent as a browsable resource in the sidebar. |
Individual actions can opt out via ->withoutActionEvents().
The registered resource is closed until you define the view-martis-action-events gate (or an ActionEvent policy), v2.0.1+: see Actions → Who can read the audit log.
Code-side registrations: app/Providers/MartisServiceProvider.php
config/martis.php cannot hold closures — Laravel's config:cache fails to serialize them. So Martis splits configuration into two layers:
| Layer | What it holds | File |
|---|---|---|
| Static config | Paths, throttle, theme, profile, cache TTLs, drawer widths, sticky-views scope, notifications poll interval, … | config/martis.php |
| Code registrations | Main menu resolver, dashboards, custom cache layers, gate definitions, page-title closures | app/Providers/MartisServiceProvider.php |
martis:install publishes the provider stub to app/Providers/MartisServiceProvider.php and wires it into bootstrap/providers.php automatically, in the file's own style (with a use import when the file imports its providers). A file whose providers array the installer cannot find gets a warning with the line to add by hand. Re-running martis:install is idempotent — the file is preserved and the bootstrap entry is not duplicated. --force does not touch it: use --force-provider to refresh the stub (this overwrites your registered dashboards, menu and gates).
The stub ships every section commented-out, so an unmodified provider registers nothing and Martis runs on its built-in defaults. Uncomment what you need:
// app/Providers/MartisServiceProvider.php protected function registerMainMenu(): void{ // Layout-agnostic — feeds sidebar / topnav / minimal presets equally. Martis::mainMenu(function ($request, $menu) { return $menu->sections([ MenuSection::make('Operations', [ MenuItem::resource(\App\Martis\Resources\ClientResource::class), ])->icon('briefcase'), ]); });} protected function registerDashboards(): void{ Martis::dashboards([ \App\Martis\Dashboards\OperationsDashboard::class, ]);} protected function registerCacheLayers(): void{ MartisCache::extend('orders', enabled: true, ttl: 30);} protected function registerGates(): void{ Gate::define('manage-martis-cache', fn ($user) => $user->is_admin);}Why a dedicated provider instead of AppServiceProvider?
- Separation of concerns — Martis bootstrap stays out of host-app providers.
- Self-documenting — section names (
registerMainMenu,registerDashboards,registerCacheLayers,registerGates) act as a catalogue of every code-side hook Martis exposes. - Easier upgrades — when Martis adds a new registration point, the stub gains a section;
AppServiceProvideris never touched. - Mirrors the dedicated-provider pattern other admin engines use.
To republish the stub manually (e.g. after package upgrade adds a new section): php artisan vendor:publish --tag=martis-provider --force.
Config keys that take a callable
Six keys in config/martis.php take a callable instead of a value:
| Key | Called with | Returns |
|---|---|---|
brand.page_title | Request $request | The <title> (?string). A plain string is the title itself. |
gates.plan_resolver | ?Authenticatable $user | The user's plan (?string). See Soft-gates. |
profile.avatar.url_resolver | string $storedPath | The avatar's public URL (string). |
auth.sso.providers.{provider}.role_source_callable | string $externalId, string $accessToken | The external role names (array<int, string>). See SSO. |
auth.sso.providers.{provider}.role_callable | array $externalRoles, ?User $user, string $provider | The local roles (Collection). See SSO. |
auth.sso.issuer.claims | Authenticatable $user, string $audience | The claims to add to, or replace in, the signed SSO token (array<string, mixed>, v2.21.0). See SSO. |
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 | systemMARTIS_DEFAULT_ACCENT=martis # martis | blue | teal | violet | amber | <custom name>MARTIS_DEFAULT_DENSITY=comfortable # comfortable | denseMARTIS_DEFAULT_LOCALE=en # one of MARTIS_UI_LOCALES (else the first listed)MARTIS_UI_LOCALES=en,pt_PT,pt_BR # locales the language picker offers (v2.10.0)MARTIS_UI_LOCALE_LABELS="en_GB:English (UK)" # code:label pairs (v2.10.0)Invalid values fall through to the safe defaults — a typo in .env never crashes the request.
Custom accent colours
The accent swatches in the PreferencesMenu can be extended with arbitrary brand colours via a single env var:
MARTIS_CUSTOM_ACCENTS="edgeflow:#1a73e8,sunset:#ff6b35,emerald:#10b981"Format: comma-separated name:hex pairs, each with an optional third segment name:hex:contrastHex naming the text / icon colour painted on top of that accent (--martis-accent-contrast). Whitespace around the separators is tolerated.
# Bright lime with an explicit navy text colour; the others derive theirs.MARTIS_CUSTOM_ACCENTS="edgeflow:#1a73e8,lime:#c6f135:#071726"Validation rules (invalid entries are dropped silently with a Log::warning):
| Field | Pattern / rule |
|---|---|
name | [a-z][a-z0-9_-]{0,31} — lowercase, alphanumeric + dash / underscore. Must NOT collide with a bundled enum value (martis, blue, teal, violet, amber, custom). |
hex | #RRGGBB (6-digit hex with leading #). |
contrastHex | Optional. #RRGGBB. When absent Martis derives it from the accent's luminance: white while white reaches the WCAG 3:1 floor against the accent, a near-black navy (#0b1220) below it. An invalid value drops the whole entry. |
| Duplicates | Last-wins (env-override semantics). |
| Limit | Up to 24 custom accents. Beyond that the parser truncates. |
Each registered accent:
- Adds an extra swatch at the end of the PreferencesMenu accent picker.
- Becomes a valid value for
MARTIS_DEFAULT_ACCENT(so a new user lands on it without having to click). - Becomes a valid value for the user's persisted
accentpreference 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 off | Routes not registered | Also |
|---|---|---|
avatar.enabled (MARTIS_AVATAR_ENABLED) | POST and DELETE /api/profile/avatar | The profile payload carries no avatar_url. |
two_factor.enabled (MARTIS_2FA_ENABLED) | POST /api/profile/2fa/setup, /confirm, /recovery-codes, DELETE /api/profile/2fa, POST /api/2fa/challenge | Nobody meets the 2FA challenge at sign-in, an account enrolled earlier included: its secret stays in the database, unused until the switch is on again. |
account.email_editable (MARTIS_PROFILE_EMAIL_EDITABLE) | none | PATCH /api/profile with another address answers 422 and saves and mails nothing; the name still saves. A confirmation link mailed earlier is refused as invalid. |
enabled (MARTIS_PROFILE_ENABLED) still removes the whole profile, name and password change included. sections only orders and shows the parts of the page: leaving one out does not remove its routes.
With php artisan route:cache, cache the routes again after you change a switch (php artisan optimize caches the config and the routes together). The controllers check the avatar and 2FA switches too, so a route cache built while a switch was on still answers 404 once it is off. The other way round is not covered: with routes cached while 2FA was off, turning it on challenges enrolled users at a POST /api/2fa/challenge the cache does not hold, and they cannot sign in until the routes are cached again.
See Authentication for full profile documentation.
User Preferences
'preferences' => [ 'enabled' => env('MARTIS_PREFERENCES_ENABLED', true), 'allowBrandColor' => env('MARTIS_ALLOW_BRAND_COLOR', false), 'locales' => array_values(array_filter(array_map('trim', explode(',', (string) env('MARTIS_UI_LOCALES', 'en,pt_PT,pt_BR'))))), 'locale_labels' => [ 'en' => 'English', 'en_GB' => 'English (UK)', 'pt_PT' => 'Português (PT)', 'pt_BR' => 'Português (BR)', ], 'locale_labels_env' => env('MARTIS_UI_LOCALE_LABELS'), 'editable' => array_values(array_filter(array_map('trim', explode(',', (string) env('MARTIS_PREFERENCES_EDITABLE', 'theme,accent,density,locale,reducedMotion'))))),],| Key | Type | Default | Effect |
|---|---|---|---|
enabled | bool | true | Master switch for the per-user preferences panel (theme, density, locale, accent). |
allowBrandColor | bool | false | When true, and accent is in editable, exposes a custom-hex brand colour picker in the preferences panel. Applied by the server too (v2.12.0): with it off, a hex is refused by PUT /api/preferences and a stored one is ignored. |
editable | array | all five | The preferences users may change (v2.12.0: MARTIS_PREFERENCES_EDITABLE, comma-separated, any of theme, accent, density, locale, reducedMotion). One left out is hidden from the menu and the sign-in pages, refused by PUT /api/preferences (422) when the request changes it, ignored in a stored row and a URL preset, and resolves to its default. theme.allowToggle => false drops theme; the brand-colour hex follows accent. An unknown name throws an InvalidArgumentException naming the variable; unset or empty means all five, none locks every one; set with a published config that lacks the key, it throws. See Preferences → Choosing which preferences users may change. |
locales | array | ['en','pt_PT','pt_BR'] | The exact locale codes the language picker offers, on both the login screen and the in-app Preferences panel (v2.10.0: from MARTIS_UI_LOCALES, comma-separated, e.g. MARTIS_UI_LOCALES=en_GB,pt_PT). A code must look like a locale (en, en_GB, pt-BR); an invalid one throws an InvalidArgumentException naming MARTIS_UI_LOCALES. A code without bundled strings (en_GB) translates through locales.fallback_chain. When empty, the three bundled locales apply. A saved, preset or default locale outside the list falls back to defaults.locale, or to the first listed code (Preferences). |
locale_labels | array | en, en_GB, pt_PT, pt_BR labels | The human-readable labels surfaced in the locale dropdown. A locale without a label shows its code. |
locale_labels_env | string|null | null | MARTIS_UI_LOCALE_LABELS (v2.10.0): comma-separated code:label pairs merged over locale_labels, the env entries winning, e.g. MARTIS_UI_LOCALE_LABELS="en_GB:English (UK),pt_PT:Português (Portugal)". The first colon splits, so a label may contain colons but not commas. A malformed segment throws an InvalidArgumentException naming MARTIS_UI_LOCALE_LABELS; the value is parsed where it is read, so artisan keeps working while you fix it. |
See User Preferences 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, ],],| Key | Type | Default | Effect |
|---|---|---|---|
enabled | bool | true | Master switch. Falsey disables the entire feature. |
scope | enum | session | Where state lives: session (sessionStorage, wipes on tab close) or local (localStorage, survives). |
persist.filters | bool | true | Remember filter values per resource. |
persist.sorting | bool | true | Remember sort column + direction. |
persist.pagination | bool | true | Remember current page. |
persist.per_page | bool | true | Remember per-page selector. |
persist.columns | bool | true | Remember visible columns toggle. |
persist.scroll | bool | false | Remember scroll position on the index. |
See Sticky Views for the full behaviour spec.
Loader
'loader' => [ 'logo' => env('MARTIS_LOADER_LOGO'), // v2.6.0 'logoDark' => env('MARTIS_LOADER_LOGO_DARK'), // v2.6.0 'disabled' => env('MARTIS_LOADER_DISABLED', false),],| Key | Type | Default | Effect |
|---|---|---|---|
logo / logoDark | ?string | null | Image shown in place of the spinner, with its dark theme variant (if only one is set it serves both themes). Path or URL. |
disabled | bool | false | Globally suppress the page loader. Useful in tests or for apps that ship their own loading UI. |
See Loader for the surface-by-surface behaviour matrix.
Impersonation
'impersonation' => [ 'enabled' => env('MARTIS_IMPERSONATION_ENABLED', false), 'guard' => env('MARTIS_IMPERSONATION_GUARD'), // null: the Martis guard 'session_key' => env('MARTIS_IMPERSONATION_SESSION_KEY', 'martis.impersonation'),],| Key | Type | Default | Effect |
|---|---|---|---|
enabled | bool | false | Master switch. Default OFF — opt-in. When false, the impersonation REST endpoints return 503 and the banner short-circuits its mount-time fetch. |
guard | string | web | Auth guard the impersonation operates on. |
session_key | string | martis.impersonation | Session bag where the operator's id is stashed. Change for cross-tenant isolation. |
The package ships no default Gate. Define martis-impersonate in your AuthServiceProvider to gate which users can start an impersonation session. See Impersonation.
Audit (v1.8.8)
The package can record three categories of administrative events into the martis_action_events table.
'audit' => [ 'role_changes' => env('MARTIS_AUDIT_ROLE_CHANGES', true), 'impersonation' => env('MARTIS_AUDIT_IMPERSONATION', true), 'authz_denials' => env('MARTIS_AUDIT_AUTHZ_DENIALS', false), 'authz_denials_include_viewany' => env('MARTIS_AUDIT_AUTHZ_DENIALS_INCLUDE_VIEWANY', false),],| Key | Default | Effect |
|---|---|---|
role_changes | true | Logs role.attached / role.detached rows whenever Spatie attaches or detaches a role. Spatie fires those events only with events_enabled set to true in config/permission.php (off by default); see Roles: audit log. |
impersonation | true | Logs impersonation.started / impersonation.stopped. |
authz_denials | false | Records denied gate decisions as authz.denied, while the Martis guard is the request's guard. Off by default: turning it on can be noisy on a busy app. |
authz_denials_include_viewany | false | When authz_denials is on, also record viewAny denials. Off by default because index pages probe viewAny on every request. |
The denial listener dedupes the same (ability, model_class, model_id) tuple within one request, so a sidebar that probes the same gate three times only emits one row.
Authorization tuning (v1.8.8)
'authz' => [ 'request_cache' => env('MARTIS_AUTHZ_REQUEST_CACHE', false), 'revoke_sessions_on_demote' => env('MARTIS_AUTHZ_REVOKE_SESSIONS_ON_DEMOTE', false),],| Key | Default | Effect |
|---|---|---|
request_cache | false | Memoises (user, ability, every argument) gate results for the current request (each model by class and key, so an attach{Model}($user, $parent, $related) ability keeps one answer per pair). Wins when a single request evaluates the same gate from many surfaces (sidebar, schema authorization block, action visibility). Per-request only — never crosses request boundaries. A call with an argument that cannot be keyed (a model with no key, an array, a closure) is skipped. |
revoke_sessions_on_demote | false | When a role or a permission is detached from a user, or a permission from a role (the users who hold it, v2.4.0), force-logs out their existing browser sessions, the request's own one excepted. Useful when promoting/demoting between admin tiers. Skipped, with a warning, when the session guards sign in users of more than one table (a custom MARTIS_GUARD with its own model): the session rows cannot be told apart by id. See Authorization → Revoke sessions on demote. |
Magic-link sign-in (v1.8.8)
Passwordless login via signed email links.
'auth' => [ 'magic_link' => [ 'enabled' => env('MARTIS_AUTH_MAGIC_LINK_ENABLED', false), 'ttl_minutes' => (int) env('MARTIS_AUTH_MAGIC_LINK_TTL', 15), 'auto_register' => env('MARTIS_AUTH_MAGIC_LINK_AUTO_REGISTER', false), ],],| Key | Default | Effect |
|---|---|---|
enabled | false | Master switch. When on, the login page exposes a "Send magic link" form. |
ttl_minutes | 15 | Validity window of the signed link. |
auto_register | false | When on, an unknown email triggers a fresh user creation. Combine with a custom user model that handles defaults. |
Email verification
'auth' => [ 'email_verification' => [ 'enabled' => env('MARTIS_AUTH_EMAIL_VERIFICATION_ENABLED', false), 'notice_url' => env('MARTIS_AUTH_EMAIL_VERIFICATION_NOTICE_URL'), ],],When enabled, the package wires Laravel's verified middleware into the panel guards and exposes the verification notice page. notice_url lets you redirect to a custom page if you don't want the bundled one.
Forced password change
'auth' => [ 'password_change' => [ 'enabled' => env('MARTIS_AUTH_PASSWORD_CHANGE_ENABLED', false), 'column' => env('MARTIS_AUTH_PASSWORD_CHANGE_COLUMN', 'must_change_password'), 'url' => env('MARTIS_AUTH_PASSWORD_CHANGE_URL'), ],],When enabled, a user flagged by the column (or by the Martis\Contracts\MustChangePassword contract) is held on the change page until they choose a new password; url sends them to a page of your own instead. See Authentication → Forced password change.
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.
| Variable | Default | Surface |
|---|---|---|
MARTIS_AUTH_LOGIN_TITLE | translation | Login title |
MARTIS_AUTH_LOGIN_SUBTITLE | translation | Login subtitle (password mode) |
MARTIS_AUTH_LOGIN_SUBTITLE_SSO | translation | Login subtitle (SSO-only mode) |
MARTIS_AUTH_REGISTER_TITLE | translation | Register title |
MARTIS_AUTH_REGISTER_SUBTITLE | translation | Register subtitle |
MARTIS_AUTH_FORGOT_TITLE | translation | Forgot password title |
MARTIS_AUTH_FORGOT_SUBTITLE | translation | Forgot password subtitle |
MARTIS_AUTH_RESET_TITLE | translation | Reset password title |
MARTIS_AUTH_RESET_SUBTITLE | translation | Reset password subtitle |
MARTIS_AUTH_INVITATION_ACCEPT_TITLE | translation | Invitation accept title |
MARTIS_AUTH_INVITATION_ACCEPT_SUBTITLE | translation | Invitation accept subtitle |
Translations remain the recommended path for multi-locale apps. Use these env vars only when you want a single hard-coded string for every locale (e.g. an internal tool).
Auth controls
'auth' => [ 'controls' => [ 'theme' => env('MARTIS_AUTH_CONTROL_THEME', true), 'locale' => env('MARTIS_AUTH_CONTROL_LOCALE', true), ],],Toggles the theme switcher and locale picker that float on the auth pages.
Session guards (v2.23.0)
'auth' => [ 'session_guards' => null, // or a list of guard names, e.g. ['site']],| Key | Type | Default | Description |
|---|---|---|---|
auth.session_guards | array<string>|null | null | The guards, besides the Martis guard, that sign in people and write Laravel's sessions.user_id. null (default): every session guard of config/auth.php and the app's default guard count, as before. A list: only the Martis guard and the guards named there count. [] means the Martis guard only. The Martis guard is always counted. A value that is not a list of names, a name that is not in auth.guards, or a name whose guard does not use the session driver (a token or custom-driver guard never writes the column) throws an InvalidArgumentException naming the key, the first time a revocation, a demotion sweep or the profile reads it (it is not read at boot). |
Why it exists: when the guards that write the column sign in users of more than one table, one id can be two people, so Martis neither deletes the session rows (SSO revokeFor(), authz.revoke_sessions_on_demote) nor lists them (the profile's Browser sessions) and logs a warning. Laravel merges its own web guard, on the users provider, into every application's auth.guards, so an admin-only app whose people live in another table reads as ambiguous although nothing signs in through web. Declare the guards that really write the column to lift that.
The list must be complete. A guard that signs in people but is left out of it can make Martis delete, or list in the profile, the session of another person who has the same id in another table. That is why the default stays null: the risk moves to the application only by an explicit setting. The tables the guards read are still compared by connection and table, so guards that share one table never count as ambiguous. See SSO → revocation 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) ],],| Key | Type | Default | Description |
|---|---|---|---|
auth.password_login.enabled | bool | true | false turns email and password sign-in off for an SSO-only panel: POST /login and POST /api/auth/login answer 403 for every address before the controller reads a credential, the password reset flow is off with it, and the login page shows the SSO buttons only (?password_login=1 still shows the form when break_glass_emails is not empty, v2.23.0). An empty string (MARTIS_PASSWORD_LOGIN_ENABLED=) is "unset": on. Anything else that is not a boolean or a boolean string throws, naming the key, on a sign-in request or the login page (the provider boot stays up). Turning it off does not end existing sessions or remember-me cookies. |
auth.password_login.break_glass_emails | array<string> | [] | Addresses (trimmed, case-insensitive) that may still sign in with a password while it is off; the account is looked up by the trimmed, lower-cased address, so store it that way. A failed break-glass sign-in answers with the same 403 as any other address. Never published to the browser: only whether it holds an address (passwordLogin.breakGlass, v2.23.0), which is what lets the login page open the form on ?password_login=1. A value that is not an array of strings throws, naming the key. |
Magic-link sign-in, invitations and registration keep their own switches: an SSO-only panel turns them off. One warning a day (with a persistent cache store) is logged when password sign-in is off and no SSO provider is enabled and magic link is off. See Authentication → Password sign-in switch.
Challenge (captcha) hook (v2.21.0)
'auth' => [ 'challenge' => [ 'enabled' => env('MARTIS_AUTH_CHALLENGE_ENABLED', false), 'verifier' => env('MARTIS_AUTH_CHALLENGE_VERIFIER'), 'site_key' => env('MARTIS_AUTH_CHALLENGE_SITE_KEY'), 'applies_to' => ['login', 'forgot', 'magic_link', 'register'], ],],| Key | Type | Default | Description |
|---|---|---|---|
auth.challenge.enabled | bool | false | Turns the hook on. An empty string (MARTIS_AUTH_CHALLENGE_ENABLED=) is "unset": off. |
auth.challenge.verifier | ?class-string | null | A class implementing Martis\Contracts\ChallengeVerifier, resolved from the container. Required while enabled; never published. |
auth.challenge.site_key | ?string | null | Public key, published as window.MartisConfig.auth.challenge.siteKey for the auth:challenge component. |
auth.challenge.applies_to | array<string> | ['login', 'forgot', 'magic_link', 'register'] | Surfaces covered. Allowed: login, forgot, reset, magic_link, register, invitation. |
A POST to a covered route without a token the verifier accepts answers 422 errors.challenge_token, before its controller (a flow that is switched off answers its own 404 or 503 and the verifier is not called); a verifier that throws refuses too (fail closed). An enabled hook with a missing, unknown or wrong-type verifier, or an unknown name in applies_to, throws naming the key. The auth entry the page publishes is an allow-list since v2.21.0. See Authentication → Challenge (captcha) hook, 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):
| Key | Default | Effect |
|---|---|---|
two_factor_attempts | 5 | Requests per user per two_factor_minutes: the account being guessed at. Past it the route answers 429, without checking the code. |
two_factor_ip_attempts | 15 | Requests per IP per two_factor_minutes: one machine guessing at many accounts. Higher than the per-user limit so a team behind one NAT signing in together is not stopped. |
two_factor_minutes | 1 | The window of both limits. |
two_factor_lockout_attempts | 5 | Consecutive wrong codes (TOTP or recovery) that lock the user out. 0 turns the lockout off. |
two_factor_lockout_minutes | 15 | How long the lockout lasts. |
The lockout is what sets a ceiling on guessing for someone who holds the user's password and can sign in again and again: see Authentication → The 2FA challenge is rate limited and locks out.
Impersonation extras
In addition to MARTIS_IMPERSONATION_ENABLED (covered above), the impersonation subsystem exposes:
| Variable | Default | Effect |
|---|---|---|
MARTIS_IMPERSONATION_GUARD | the Martis guard | Auth guard the impersonation operates on (v2.0.0+: unset follows MARTIS_GUARD, then the app's default guard; it was web). |
MARTIS_IMPERSONATION_SESSION_KEY | martis.impersonation | Session bag where the operator's id is stashed. |
MARTIS_IMPERSONATION_MAX_DURATION | 0 | Maximum session length in minutes. 0 disables the timeout. |
MARTIS_IMPERSONATION_POLL_MS | 120000 | Banner status poll interval in ms. Default 2 min — sessions change rarely. Set to 0 to disable polling (banner still mounts and reads state once per page load). v1.8.8. |
API docs (Scramble)
'api_docs' => [ 'enabled' => env('MARTIS_API_DOCS_ENABLED', false), 'path' => env('MARTIS_API_DOCS_PATH', 'api-docs'),],Off by default. When enabled, mounts the Scramble-generated OpenAPI 3.1 surface at /{path} (default /api-docs). api_docs.middleware defaults to null (v2.4.0), the Martis protected stack (Martis guard, 2FA, email verification, viewMartis, forced password change). A list you set is kept and completed with the guards it leaves out: the plain auth middleware reads the app's default guard, not MARTIS_GUARD, so it never stands in for them. Set a list only to add to the stack (a rate limit, an IP allow-list). See API → Overview 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).
| Variable | Default | Surface |
|---|---|---|
MARTIS_CACHE_DASHBOARDS_ENABLED | true | Dashboard renders |
MARTIS_CACHE_DASHBOARDS / MARTIS_CACHE_DASHBOARDS_TTL | 5 | Dashboard TTL (the list key is also fingerprinted by the user's authorized set, so membership changes never wait for the TTL) |
MARTIS_CACHE_METRICS_ENABLED | true | Metric cards |
MARTIS_CACHE_METRICS / MARTIS_CACHE_METRICS_TTL | 5 | Metrics TTL |
MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS | null | Metrics TTL in seconds for a metric that sets none (v2.20.0); null keeps the minutes above. See Cache |
MARTIS_CACHE_NAVIGATION_ENABLED | true | Sidebar navigation tree |
MARTIS_CACHE_NAVIGATION / MARTIS_CACHE_NAVIGATION_TTL | 1 | Navigation TTL |
MARTIS_CACHE_BADGE_TTL_SECONDS | null | TTL in seconds of each user's sidebar badge map (v2.20.0); null keeps the navigation TTL in minutes |
MARTIS_CACHE_SCHEMA_ENABLED | true | Resource schema payload |
MARTIS_CACHE_SCHEMA / MARTIS_CACHE_SCHEMA_TTL | 1440 | Schema TTL in minutes (one day since v2.0; null before, see Cache → Invalidation) |
MARTIS_CACHE_ADMIN_UI | true | Cache admin UI in the System sidebar group |
The shorter names (MARTIS_CACHE_DASHBOARDS, etc.) and the explicit _TTL variants resolve to the same value — the _TTL form just makes intent unambiguous. Pick whichever reads better in your .env.
Drawer
'drawer' => [ 'expandable' => env('MARTIS_DRAWER_EXPANDABLE', true),],When on, the drawer that opens record detail can be expanded into a full overlay. Set to false to lock the drawer to its standard width.
width (default 720px) and expanded_width (default 960px) size every drawer on desktop. A drawer is never wider than the viewport, and at 768px or less it takes the whole screen (v2.18.2), whatever these values say; see Overrides → Drawer overrides.
Sticky views
'sticky_views' => [ 'enabled' => env('MARTIS_STICKY_VIEWS_ENABLED', true), 'scope' => env('MARTIS_STICKY_VIEWS_SCOPE', 'session'),],| Key | Default | Effect |
|---|---|---|
enabled | true | Remember the last index URL (filter, sort, page) per resource so users return to where they left off. |
scope | session | Where state is persisted: session — sessionStorage, wipes on tab close; local — localStorage, survives tab close. |
Loader
'loader' => [ 'disabled' => env('MARTIS_LOADER_DISABLED', false),],Hides the global pre-hydration loader. Useful when you embed Martis inside an outer shell that already shows its own splash screen.
Search defaults
'search' => [ 'mode' => env('MARTIS_SEARCH_MODE', 'bar'), 'mobile_mode' => env('MARTIS_SEARCH_MOBILE_MODE', 'icon'), 'default_limit' => (int) env('MARTIS_SEARCH_DEFAULT_LIMIT', 5), 'min_query' => (int) env('MARTIS_SEARCH_MIN_QUERY', 2), 'match_mode' => env('MARTIS_SEARCH_MATCH_MODE', 'contains'),],| Key | Default | Effect |
|---|---|---|
mode | bar | Topbar search mode on desktop. bar, icon, or disabled. |
mobile_mode | icon | Equivalent for mobile viewports. |
default_limit | 5 | Result rows per resource in the omnisearch dropdown. |
min_query | 2 | Don't trigger a search until the query has this many characters. |
match_mode | contains | How the database search matches a term for every resource without its own searchMode(): contains (ILIKE '%term%'), prefix (ILIKE 'term%') or columns (only col:term tokens). An unknown value throws. See search-performance.md. |
Default row actions
'index' => [ 'default_row_actions' => [ 'enabled' => env('MARTIS_DEFAULT_ROW_ACTIONS', true), 'view' => env('MARTIS_DEFAULT_ROW_ACTION_VIEW', true), 'edit' => env('MARTIS_DEFAULT_ROW_ACTION_EDIT', true), 'delete' => env('MARTIS_DEFAULT_ROW_ACTION_DELETE', true), ], 'row_click_opens_detail' => env('MARTIS_ROW_CLICK_OPENS_DETAIL', true),],The View / Edit / Delete row actions appear by default on every Resource. Toggle individual ones globally, or disable the whole default_row_actions block and re-add per Resource via actions(). row_click_opens_detail controls whether clicking a row opens detail (or just selects the row).
Locales and i18n
'locales' => [ 'app_namespaces' => env('MARTIS_APP_LOCALE_NAMESPACES', ''), 'fallback_chain' => env('MARTIS_LOCALE_FALLBACK_CHAIN', 'en'), 'rtl_locales' => env('MARTIS_RTL_LOCALES', 'ar,fa,he,ur'),],Each env var is a comma-separated string; Martis splits it into an array at boot.
| Key | Default | Effect |
|---|---|---|
locales.fallback_chain | en | Comma-separated chain consulted when a translation is missing in the active locale. |
locales.app_namespaces | '' | Comma-separated list of consumer translation namespaces published into Martis surfaces (so your app can override package strings). |
locales.rtl_locales | ar,fa,he,ur | Locales rendered right-to-left. |
Dev tools (v1.8.8)
'dev' => [ 'tools_enabled' => env('MARTIS_DEV_TOOLS', in_array(env('APP_ENV', 'production'), ['local', 'testing'], true)), 'prevent_lazy_loading_in_listings' => env('MARTIS_DEV_PREVENT_LAZY_LOADING_IN_LISTINGS', false),],Master switch for the Component Inspector overlay (Cmd/Ctrl+Shift+I) and other in-page debugging surfaces. Defaults to ON in local and testing, OFF in production. Force ON in production with MARTIS_DEV_TOOLS=true for short-lived diagnostic windows.
prevent_lazy_loading_in_listings (v2.20.0, off by default) makes a relationship lazy loaded while a listing's rows are serialized throw in local and testing and log a warning elsewhere, without turning on Laravel's global Model::preventLazyLoading(). See Resources → Avoiding per-row queries on index pages.
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.phpuse Closure;use Illuminate\Http\Request;use Illuminate\Support\Facades\Vite; class ContentSecurityPolicy{ public function handle(Request $request, Closure $next) { // Generates a random nonce and keeps it for every tag Vite and Martis render. // Already have one (spatie/laravel-csp, say)? Pass it: Vite::useCspNonce($nonce). $nonce = Vite::useCspNonce(); $response = $next($request); $response->headers->set('Content-Security-Policy', implode('; ', [ "default-src 'self'", "script-src 'self' 'nonce-{$nonce}'", "style-src 'self' 'nonce-{$nonce}'", "img-src 'self' data: blob: https:", // avatars, Gravatar, uploads and local previews "media-src 'self' blob:", "font-src 'self' data:", "connect-src 'self'", // add the host of your real-time transport (Echo, Pusher, SSE) "object-src 'none'", "base-uri 'self'", "frame-ancestors 'self'", ])); return $response; }}Roll it out with Content-Security-Policy-Report-Only first and watch the reports. What the panel needs, and why:
| Directive | Needs | Because |
|---|---|---|
script-src | 'self' and the nonce | The bundle is a same-origin module graph (its chunks load by dynamic import()), and the boot script is inline. No 'unsafe-eval' is needed. |
style-src | 'self' and the nonce | The stylesheets are same-origin files, the shell has two inline <style> blocks, and PrimeReact and CodeMirror inject their own at run time. The SPA applies its inline style values through the CSSOM, which this directive allows. |
img-src | data: and blob: besides 'self' | Image and avatar fields preview a picked file as a blob: URL, and some fields embed data: images. Add the hosts of your disk and of Gravatar, or allow https:. |
connect-src | 'self' | The SPA talks to its own /api only. Add the host of a real-time transport you wired to martisEventBus. |
HTML that a field renders from stored content cannot carry inline style attributes under such a policy: the Markdown and Trix displays strip them anyway (see Markdown 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 tablephp artisan martis:list-env-vars --json # JSON array| Variable | Default |
|---|---|
MARTIS_2FA_ENABLED | true |
MARTIS_2FA_RECOVERY_CODES | 8 |
MARTIS_2FA_TRUST_REMEMBER_ME | true |
MARTIS_ACTION_EVENTS_ENABLED | true |
MARTIS_ACTION_EVENTS_RESOURCE | true |
MARTIS_ALLOW_BRAND_COLOR | false |
MARTIS_API_DOCS_ENABLED | false |
MARTIS_API_DOCS_PATH | 'api-docs' |
MARTIS_APP_LOCALE_NAMESPACES | '' |
MARTIS_ATTACHMENT_MAX_SIZE | 10240 |
MARTIS_ATTACHMENT_MIMES | 'jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,ppt,pptx,txt,csv,zip,mp4,mp3' |
MARTIS_AUDIT_AUTHZ_DENIALS | false |
MARTIS_AUDIT_AUTHZ_DENIALS_INCLUDE_VIEWANY | false |
MARTIS_AUDIT_IMPERSONATION | true |
MARTIS_AUDIT_ROLE_CHANGES | true |
MARTIS_AUTHZ_REQUEST_CACHE | false |
MARTIS_AUTHZ_REVOKE_SESSIONS_ON_DEMOTE | false |
MARTIS_AUTH_CONTROL_LOCALE | true |
MARTIS_AUTH_CONTROL_THEME | true |
MARTIS_AUTH_EMAIL_VERIFICATION_ENABLED | false |
MARTIS_AUTH_EMAIL_VERIFICATION_NOTICE_URL | (no default) |
MARTIS_AUTH_FORGOT_SUBTITLE | (no default) |
MARTIS_AUTH_FORGOT_TITLE | (no default) |
MARTIS_AUTH_LOGIN_SUBTITLE | (no default) |
MARTIS_AUTH_LOGIN_SUBTITLE_SSO | (no default) |
MARTIS_AUTH_LOGIN_TITLE | (no default) |
MARTIS_AUTH_MAGIC_LINK_AUTO_REGISTER | false |
MARTIS_AUTH_MAGIC_LINK_ENABLED | false |
MARTIS_AUTH_MAGIC_LINK_TTL | 15 |
MARTIS_AUTH_PASSWORD_BROKER | unset (the broker of the Martis guard's provider) |
MARTIS_AUTH_PASSWORD_CHANGE_COLUMN | must_change_password |
MARTIS_AUTH_PASSWORD_CHANGE_ENABLED | false |
MARTIS_AUTH_PASSWORD_CHANGE_URL | (no default) |
MARTIS_AUTH_PASSWORD_RESET_ENABLED | false |
MARTIS_AUTH_PASSWORD_RESET_URL | (no default) |
MARTIS_AUTH_REGISTER_SUBTITLE | (no default) |
MARTIS_AUTH_REGISTER_TITLE | (no default) |
MARTIS_AUTH_REGISTRATION_DEFAULT_ROLE | (no default) |
MARTIS_AUTH_REGISTRATION_ENABLED | false |
MARTIS_AUTH_REGISTRATION_URL | (no default) |
MARTIS_AUTH_RESET_SUBTITLE | (no default) |
MARTIS_AUTH_RESET_TITLE | (no default) |
MARTIS_AVATAR_COLUMN | 'profile_picture' |
MARTIS_AVATAR_DISK | 'public' |
MARTIS_AVATAR_ENABLED | true |
MARTIS_AVATAR_MAX_SIZE | 2048 |
MARTIS_AVATAR_PATH | 'avatars' |
MARTIS_BRAND_APPLE_TOUCH_ICON | (no default) |
MARTIS_BRAND_AUTH_LOGO | (no default) |
MARTIS_BRAND_AUTH_LOGO_DARK | (no default) |
MARTIS_BRAND_DOCS_URL | (no default) |
MARTIS_BRAND_ICON | (no default) |
MARTIS_BRAND_ICON_DARK | (no default) |
MARTIS_BRAND_LOGO | (no default) |
MARTIS_BRAND_LOGO_DARK | (no default) |
MARTIS_BRAND_LOGO_HEIGHT_AUTH | (no default) |
MARTIS_BRAND_LOGO_HEIGHT_MENU | (no default) |
MARTIS_BRAND_MANIFEST | (no default) |
MARTIS_BRAND_NAME | 'Martis' |
MARTIS_BRAND_THEME_COLOR | (no default) |
MARTIS_BRAND_VERSION | (no default) |
MARTIS_CACHE_ADMIN_UI | true |
MARTIS_CACHE_BADGE_TTL_SECONDS | (no default) |
MARTIS_CACHE_DASHBOARDS | 5 |
MARTIS_CACHE_DASHBOARDS_ENABLED | true |
MARTIS_CACHE_DASHBOARDS_TTL | env('MARTIS_CACHE_DASHBOARDS', 5) |
MARTIS_CACHE_ENABLED | true |
MARTIS_CACHE_METRICS | 5 |
MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS | (no default) |
MARTIS_CACHE_METRICS_ENABLED | true |
MARTIS_CACHE_METRICS_TTL | env('MARTIS_CACHE_METRICS', 5) |
MARTIS_CACHE_NAVIGATION | 1 |
MARTIS_CACHE_NAVIGATION_ENABLED | true |
MARTIS_CACHE_NAVIGATION_TTL | env('MARTIS_CACHE_NAVIGATION', 1) |
MARTIS_CACHE_SCHEMA | null |
MARTIS_CACHE_SCHEMA_ENABLED | true |
MARTIS_CACHE_SCHEMA_TTL | env('MARTIS_CACHE_SCHEMA', 1440) |
MARTIS_CUSTOM_ACCENTS | (no default) |
MARTIS_DASHBOARD_SHOW_GREETING | true |
MARTIS_DASHBOARD_SHOW_WELCOME | true |
MARTIS_DASHBOARD_SHOW_WELCOME_CARD | true |
MARTIS_DEFAULT_ACCENT | 'martis' |
MARTIS_DEFAULT_DENSITY | 'comfortable' |
MARTIS_DEFAULT_LOCALE | 'en' |
MARTIS_DEFAULT_ROW_ACTIONS | true |
MARTIS_DEFAULT_ROW_ACTION_DELETE | true |
MARTIS_DEFAULT_ROW_ACTION_EDIT | true |
MARTIS_DEFAULT_ROW_ACTION_VIEW | true |
MARTIS_DEFAULT_THEME | 'dark' |
MARTIS_DEFAULT_TRASHED_FILTER | 'active' |
MARTIS_DEV_TOOLS | in_array(env('APP_ENV', 'production'), ['local', 'testing'], true), |
MARTIS_DRAWER_EXPANDABLE | true |
MARTIS_FAVICON | null |
MARTIS_FOOTER_TEXT | (no default) |
MARTIS_GUARD | null |
MARTIS_IMPERSONATION_ENABLED | false |
MARTIS_IMPERSONATION_GUARD | null (the Martis guard) |
MARTIS_IMPERSONATION_MAX_DURATION | 0 |
MARTIS_IMPERSONATION_POLL_MS | 120000 |
MARTIS_IMPERSONATION_SESSION_KEY | 'martis.impersonation' |
MARTIS_INDEX_COLUMN_DEFAULTS | true |
MARTIS_KEYBOARD_SHORTCUTS_ENABLED | true |
MARTIS_KEYBOARD_SHORTCUTS_HELP_OVERLAY | true |
MARTIS_LAYOUT | 'sidebar' |
MARTIS_LAYOUT_HEADER_FIRST | false |
MARTIS_LOADER_DISABLED | false |
MARTIS_LOADER_LOGO | (no default) |
MARTIS_LOADER_LOGO_DARK | (no default) |
MARTIS_LOCALE | env('APP_LOCALE', 'en') |
MARTIS_LOCALE_FALLBACK_CHAIN | 'en' |
MARTIS_2FA_LOCKOUT_ATTEMPTS | 5 |
MARTIS_2FA_LOCKOUT_MINUTES | 15 |
MARTIS_2FA_THROTTLE_ATTEMPTS | 5 |
MARTIS_2FA_THROTTLE_IP_ATTEMPTS | 15 |
MARTIS_2FA_THROTTLE_MINUTES | 1 |
MARTIS_LOGIN_THROTTLE_ATTEMPTS | 20 |
MARTIS_LOGIN_THROTTLE_EMAIL_ATTEMPTS | 100 |
MARTIS_LOGIN_THROTTLE_EMAIL_MINUTES | 15 |
MARTIS_LOGIN_THROTTLE_MINUTES | 1 |
MARTIS_NAV_BADGES_POLL_MS | 300000 |
MARTIS_NAV_COUNTS | true |
MARTIS_NAV_COUNTS_REPORT_FAILURES | true |
MARTIS_NAV_COUNT_COMPACT_THRESHOLD | 10000 |
MARTIS_NOTIFICATIONS_ENABLED | true |
MARTIS_NOTIFICATIONS_MAX_DROPDOWN | 10 |
MARTIS_NOTIFICATIONS_POLL_INTERVAL | 90000 |
MARTIS_PAGE_TITLE | (no default) |
MARTIS_PATH | 'martis' |
MARTIS_PREFERENCES_EDITABLE | 'theme,accent,density,locale,reducedMotion' |
MARTIS_PREFERENCES_ENABLED | true |
MARTIS_PANEL_OPEN_ENVIRONMENTS | 'local,testing' |
MARTIS_PROFILE_EMAIL_CHANGE_TTL | 60 |
MARTIS_PROFILE_EMAIL_EDITABLE | true |
MARTIS_PROFILE_ENABLED | true |
MARTIS_ROW_CLICK_OPENS_DETAIL | true |
MARTIS_RTL_LOCALES | 'ar,fa,he,ur' |
MARTIS_SEARCH_DEFAULT_LIMIT | 5 |
MARTIS_SEARCH_MATCH_MODE | 'contains' |
MARTIS_SEARCH_MIN_QUERY | 2 |
MARTIS_SEARCH_MOBILE_MODE | 'icon' |
MARTIS_SEARCH_MODE | 'bar' |
MARTIS_SHOW_PROFILE_MENU | true |
MARTIS_SSO_AZURE_ENABLED | false |
MARTIS_SSO_AZURE_LOGOUT_URL | (no default) |
MARTIS_SSO_CACHE_STORE | (no default) |
MARTIS_SSO_ENABLED | false |
MARTIS_SSO_ISSUER | (no default) |
MARTIS_SSO_ISSUER_AUDIENCE_PANEL_B_CALLBACK_URL | (no default) |
MARTIS_SSO_ISSUER_ENABLED | false |
MARTIS_SSO_ISSUER_ENVIRONMENT | (no default) |
MARTIS_SSO_ISSUER_PRIVATE_KEY | (no default) |
MARTIS_SSO_PANEL_A_AUDIENCE | (no default) |
MARTIS_SSO_PANEL_A_AUTHORIZE_URL | (no default) |
MARTIS_SSO_PANEL_A_ENABLED | false |
MARTIS_SSO_PANEL_A_ISSUER | (no default) |
MARTIS_SSO_PANEL_A_PUBLIC_KEYS | '' |
MARTIS_STICKY_VIEWS_ENABLED | true |
MARTIS_STICKY_VIEWS_SCOPE | 'session' |
MARTIS_STORAGE_DISK | 'public' |
MARTIS_THEME | 'dark' |
MARTIS_THEME_NAME | null |
MARTIS_THROTTLE_DECAY | 1 |
MARTIS_THROTTLE_ENABLED | true |
MARTIS_THROTTLE_MAX | 120 |
MARTIS_THROTTLE_METRICS_DECAY | null (the API decay) |
MARTIS_THROTTLE_METRICS_MAX | null (metrics stay in the API bucket) |
MARTIS_TOAST_POSITION | 'bottom-right' |
MARTIS_UI_LOCALES | 'en,pt_PT,pt_BR' |
MARTIS_UI_LOCALE_LABELS | (no default) |
MARTIS_USER_ID_COLUMN_TYPE | (no default) |
MARTIS_WELCOME_DESCRIPTION | (no default) |
MARTIS_WELCOME_HEADING | (no default) |
Next Steps
- Installation Guide — Initial setup
- Authentication — Login, 2FA, profile
- Resources — Resource configuration
- Override System — Customize the UI