# Upgrading · Martis docs

> The sections below list the breaking changes of each major version and what to change in an app.

Source: https://getmartis.com/docs/getting-started/upgrading

# Upgrading

> **These docs describe Martis v2.** Martis v1.x read `Select` and `MultiSelect` options as `[label => value]`; v2.0 and later read `[value => label]`, Nova's order. If your app still runs v1.x, flip the option arrays in the examples, or upgrade with the steps below.

The sections below list the breaking changes of each major version and what to change in an app.

## Upgrading to v2.23.0 from v2.22.x

v2.23.0 adds SSO operations (`martis:sso:check`, `auth.session_guards`), table and detail presentation options, an emit message for actions, `FieldError` for custom inputs and a throttle bucket for the metrics. Republish the assets (`php artisan martis:publish-assets`) and the config (`php artisan vendor:publish --tag=martis-config --force`, or copy the new `session_guards`, `metrics_max_attempts` and `metrics_decay_minutes` keys by hand), and refresh the extension shims if you build extensions (`php artisan vendor:publish --tag=martis-extension-shims --force`). Every new option is off by default. Check these, which change what a panel renders or throws:

- **A field that already calls `fullWidth()` now renders as one column on the detail page and in the drawer**, with the label above the value. It was ignored there. Remove the call if you want the two-column row back.
- **The per-page and trashed selects are PrimeReact dropdowns.** Host CSS or a test that targets `select.martis-perpage-select` must target `.p-dropdown.martis-perpage-select`; the class stays on the root and the `aria-label` is kept. Other native selects (repeater, MorphTo, metric range, preferences, sort chip) are unchanged.
- **The field `column` block gained `textAlign` and `hideBelow`**, both `null`. Code or a test that asserts its exact shape must include them. `textAlign` and `hideBelow` apply to the index table only, not to relationship tables yet.
- **`?password_login=1` is ignored when `auth.password_login.break_glass_emails` is empty.** With password sign-in off, the login page used to open the form on that parameter for anyone; it now does so only when the list holds an address (the published `passwordLogin.breakGlass`). A custom `auth:login` component that opens the form on the parameter should read `config.auth.passwordLogin.breakGlass` too. The flag is computed without validating the list, so a malformed `break_glass_emails` does not fail a page render; the break-glass sign-in still throws, naming the key.
- **`auth.session_guards`, when you set it, must be complete.** `null` (the default) changes nothing. A list makes Martis count the Martis guard and only the guards you list when it decides whether `sessions.user_id` can be attributed to one person, so an admin-only app with no other session guard can set `'session_guards' => []` and get session deletion on SSO revocation and on demotion, and the profile's Browser sessions. A guard that signs in people but is not listed can make Martis delete, or list, the session of another person with the same id in another table. A value that is not a list of names, a name that is not in `auth.guards`, or a guard whose driver is not `session` throws, naming the key, the first time a revocation, a demotion sweep or the profile reads it.
- **The metrics throttle bucket is opt-in.** Set `MARTIS_THROTTLE_METRICS_MAX` and the metric and dashboard read routes leave the API bucket and count against their own; a request never counts in both. An empty variable reads as unset; `0`, a negative number or text throws, naming the key, when the routes are registered.
- **A signed token that repeats a JSON key is refused as `malformed`.** Tokens issued by Martis never do. Run `php artisan martis:sso:check` in a deploy script to validate the SSO configuration; it prints no key.
- **The revocation of a Redis-backed panel recovers on upgrade.** A marker that Redis returned as text was refused at every sign-in; it is compared by time again.
- **The cached dashboard payload follows the cards in code.** After an upgrade the `dashboards` layer is keyed with a fingerprint of the cards and filters, so entries of the old key are simply not read again and expire on their TTL.
- **Extension authors** can import `FieldError` and `useFieldError` from `@martis/runtime` after refreshing the shims. `ActionResponse::emit()` takes an optional message and `silent()` drops the toast; an existing call is unchanged.

## Upgrading to v2.22.0 from v2.21.x

v2.22.0 hardens the signed-token SSO of v2.21.0 (state fixation, the state lifetime, the environment lock), adds audit events to the issuing side, a cache store for everything SSO keeps in a cache, and a translatable provider `label`. Republish the assets (`php artisan martis:publish-assets`) and the config (`php artisan vendor:publish --tag=martis-config --force`, or copy the new `cache_store` key by hand). Nothing is on by default that was off. Check these, three of which change what a panel accepts or throws:

- **The one-time `jti` is used up before the state is checked, and before the callback's throttle.** A signed-token callback that fails the state check now burns its token: presenting the same URL again, even in the browser that holds the right state, answers `replayed`. A listener or a log alert keyed on `SsoFailed` reason `state` for a repeated callback sees `replayed` instead. A person who opens the callback of a flow twice (two tabs, a restored session) signs in once and is refused the second time, as before; only the reason changed. The callback route's `throttle:<max>,<decay>` middleware is now `Martis\Http\Middleware\SpendSignedToken:<max>,<decay>`: it spends the token of a signed-token provider and then applies the same limit through the app's `throttle` alias (so `throttleWithRedis()` keeps working), whatever the provider. A host that declares the callback route itself, or lists its middleware by hand, should use it in place of `throttle` there. This is the fix for a state-fixation replay, see [SSO, section 18](https://getmartis.com/docs/auth/sso#security-notes).
- **The first arrival of a valid token spends it, so a gateway that fetches links breaks the sign-in.** A corporate URL-rewriting gateway or an email-security sandbox that follows redirects and fetches the callback URL with a `GET` ahead of the browser uses the token up, and the browser's own request is refused as `replayed`. A `HEAD` request on the callback of a signed-token provider is answered 405 before anything is read or recorded, so a scanner that probes with `HEAD` first is harmless. If such a gateway sits in front of the panels, exempt the callback URL from it.
- **An existing pair where only the issuer sets `environment` stops signing in until the consumer sets it too.** The issuer adds the `env` claim to every token; a consumer with no `environment` now refuses a token that carries it (reason `environment`, the log line says to set `environment`). Set `environment` in the consumer's provider block to the same value as the issuer's. The other way round (the consumer is locked, the issuer is not) was already refused.
- **`environment_claim` is removed, on the issuer block and on every provider block.** The claim that carries the environment is always `env`; a block that still sets the key (even to its default) throws on its first use, naming the key. Delete the line. With a custom name a locked issuer and an unlocked verifier read different claims and disagreed without any error, which is why the name is not configurable any more. An issuer `claims` class may no longer set `env` either (it could not set the old custom name).
- **A configuration error of a signed-token provider block is a 500 on the callback**, naming the key (it was an `unknown` refusal with the generic text on the login page). A refusal is an `SsoException` only, so a log alert keyed on `SSO callback failed` for a bad `public_keys` entry or an empty `issuer` now finds the error in the exception log instead.
- **A provider `label` that is neither a string nor a map of strings throws** when the login page is built, naming `martis.auth.sso.providers.<name>.label`. It was dropped without a word before. A map of `locale => string` is new and optional. A custom `auth:login` component that reads `config.auth.sso.providers.*.label` as a string must handle a map now (`resolveCopyValue(label, locale)` from `authCopy.ts` does); a map rendered as a React child throws. An empty-string label reads as unset: the button says "Continue with" followed by the provider name.
- **The state of a flow expires after ten minutes**, so a sign-in at the issuing panel that takes longer (a forgotten password, a slow 2FA challenge) comes back refused as `state` and the person starts again. The limit is a class constant (`SignedTokenProvider::STATE_LIFETIME_SECONDS`), not a config key. A flow started before the upgrade and finished after it is refused once for the same reason: its state was kept without a time.
- **`martis.auth.sso.cache_store`** (`MARTIS_SSO_CACHE_STORE`) is new and optional; unset (or empty), every SSO cache entry stays in the default store as before. With SSO on, a name that is not in `cache.stores` throws, naming the key, on the SSO paths **and** on password, magic-link and invitation sign-in (before anyone is signed in, and before a link or an invitation is spent), on sign-out and on the plain 401 (they read or drop the SSO origin of the session); with SSO off none of them touches the cache. With SSO on, an `array` or `null` store (the default one included, when the key is unset) now throws outside the test suite: set the key to a shared store, or point `cache.default` at one. Changing the store on a running panel orphans the revocation times, the origin nonces and the one-time `jti` records of the old one: change it at a quiet moment and run `revokeFor()` again for anyone revoked recently. Test doubles that mocked the `Cache` facade's `add()` for the one-time `jti` must now answer `Cache::store()`.
- **New events** `Martis\Events\SsoTokenIssued` and `Martis\Events\SsoIssueDenied` on the issuing side, and a warning log line for a refused issue. They run inside the request, so a throwing listener of `SsoTokenIssued` stops the sign-in. Nothing to change unless you want an audit trail: see [SSO, issuer audit events](https://getmartis.com/docs/auth/sso#issuer-audit-events-v2220).

## Upgrading to v2.21.0 from v2.20.x

v2.21.0 is about sign-in: SSO-only panels, a signed token between two Martis apps, SSO session limits and revocation, a switch for password sign-in, a challenge (captcha) hook and toast lifetimes. Republish the assets (`php artisan martis:publish-assets`), the config (`php artisan vendor:publish --tag=martis-config --force`, or copy the new keys by hand) and the extension shims (`php artisan vendor:publish --tag=martis-extension-shims --force`). Every new feature is off by default. These behaviours change without any config:

- **`window.MartisConfig.auth` carries only what the SPA reads.** It no longer has: any key of an SSO provider block other than `enabled`, `label` and `icon`; disabled providers; `passwordReset.broker`; `registration.default_role`; `magicLink.ttlMinutes` and `magic_link.auto_register`; `emailVerification.notice_url`; `passwordChange.column`; the snake_case duplicates (`magic_link`, `email_verification`, `password_change`); the flat `google` block; any key an app added. A custom `auth:login` component that read one of them lists the provider keys it needs in that provider's `public` array, or fetches the rest from an endpoint of the app. Only `auth` is an allow-list: the other blocks of the shell are published whole, as before.
- **A refused SSO identity leaves no row.** The role decision now comes before the local user is written, so an identity with no matching role creates no user and does not rewrite the name or email of an existing one. An identity with no role and no local user is refused as `no_role_match` (it was "not provisioned"), and `MartisSso::resolveUserUsing()` is not called for it. An app that pre-provisions rows through refused sign-ins sets `provision_on_deny => true` on the provider.
- **`external_id` matching writes the id.** Auto-created users get the id column filled, and the second sign-in finds them. A row created by v2.20 or earlier has an empty column: set `link_by_email => true` on the provider to link it on its next sign-in (the row must pass the same email check as the `email` strategy), or fill the column yourself. A custom `resolveUserUsing()` still wins.
- **Values that were silently ignored now throw**, naming the key: an unknown `identity_match_attribute`, a non-string `identity_external_id_column`, an `on_no_role_match` other than `deny`, `guest` or `callable` (it signed the user in role-less), a `logout_url` that is neither a string nor null.
- **SSO callback URLs are lower case.** `/sso/Azure/callback` is a 404. Check the redirect URI registered at the IdP.
- **The failed-callback log line changed**: its context is `provider`, `reason`, `request_id`, `exception`, `error` (credentials masked); the `email` key is gone. A failure after the user was resolved (a throwing `afterLogin` hook, a unique index) is now a refusal on the login page, not a 500.
- **A refused SSO sign-in shows an error toast on the login page** (the text of the exception's `messageKey`, else the generic one). It showed nothing before. A custom `auth:login` component reads it from `window.MartisConfig.flash.sso`.
- **New refusal reason `unlinked_account`**: an `external_id` sign-in whose address is held by a local row with no id, with `link_by_email` off. It was reported as `account_unverified`; a listener on `SsoFailed` or a log alert keyed on that reason adds the new one.
- **The SPA waits 300 ms before it leaves for `login?expired=1` on a 401**, so a response that carries `sso_reauth_url` can win.
- **`MartisSso::afterLogin()` appends.** Registering twice ran only the last closure; both run now, in order. `MartisSso::forgetHooks()` clears them.
- **`RouteMiddleware::authenticated()` includes `martis.sso.session`.** A host that copies the middleware list by hand adds it after the guard. An SSO session costs one cache read per request, and a failing cache store ends SSO sessions.
- **`php artisan martis:sso azure|google|github` now inserts its block** into a published config that only had the commented example.

To use the new features:

- **SSO-only panel**: `MARTIS_PASSWORD_LOGIN_ENABLED=false`, and turn registration, magic link and invitations off if they are on. Sessions that already exist are not ended: cycle the remember tokens and clear the session store when you switch. See [Authentication](https://getmartis.com/docs/auth/authentication).
- **Session limits**: `max_session_minutes` or `revalidate_every_minutes` on a provider need `remember => false` on it (the combination throws). Sessions opened before the upgrade have no sign-in time and end at their next request once a limit is set.
- **Signed token**: both panels run v2.21.0 or later; `ext-sodium` is required on both. Start with `php artisan martis:sso:keys`, then [SSO, section 18](https://getmartis.com/docs/auth/sso).
- **Subclasses**: `IdentityResolver`'s protected `find()` is now `findByEmail()` / `findByExternalId()`, and `create()` / `syncAttributes()` take the provider and its config; `SsoSession::start()` and `SsoIdentity` have a new optional last parameter.

## Upgrading to v2.20.0 from v2.19.x

v2.20.0 adds opt-in performance hooks: lazy extension components, cached and batched sidebar counts, metric TTLs in seconds, a per-page prefetch hook with batched action visibility, and PostgreSQL search tooling. Republish the assets (`php artisan martis:publish-assets`) and the config (`php artisan vendor:publish --tag=martis-config --force`, or copy the new keys by hand). Nothing else has to change in an app; check these if they apply:

- **The extension bundle URL now ends in `?v=<hash>`** when it points at an existing file under `public/` (the default `/vendor/martis-user/extensions.js` does). The file is the same; only the URL changes with its content, so it can be served as immutable (see [Extensions: caching](https://getmartis.com/docs/customization/extensions-caching)). A test or a CSP rule that matched the bare URL exactly should allow the query string, or set `MARTIS_EXTENSIONS_VERSIONED_URLS=false`.
- **To register components lazily** in an app scaffolded before v2.20.0, refresh the extension scaffold (`php artisan vendor:publish --tag=martis-extension-shims --force`) for `reloadOnStaleChunk` in the runtime shim and the code-splitting `vite.extensions.config`, then `npm run build:extensions`. Existing eager registrations keep working unchanged.
- **New public members** on `Action` (`canRunForRows()`, `hasBatchedCanRun()`, `authorizedToRunRows()`), `Resource` (`prefetchIndexPage()`, `menuCountCache()`, `searchMode()`, `$searchMode`), `Tool` (`menuCountCache()`) and `Metric` (`$cacheSeconds`, `cacheForSeconds()`, `$live`, `live()`). A subclass or trait in the app that already declares one of these names with another signature fails at class load: rename it. `ActionContract` and `ResourceContract` are unchanged, so a class that implements a contract without extending the base class keeps working, without the new behaviour.
- **`martis.search.match_mode`** is the new search matching key. `martis.search.mode` is still the top-bar search bar mode (`bar`, `icon`, `disabled`).
- **A `canRunForRows()` answer is keyed by the rows' keys** (`$rows->mapWithKeys(...)`); a row it omits is denied unless the action also has a per-row `canRun()`, and a key that is no row throws.

## Upgrading to v2.19.0 from v2.18.x

v2.19.0 names a dashboard from the first paint: the shell embeds the list of dashboards the user may see in its boot payload (`window.MartisConfig.dashboards`), so a reload or a bookmark no longer shows "Dashboard" in the breadcrumb and the sidebar, the generic tab title, or three skeleton cards until `GET /api/dashboards` answers. Republish the assets (`php artisan martis:publish-assets`). Nothing has to change in an app; check these if they apply:

- **The server-rendered `<title>` of a dashboard is its `name()`.** `/dashboards/support-team` read `Support team · Acme` (the address, title-cased) and `/` read the generic default; they now read the dashboard's name, and a dashboard the user cannot see gets the default. A `brand.page_title` or `Martis::pageTitleUsing()` still wins.
- **Every panel page a signed-in user may open resolves the dashboards** (their `authorizedToSee()` and lock predicates, once per request) to fill the boot payload, as `GET /api/dashboards` does. The pages served before sign-in, the 2FA challenge and the "no access" (403) page carry no list. A dashboard whose `authorizedToSee()` throws, or whose `meta()` cannot be encoded as JSON, no longer breaks the page: the error is reported and the SPA requests the list itself.
- **`usePageTitle()` and `useDynamicCrumb()` take `{ pending: true }`** while their label loads: the title stays as it is and the crumb is a blank instead of the route's generic label. Pages that do not pass it behave as before. Refresh the extension shims to get the option in the types (`php artisan vendor:publish --tag=martis-extension-shims --force`).
- **A test that expected "Dashboard" in the breadcrumb or the sidebar while `/api/dashboards` was pending** now finds a placeholder (`.martis-breadcrumb-pending`, `.martis-sb-item-pending`).

## Upgrading to v2.18.3 from v2.18.2

v2.18.3 opens the filter panel collapsed on a `?filters=` link (a `MenuItem::filter()` item), keeps a lens's filter panel open while the user changes the filters, and lays the top bar's search out in the row, so a `topbar:end` component no longer slides under it. Nothing has to change in an app: republish the assets (`php artisan martis:publish-assets`). A browser test that clicked **Filters** to close the panel after following a filter link, or expected the filter controls to be visible on that page, now finds the panel closed. A stylesheet that positioned `.martis-tb-search` itself should check it against the new layout (`flex: 0 1 420px` in the bar's row). A custom top bar (`layout:topbar`) copied from the bundled one keeps the search in the row but loses the centring unless its `<header class="martis-tb">` also carries `data-search="bar"` (the search mode: `bar`, `icon` or `disabled`).

## Upgrading to v2.18.2 from v2.18.1

v2.18.2 fixes drawers on phones: at 768px or less a drawer is the full viewport (it kept its configured width before, so its left part was off screen), it is never wider than the viewport above that, and the page behind it no longer scrolls while it is open. Nothing has to change in an app: republish the assets (`php artisan martis:publish-assets`). `config('martis.drawer.width')` and `expanded_width` keep sizing drawers on desktop.

## Upgrading to v2.18.1 from v2.18.0

v2.18.1 ships the new Martis logo in the bundled sign-in logo, the default menu icon and the README. Nothing has to change in an app: republish the assets (`php artisan martis:publish-assets`). An app with its own logo (`brand.logo`, `MARTIS_BRAND_LOGO`) keeps it.

## Upgrading to v2.18.0 from v2.17.x

v2.18.0 opens the records of a resource with a detail override over its index without reloading the page, and adds `Resource::documentTitle()` for the browser tab of a record. Republish the assets (`php artisan martis:publish-assets`). Nothing has to change in an app; check these if they apply:

- **Closing a detail override steps back in the history** to the index entry it was opened from (a deep-linked record still pushes the index URL). A custom detail override that calls `onClose()` gets the same behaviour.
- **A custom detail override component still receives the loaded record**; while it loads, a light loader shows over the list instead of the full-page loader. Its `onUpdated()` now also refreshes the list behind it.
- **A shim that rewrote the detail tab title** can be replaced by `documentTitle()`. See [Resources → Browser tab title](https://getmartis.com/docs/core/resources#browser-tab-title--documenttitle).

## Upgrading to v2.17.0 from v2.16.x

v2.17.0 adds fields a sibling shows or hides (`hide()` / `show()` in `dependsOn()`), `whenInapplicable()`, `inRowMenu()` and `$maxInlineButtons` for row actions, confirmation texts from a closure, file fields in action modals, `acceptsPrefill()` and `sticky()` for the create form, a `Badges` field, and sorting by several columns. Republish the assets (`php artisan martis:publish-assets`). Most apps change nothing; check these:

- **`dependsOn()` now reaches the form.** The form used to drop every change a callback made (required, options, help, label), since `dependsOn()` was introduced. A callback that was written but never seen to work now applies: check it does what its code says.
- **Relation titles.** A `BelongsTo`, `Tag`, `BelongsToMany` or `MorphToMany` without `titleAttribute()` now shows the related resource's `title()` when that resource declares one (a `titleAttribute()` other than `id`, or its own `title()`), instead of the `name` attribute. Where the cell showed `#4`, it now shows the record's title; a field that sets `titleAttribute()` is unchanged.
- **The detail page greys out the actions its record cannot run**, as the index rows do. Use `whenInapplicable(InapplicableAction::Hide)` to leave them out.
- **Sorting.** Every list ends its order on the primary key, so rows that tie on a non-unique column may change order. A relationship panel with no requested sort follows its related resource's `defaultSort()`. The schema's `defaultSort` is now `[{ attribute, direction }]` (no `defaultSortDirection`), and a custom `component:Table` override receives `sort` and `onSort(attribute, additive)` instead of `sortBy`, `sortDir` and `onSort(attribute)`. A saved index sort (sticky view) is read once in its old shape.
- **`ActionContract`.** `confirmText()`, `confirmButtonText()` and `cancelButtonText()` take `string|Closure`: a class that implements the contract directly (not through `Action`) widens the parameter type.
- **A queued action that receives an uploaded file throws** a `LogicException`, naming the action and the field.

## Upgrading to v2.16.6 from v2.16.5

v2.16.6 keeps the sort a user picks on an index after they open a record and come back: on a resource with a `defaultSort()`, the default used to override the restored sort. Nothing has to change in an app: republish the assets (`php artisan martis:publish-assets`). See [Sticky views](https://getmartis.com/docs/core/sticky-views#how-it-works).

## Upgrading to v2.16.5 from v2.16.4

v2.16.5 lets a `Textarea` field grow to fit its text, in a form and in an action modal: a stylesheet rule held it at its row count with the rest of the text hidden and no way to scroll to it. Nothing has to change in an app: republish the assets (`php artisan martis:publish-assets`). See [Fields → Textarea](https://getmartis.com/docs/core/fields#textarea).

## Upgrading to v2.16.4 from v2.16.3

v2.16.4 lets the hour and minute of the `DateTime` time picker be typed, and keeps the action, pivot action and inline create modals open on a click outside them (they close through Cancel, the X and Escape). Nothing has to change in an app: republish the assets (`php artisan martis:publish-assets`). An extension that relied on a scrim click closing one of those modals now closes it through its own button. See [Fields → DateTime](https://getmartis.com/docs/core/fields#datetime) and [Actions](https://getmartis.com/docs/core/actions#how-it-looks).

## Upgrading to v2.16.3 from v2.16.2

v2.16.3 changes no behaviour: it audits the test suite and documents how a model related to itself lists the parent record among its attachable records, and how to leave it out. Nothing has to change in an app. See [Relationships → A model related to itself](https://getmartis.com/docs/core/relationships#a-model-related-to-itself).

## Upgrading to v2.16.2 from v2.16.1

v2.16.2 wraps long titles and values in the peek card instead of truncating them. Nothing has to change in an app. A card with a long value is now taller, not wider: it keeps its 20rem maximum width. See [Fields → Peek / Preview](https://getmartis.com/docs/core/fields#peek--preview).

## Upgrading to v2.16.1 from v2.16.0

v2.16.1 floats the picker lists above modals and drawers, lets Escape close an open picker before the modal it is in, and keeps a resource's sidebar item active on a filtered index. Nothing has to change in an app. Check these points:

- **The picker lists render in `document.body`** (`BelongsTo`, `MorphTo`, `Tag`, `MultiSelect`, the icon picker, the Repeater's add menu, the pivot action menus), with `position: fixed` and the class `.martis-floating-panel` (`z-index: 10001`). A host stylesheet that reached a list through its picker (`.my-form .martis-belongs-to-dropdown`) no longer matches it; target `.martis-floating-panel` or the list's own class. `.martis-belongs-to-dropdown` lost its `position: absolute`, `left`, `right`, `z-index` and `margin-top` and became a flex column (`.martis-belongs-to-options` gained `flex: 1 1 auto; min-height: 0`): a component of your own that borrowed the class for an `absolute` panel sets those itself, and a stylesheet that sets `display` on it keeps `flex`. See [Components → Picker lists float above modals and drawers](https://getmartis.com/docs/customization/components#picker-lists-float-above-modals-and-drawers-v2161).
- **A resource's sidebar item stays active when the URL carries a `?filters=` that no `MenuItem::filter()` item of the menu carries** (a link into a filtered index, the Filters panel). A filter item with that payload still takes the highlight alone. See [Menus → Which item is active](https://getmartis.com/docs/core/menus#which-item-is-active).
- **In the action, pivot action and inline create modals, Escape closes an open picker, menu or PrimeReact overlay first**, and the modal on the next Escape; of two stacked modals, only the top one closes. Up to v2.16.0 one Escape closed both. The modals now listen in the capture phase: an override that stops an Escape in a capture listener of its own on `document` registered earlier keeps the modal open, as it already kept a drawer open.

## Upgrading to v2.16.0 from v2.15.x

v2.16.0 opens action fields and create forms on their `default()`, shows help text and tooltips in the action modals, gives a single-record action's `fields()` its record and gives custom field displays the record they render. Nothing has to change in an app. Check these points:

- **Forms and action modals now open on the fields' `default()`.** Up to v2.15.x the create page, the create drawer, the inline create modal and every action modal opened empty, whatever the field declared. A field whose default was only meant for the run (the value `handle()` receives when the request cannot set the field) is now also shown in the modal, and a create form now submits the defaults the user leaves untouched. Remove a `default()` you did not want on screen, or move it to the model or the database. An app that overrides the action modal (`component:ActionModal` in the registry) seeds its own state and is unchanged. A custom action component (`->component()`) now also posts the declared fields' defaults, under the values it sends itself. Both action modals now fetch their fields again on every opening, so a default or help text computed from the record is never a stale copy.
- **`fields()` can read the record with `$this->selectedModel()`**, and must handle `null` (bulk, standalone, no record named). An app that hard-coded the record inside `fields()` can drop it. The fields endpoint answers 404 for a `resourceId` the run would refuse; nothing in the panel sends one otherwise. See [Actions → The record the action runs on](https://getmartis.com/docs/core/actions#the-record-the-action-runs-on).
- **The index and lens toolbars no longer list a standalone action that is kept off the index** (`exceptOnIndex()`, `onlyOnDetail()`, `onlyInline()`), as Nova filters the index's actions by where they are shown; up to v2.15.x the toolbar listed every standalone action. Call `showOnIndex()` on one you want there.
- **An Action class of your own that declares a method named `selectedModel()` or `withSelectedModel()`** now overrides the base class's: rename it, or PHP refuses an incompatible signature.
- **The command palette no longer offers a standalone action that is kept off the index** (`exceptOnIndex()`, `onlyOnDetail()`, `onlyInline()`): the shortcut opens the index, where such an action has no button. Show it on the index to keep it in the palette.
- **A custom field display receives `resourceKey`, `resourceId` and `context`.** A display that spreads its props onto a DOM element now passes them through too; pick the props it renders instead. To use `useRunAction()`, `useRefreshRecord()` and the new types in an extension, republish the shims: `php artisan vendor:publish --tag=martis-extension-shims --force`.

## Upgrading to v2.15.0 from v2.14.x

v2.15.0 opens the tab of an `openInNewTab()` answer reliably, fills the blank confirmation modal, keeps the notifications and preferences panels inside a phone screen and lets "Keep me signed in" survive the 2FA challenge. Nothing has to change in an app. Check these points:

- **Security posture: "Keep me signed in" no longer asks a 2FA user for the code on every new session**, as in Nova, and it is on by default: a device that passed the challenge while holding the remember-me cookie keeps the pass when the cookie starts a new session (a `martis_two_factor_remembered` marker cookie, see [Authentication → "Keep me signed in" with 2FA](https://getmartis.com/docs/auth/authentication#keep-me-signed-in-with-2fa-v2150)). Sessions from before the upgrade meet the challenge once more. The remember-me cookie and the marker together then skip the code until sign-out, a password change or a new 2FA secret, so on shared computers users should not tick "Keep me signed in". Set `MARTIS_2FA_TRUST_REMEMBER_ME=false` to keep the challenge on every new session, the behaviour up to v2.14.x. A published `config/martis.php` without the key keeps the default (on).
- **Declare `opensInNewTab()` on an action whose run answers `ActionResponse::openInNewTab()`.** Without it the tab still opens after the request, where Safari and slow runs block it (a warning toast with a link now says so). With it the tab opens on the click; an action that answers something else would flash a blank tab, so declare it only where the answer is `openInNewTab()`. An app that overrides the action modal (`component:ActionModal` in the registry) does not get the early tab. See [Actions → Opening a new tab](https://getmartis.com/docs/core/actions#opening-a-new-tab).
- **The page an `openInNewTab()` answer opens now receives the panel's origin as the referrer.** The tab was opened with `noopener,noreferrer`, which made a blocked tab undetectable; the panel now opens a blank tab, cuts its `opener` and then sends it to the URL, with or without `opensInNewTab()`. Under the browser's default referrer policy another site receives the panel's origin only, not the path. Serve the panel with `Referrer-Policy: no-referrer` if that matters.
- **An action with `withoutConfirmation()` and fields waits for its fields.** It used to run as soon as it was clicked, before its fields loaded (so without them, on a cold cache); it now opens the modal for them. A run without a modal that fails now closes, so the action can be run again.
- **A confirmation modal without `confirmText()` shows "Are you sure you want to run this action?"**, also above the fields of an action that has them, as Nova does. Set `confirmText()` for your own sentence; override `actions.confirm_run` in `lang/vendor/martis` to change the default.
- **A theme that restyles `.martis-notif-panel`** keeps `right: var(--martis-notif-shift, 0px)` and `max-height: min(420px, var(--martis-notif-room, 420px))`, or the panel runs off a phone screen again. See [Notifications → On a phone](https://getmartis.com/docs/reference/notifications#configuration).

## Upgrading to v2.14.0 from v2.13.x

v2.14.0 keeps the shell inside the screen on a phone and moves the top-bar slots out of a phone's top bar. Nothing has to change in an app that keeps the bundled sidebar and top bar. Check these points:

- **On a phone the `topbar:start` / `topbar:end` components render outside the top bar.** The sidebar preset renders them in the navigation drawer, under the brand; the topnav preset at the end of the breadcrumb strip. Up to v2.13.0 they stayed in the bar, where a component wider than the room left (a 180px switcher, or only 44px next to long breadcrumbs) widened the shell past the viewport and cut off the right part of every page. A component that should look different in the drawer reads the new `placement` prop (`'topbar'`, `'drawer'` or `'breadcrumbs'`, type `TopbarSlotProps` from `@martis/runtime`; republish the extension shims for the type). An app with a custom sidebar (`layout:sidebar`) gets no slots on a phone: render the component in that sidebar. An app with a custom top bar (`layout:topbar`) that renders a slot component itself skips it when the bar receives `onToggleSidebar` (a phone), or it renders twice, in the bar and in the drawer. See [Overrides → On a phone](https://getmartis.com/docs/customization/overrides#on-a-phone-v2140).
- **The breadcrumbs truncate.** In the sidebar preset's top bar and in the topnav strip, a crumb that does not fit ends in an ellipsis instead of pushing the bar wider; the icons and the user menu keep their room. A host stylesheet that relied on the breadcrumbs overflowing can target the new `.martis-breadcrumbs`, `.martis-breadcrumb` and `.martis-breadcrumb-label` classes.
- **The shell's content column is `minmax(0, 1fr)`** (it was `1fr`) in the sidebar preset, on a wide screen and on a phone, so its content can never make it wider than the room it has. A custom top bar (`layout:topbar`) whose items need more than the screen now overflows the bar instead of widening the page.

## Upgrading to v2.13.0 from v2.12.x

v2.13.0 makes the PrimeReact components follow a theme's font, adds the `--martis-font-feature-settings` theme variable, and links a configured favicon with a `type` that matches the file. Nothing has to change in an app at run time. Check these points:

- **A theme font reaches the PrimeReact components.** Up to v2.12.0 the PrimeReact `--font-family` and `--font-feature-settings` variables kept a literal Inter, so dropdowns, menus, overlay panels, toasts and inputs ignored `--martis-font-sans`. They now read the theme tokens. A theme that worked around it by setting `--font-family` / `--font-feature-settings` itself can drop those two lines.
- **`--martis-font-feature-settings` is a new theme variable** (163 in all), default `'cv02', 'cv03', 'cv04', 'cv11'` (Inter's character variants, as before). A theme with another font family sets it to `normal` or to that family's features. An existing theme without it keeps the default; `php artisan martis:theme:diff` lists it under "Missing in consumer" and exits `2`, so a CI gate that runs it fails until the theme declares the variable (`--martis-font-feature-settings: normal;` for another family, or the Inter default).
- **The configured favicon is linked once.** With `MARTIS_FAVICON` set, the panel linked the file twice, as `image/x-icon` and as a 32x32 `image/png`. It now emits one `<link rel="icon">` typed by the extension (`.svg` → `image/svg+xml`, `.png` → `image/png`, `.ico` → `image/x-icon`; no `type` for an extension it does not know). An app that published `resources/views/vendor/martis/app.blade.php` keeps its own copy: republish it or apply the change by hand.

## Upgrading to v2.12.0 from v2.11.x

v2.12.0 adds `preferences.editable` (`MARTIS_PREFERENCES_EDITABLE`): the preferences users may change. Unset, every preference stays editable, as before. Check these points:

- **Published config.** A `config/martis.php` published before v2.12.0 has no `editable` key and keeps every preference editable. To use the switch, add the key to the `preferences` block (copy it from the package config) or republish the config: setting `MARTIS_PREFERENCES_EDITABLE` without the key throws an `InvalidArgumentException` that says so, as does an unknown name in the list. See [Preferences → Choosing which preferences users may change](https://getmartis.com/docs/reference/preferences#choosing-which-preferences-users-may-change-v2120).
- **`theme.allowToggle => false` is applied by the server.** Up to v2.11.1 it only hid the theme pickers: a theme a user had stored earlier still applied, and `PUT /api/preferences` still saved one. Now the stored theme is ignored, everyone gets `preferences.defaults.theme` (`MARTIS_DEFAULT_THEME`), and a request that changes the theme answers `422`. A request that repeats the theme in effect is accepted, so the bundled SPA and a client sending its full state keep working.
- **`allowBrandColor => false` (the default) is applied by the server.** Up to v2.11.1 it only hid the hex input: `PUT /api/preferences` still saved a `brandColor`, and a stored one applied. Now a hex is refused with `422`, a stored one is ignored, and an accent stored as `custom` resolves to `preferences.defaults.accent`. `brandColor: null` is still accepted and still clears a `defaults.brandColor` when a user picks a swatch. If your users rely on a stored hex, set `MARTIS_ALLOW_BRAND_COLOR=true`.
- **`meta.editable`** is a new key of the `/api/preferences` response, and `window.MartisConfig.preferences.editable` a new key of the shell config.

## Upgrading to v2.11.1 from v2.11.0

v2.11.1 makes the detach and the pivot update of a `BelongsToMany` / `MorphToMany` panel find the related record through the relation, makes the attach answer a missing id like a record its picker hides, and writes the pivot through the relationship's `relatedKey`. Nothing has to change in an app; check these points:

- **A record that is not attached answers `404`.** `DELETE .../{relatedId}/detach` and `PUT .../{relatedId}/pivot` answered `200` for a record that exists but is not attached to the parent (the detach changed nothing), and `404` for an id that does not exist, so the routes told another tenant's records apart from missing ids. Both now answer `404` with the body of a missing id (`Related record not found.`), before the `detach{Model}` / `updatePivot{Model}` policy is asked. A client that detaches the same record twice gets `404` on the second call instead of `200`; the panel shows the message in its detach dialog. An `authorizedToDetach()` override or a `detach{Model}` ability written only to refuse records that are not attached can go. See [Relationships → Only an attached record is detached](https://getmartis.com/docs/core/relationships#only-an-attached-record-is-detached).
- **The attach of a missing id answers `422`.** `POST .../attach` answered `404` (`Related record not found.`) for an id that names no record and `422` (or `403` from `attach{Model}`) for a record the picker hides, so it told them apart too. A missing id now fails like a hidden record, `422` on `related_id` with the same body, and the policy is only asked about records the picker lists. In a batch (`related_ids`) a missing id now fails the whole batch with `422` instead of being listed in `meta.errors` and skipped. A client that read the attach's `404` reads the `422` now. See [Relationships → Writes follow the pickers](https://getmartis.com/docs/core/relationships#writes-follow-the-pickers).
- **A relationship with a custom `relatedKey` attaches the right value.** For `belongsToMany(Skill::class, 'course_skill', 'course_id', 'skill_code', 'id', 'code')` (or the `morphToMany()` equivalent) the attach wrote the skill's primary key into `skill_code`, and the detach and the pivot update looked the row up by it and found nothing (the detach answered `200`). They now use `code`, as Eloquent does. Rows attached through the panel before v2.11.1 hold the primary key in that column: check them if you use such a relationship.
- **An id an integer key cannot hold no longer reaches the database.** `.../abc/detach` and `.../abc/pivot` answer `404`, and an attach of `abc` answers `422`; on PostgreSQL they answered `500`.

## Upgrading to v2.11.0 from v2.10.x

v2.11.0 fixes how Martis reads a policy that returns `Illuminate\Auth\Access\Response`, lets such a denial answer its status, and stops a headless resource from standing in for the routable resource over the same model. Check these points:

- **A policy that returns `Response::deny()` now denies.** Up to v2.10.1 Martis cast a Resource policy's answer to a boolean, and an object is always `true`, so `Response::deny()`, `Response::denyAsNotFound()` and `Response::denyWithStatus()` allowed the request (a security fix, also in v1.39.8). If a policy of yours returns a `Response`, the users it refuses now get what it says: review those policies, since what used to pass silently is refused now. Policies that return booleans behave as before. See [Authorization → Policy responses](https://getmartis.com/docs/auth/authorization#policy-responses).
- **`Response::denyAsNotFound()` answers `404` on the record endpoints.** A record whose `view`, `update`, `delete`, `restore`, `forceDelete` or `replicate` the policy refuses this way answers `404`, with the body of a missing record, instead of `403`; `Response::denyWithStatus()` answers its status. To hide which records a narrower resource can reach (a headless relation target's `peek`, for example), deny them this way. See [Authorization → HTTP responses](https://getmartis.com/docs/auth/authorization#http-responses).
- **The routable resource wins over a headless one over the same model.** The audit log's target label and link, the resource a `BelongsTo` without `relatedResource()` checks its value against, the Action Events panel and the command palette's Recent block (an entry's link, and the audit-log resource that gates the block) now use the routable resource even when a headless one is registered first. A `BelongsTo` without `relatedResource()` that answered `422` ("Declare relatedResource() on the field.") because a headless resource shared the related model is now checked against the routable resource's `relatableQuery()` and policies; if it should be checked against the headless one, declare `relatedResource()` on the field. A workaround that rebound `ResourceRegistry` to sort routable resources first can go. See [Resources → A headless resource over a model that has a page](https://getmartis.com/docs/core/resources#a-headless-resource-over-a-model-that-has-a-page).
- **A Gate ability that returns a `Response` is read the same way by the ability cache and the denial audit.** With `martis.authz.request_cache` on, `RequestScopedAbilityCache::lookup()` returned `true` for an ability that returned `Response::deny()`; it now returns `false`. With `martis.audit.authz_denials` on, a Gate denial that is a `Response` is now written as `authz.denied`, where only a `false` answer was before, so expect rows you did not get earlier.
- **A policy without `replicate` hands the status of its `create` or `update` denial to the replicate prefill.** A policy that answers `update` with `Response::denyAsNotFound()` made the update route answer `404` and the prefill `403`; both answer `404` now.
- **Do not use `401` or `419` in `Response::denyWithStatus()` for a record.** The panel reads a `401` as an ended session and sends the user to the login page. See [Authorization → HTTP responses](https://getmartis.com/docs/auth/authorization#http-responses).

## Upgrading to v2.10.1 from v2.10.0

v2.10.1 makes the code Martis writes into an app pass PHPStan level 8, with or without Larastan: the provider `martis:install` publishes, the published `config/martis.php`, and what the generators and the `martis:invitations` and `martis:roles` scaffolds write. Nothing has to change in an app at runtime. If you analyse your app with PHPStan:

- **A provider you published earlier keeps the old gate.** `martis:install` never rewrites an existing provider, so a `viewMartis` gate published by v2.4.0 to v2.10.0 still checks the address against an empty inline list, which PHPStan reports as `Call to function in_array() with arguments mixed, array{} and true will always evaluate to false.` (`function.impossibleType`). Move the list into a typed property, as the stub now does: `/** * @var list<string> */protected array $panelEmails = [ 'admin@example.com',]; protected function registerGates(): void\{ Gate::define('viewMartis', fn ($user) => app()->environment(['local', 'testing']) || in_array($user->email, $this->panelEmails, true));}` Do not run `martis:install --force-provider` for this: it replaces the whole file, your menu, dashboards and gates included.
- **Errors that came from Martis's own types are gone, for code you wrote too.** A dashboard or a tool whose constructor takes no arguments (`parameter.missing`, from `@phpstan-consistent-constructor` on `Dashboard` and `Tool`), a filter's `apply()` and a lens's `query()` without generic types (`missingType.generics`), and a `relatableQueryUsing()`, `MartisSso::syncRolesUsing()` or `MartisSso::afterLogin()` arrow function that returns a value (`argument.type`) no longer fail. Remove the baseline entries or `@phpstan-ignore` comments you added for them.
- **Files a generator or a scaffold wrote earlier stay as they were.** They are yours. If PHPStan reports one (`InviteUser`, `ResendInvitation`, `RevokeInvitation`, `InvitationPolicy`, `BulkAssignRole`, a date filter), compare it with the package's stub in `vendor/martis/martis/stubs/`. If you published the stubs with `php artisan martis:stubs`, your copies in `stubs/martis/` win over the package's: bring them up to date the same way.

## Upgrading to v2.10.0 from v2.9.x

v2.10.0 fixes how conditional `required_*` rules validate, sends a pre-filled `BelongsTo` as its id, refreshes a relationship panel after a create made from it, widens what the extension shims expose, and lets a multi-tenant app scope Martis's cache. Nothing has to change in most apps. Check these points:

- **A field with only `required_if` (or another conditional `required_*` rule) is no longer required unconditionally.** Before, `Text::make('first_name')->rules(['nullable', 'required_if:type,person'])` got an asterisk and a literal `required` in front of its rules, so it was required on every request, and two mutually exclusive `required_if` fields made every create fail with a `422`. Now only the exact `required` rule (or `Rule::requiredIf(true)`) makes a field required, as in Nova: a conditional rule runs when the request is validated, and the field shows no asterisk. If you relied on the old behaviour, add `->required()` or `'required'`. See [Conditional required rules](https://getmartis.com/docs/core/fields#conditional-required-rules-v2100).
- **A `BelongsTo` map is reduced to its id before the rules run.** A create from a relationship panel posted `{ "contact_id": { "id": "13", "title": "Ana" } }`, which a `Rule::exists('contacts', 'id')` of yours saw as an array. The forms now send the id, and the write endpoints reduce a map for any client. A malformed id is still refused on the resource store, on update, on pivot rows and on an Action's fields (the parent key of a panel store is overwritten by the controller, not refused). A `fillUsing()` callback on a `BelongsTo` receives the id, not the map, and a map that names no id reads as an empty value. See [Relationships](https://getmartis.com/docs/core/relationships#writes-follow-the-pickers).
- **The forms shape their payload by the field's type.** The create and update forms reduced any object with `id` and `title` keys to its id (and the update form dropped any object with a `url` key), whatever the field: an untouched `KeyValue` or JSON `Code` field whose map had such keys was saved as an id or left out. Only a `belongs_to` value is reduced now, and only a stored file (`file`, `image`, `avatar`, `audio`) is left out of an update. A `BelongsTo` whose target is soft-deleted now posts `<attribute>_trashed: true` beside the id (Nova's opt-in), where the update form used to answer `422` on it for every save of the record.
- **A relationship panel shows the new record at once.** The create page invalidated only the resource's list, so the panel served its cached list for up to 30 seconds after the redirect; it now refreshes it, and an edit made from a `morph-many` or `morph-one` panel refreshes that panel (it refreshed the `has-many` one).
- **`react-dom` and `react-dom/client` for extensions.** The `react-dom` shim serves `createPortal`, `flushSync`, `unstable_batchedUpdates` and `version`; a new `react-dom/client` shim serves `createRoot` and `hydrateRoot`. To use them (a library such as `@dnd-kit/core` imports `unstable_batchedUpdates`), republish the shims, then bring the Vite config and `tsconfig.extensions.json` up to date, because the `react-dom/client` alias and path live there and the shims tag leaves them alone: `php artisan vendor:publish --tag=martis-extension-shims --force` Then add the alias and the `paths` entry shown in [Refreshing the extension scaffold](https://getmartis.com/docs/getting-started/installation#refreshing-the-extension-scaffold-after-an-upgrade) (`/^react-dom\/client$/` in `vite.extensions.config.ts`, `react-dom/client` in `tsconfig.extensions.json`), or refresh the whole scaffold with `php artisan martis:install --force` (it rewrites both files too: review the diff), and run `npm run build:extensions`. The legacy root APIs `render`, `hydrate`, `findDOMNode` and `unmountComponentAtNode` stay out: React 19 removed them. An extension built with the earlier shims keeps building and running. See [The react-dom shims](https://getmartis.com/docs/customization/overrides#the-react-dom-shims-v2100).
- **`ResourceIcon` is on `@martis/runtime`.** An extension imports it instead of bundling `@phosphor-icons/react` copies. Republish the shims (the command above) to import it by name. See [components.md § ResourceIcon](https://getmartis.com/docs/customization/components#resourceicon).
- **Multi-tenant apps can scope the cache.** `Martis::cacheScopeUsing(fn (Request $request): ?string => $request->getHost())` adds the host (or any value of the request) to every Martis cache key, so a menu, a schema, a dashboard, a metric or a lens that depends on the tenant is no longer served to another tenant for the TTL. Without a resolver every key is unchanged: an app that does not call it needs no action. The resolver also runs in a console command and a queue job, with the placeholder request Laravel builds from `APP_URL`: write it null-safe (`$request->user()?->tenant_id`). See [Scoping the keys by host or tenant](https://getmartis.com/docs/reference/cache#scoping-the-keys-by-host-or-tenant-v2100).
- **The UI locales and their labels come from `.env`.** `MARTIS_UI_LOCALES` (comma-separated, default `en,pt_PT,pt_BR`) replaces the literal `preferences.locales`, and `MARTIS_UI_LOCALE_LABELS` (`code:label` pairs, e.g. `"en_GB:English (UK),pt_PT:Português (Portugal)"`) is merged over `preferences.locale_labels`, which gains an `en_GB` label. A published `config/martis.php` keeps the old literals until you merge the new `locales`, `locale_labels` and `locale_labels_env` lines (or republish it with `php artisan vendor:publish --tag=martis-config --force`, after saving your own edits): the variables do nothing until then. An invalid locale code or a malformed label segment throws, naming the variable. See [Configuration → User Preferences](https://getmartis.com/docs/reference/configuration#user-preferences) and [i18n](https://getmartis.com/docs/customization/i18n#2-register-the-locale).
- **A locale the app does not offer is no longer applied.** A user's stored locale (say `pt_BR` after the list drops it), a preset's and a `MARTIS_DEFAULT_LOCALE` outside `preferences.locales` used to be applied as they were; they now fall back to the default, or to the first listed locale. The stored row is kept. See [Preferences](https://getmartis.com/docs/reference/preferences#locale-normalisation-v2100).

## Upgrading to v2.9.0 from v2.8.x

v2.9.0 keeps a dashboard's filters in its address and lets a custom card set them ([Filters in the URL](https://getmartis.com/docs/core/dashboards#filters-in-the-url-v290)), and answers a write a unique index refuses with a `422` on the field ([When the database index refuses the write](https://getmartis.com/docs/core/fields#when-the-database-index-refuses-the-write-v290)). Nothing has to change in an app. Check these points if you test the panel or its API:

- **A dashboard's address carries `?filters=`.** A change in the filter panel and a filter's `default()` rewrite it in place; a browser test that asserts the exact URL of a filtered dashboard sees the parameter. A card's `setFilters()` adds a history entry.
- **Custom cards receive a `setFilters` prop next to `filters`.** To type it, or to call `useDashboardFilters()`, republish the shims: `php artisan vendor:publish --tag=martis-extension-shims --force`, then `npm run build:extensions`.
- **A relationship panel answers a unique violation with `422`, not `500`.** Its create, update, attach and pivot update now answer as the resource endpoints do, with the field error when the index names a field of the form, and use the same messages for the other database errors (`This record cannot be modified because it is referenced by other records.`, `A required field is missing. Please check all mandatory fields.`, ...).
- **On SQLite, a `NOT NULL` or foreign key failure is no longer a duplicate.** SQLite reports every constraint with the same code, and these answered `422` "A record with this value already exists."; they now answer `500` with their own message. A test written against the old answer changes with it.
- **A unique violation's field error is the rule's message.** It reads as the field's `unique()` message, or Laravel's `validation.unique` line ("The Email has already been taken."), where it was the generic `A record with this value already exists. Please use a unique value.`, which stays the response's `message`.

## Upgrading to v2.8.0 from v2.7.x

v2.8.0 makes the profile's feature switches hold on the server, as Fortify's features do (see [Configuration → Profile](https://getmartis.com/docs/reference/configuration#profile)). Nothing changes for an app that leaves them on, the default. If you turned one off:

- **`MARTIS_2FA_ENABLED=false` now turns off the sign-in challenge.** A user who enrolled while 2FA was on is no longer challenged, and the 2FA routes answer `404` ([Turning 2FA off](https://getmartis.com/docs/auth/authentication#turning-2fa-off-v280)). Up to v2.7.0 such a user still met the challenge. Nothing logs it, so check before you upgrade: with the switch off, `php artisan tinker --execute="echo App\Models\User::whereNotNull('two_factor_confirmed_at')->count();"` (your Martis guard's user model) counts the accounts that stop being challenged. `martis:install` run without a TTY and without `--with-2fa` writes `MARTIS_2FA_ENABLED=false`, so an app installed from CI or a script may have the switch off without meaning to. If you turned the switch off only to hide the profile's 2FA section and want enrolled users challenged, turn it back on and leave `security` out of `profile.sections`: that hides the section and keeps 2FA in force.
- **`MARTIS_AVATAR_ENABLED=false` removes the avatar endpoints.** `POST` and `DELETE /api/profile/avatar` answer `404`.
- **`MARTIS_PROFILE_EMAIL_EDITABLE=false` refuses a new address with `422`.** A custom `ProfileResource` written only to drop `email` from `updateRules()` can go; one that does more can stay. A confirmation link mailed before the switch went off no longer applies.
- **Cache the routes again after you change a switch.** The routes are registered from the config, as `MARTIS_PROFILE_ENABLED` already did, so with `php artisan route:cache` run it again (`php artisan optimize` does both). A route cache built while 2FA was off has no challenge route: turning 2FA on without caching the routes again keeps enrolled users out ([Configuration → Profile](https://getmartis.com/docs/reference/configuration#profile)).
- No config key was added: a published `config/martis.php` keeps working. Only the comments of its `profile` block changed.

## Upgrading to v2.7.0 from v2.6.x

v2.7.0 makes the sidebar layout meet WCAG 2.2 AA for its collapsed rail and its mobile menu, adds a "Skip to navigation" link, a header-first order and two top-bar slots. Nothing has to change in an app that keeps the bundled shell; check these points if you test, style or replace it:

- **The sidebar is a navigation landmark.** Its root is now `<div id="martis-sidebar" class="martis-sb">` (it was `<aside aria-label="Resources">`, a complementary landmark), and everything in it sits in `<nav id="martis-navigation" class="martis-sb-nav" aria-label="Main navigation">`. A browser test that finds the sidebar by role finds it as `getByRole('navigation', { name: 'Main navigation' })`. A stylesheet that targets `aside.martis-sb` targets `.martis-sb`; one that uses a child selector (`.martis-sb > .martis-sb-scroll`) adds the `.martis-sb-nav` level.
- **The top bar's breadcrumb wrapper is a `<div>`.** `.martis-tb-breadcrumbs` was a second, unlabelled `<nav>` around the breadcrumbs' own `<nav aria-label="Breadcrumbs">`.
- **The closed mobile drawer is `inert`.** Its off-screen links leave the Tab order and the accessibility tree; a browser test that clicked one without opening the menu opens it first.
- **The new config keys need a published config updated.** Laravel does not merge nested keys, so a `config/martis.php` published before v2.7.0 ignores `MARTIS_LAYOUT_HEADER_FIRST` until you copy `'header_first' => (bool) env('MARTIS_LAYOUT_HEADER_FIRST', false),` into its `layout` block. The `topbar_start` and `topbar_end` component keys are optional: without them the slots read their default keys, `topbar:start` and `topbar:end`.
- **`overrides/TopbarStart.tsx` and `TopbarEnd.tsx` need two lines in your `index.ts`.** The file is your app's own, so an install scaffolded before v2.7.0 adds `TopbarStart: 'topbar:start',` and `TopbarEnd: 'topbar:end',` to its `OVERRIDE_KEYS`, or registers the component by hand ([Top-bar slots](https://getmartis.com/docs/customization/overrides#top-bar-slots-v270)).
- **A replacement sidebar or top bar keeps the skip links working with the same ids.** See [Keyboard and screen-reader contract](https://getmartis.com/docs/customization/overrides#keyboard-and-screen-reader-contract-v270): `id="martis-sidebar"` on the sidebar, `<nav id="martis-navigation" tabIndex={-1}>` around its menu, `id="martis-sidebar-toggle"` with `aria-controls` and `aria-expanded={sidebarOpen}` on the menu button. `useModalFocus` gives a replacement sidebar the drawer's focus handling; republish the shims to import it (`php artisan vendor:publish --tag=martis-extension-shims --force`).

## Upgrading to v2.6.0 from v2.5.x

v2.6.0 moves the one-time token of the password-reset, invitation and magic-link emails into the URL fragment, out of the request line that proxies and web servers log. See [One-time links keep the token out of the request line](https://getmartis.com/docs/auth/authentication#one-time-links-keep-the-token-out-of-the-request-line).

- **Links already mailed keep working until they expire.** `GET /reset-password/{token}`, `GET /invitations/accept/{token}` and `GET /magic-link/confirm?email=…&token=…` redirect to the fragment form. These legacy routes are removed in v3.0.0.
- **An auth page override that reads the token from the route breaks.** `auth:reset-password` and `auth:invitation-accept` are now mounted at `/reset-password` and `/invitations/accept`, without a `:token` segment, so `useParams().token` and `useSearchParams().get('email')` return nothing. Read them with `useAuthLinkParams()` from `@martis/runtime` ([Auth page overrides and emailed links](https://getmartis.com/docs/customization/overrides#auth-page-overrides-and-emailed-links)), then `npm run build:extensions`. If your extension types come from published declarations, republish them first (`php artisan vendor:publish --tag=martis-extension-shims --force`).
- **A custom link builder should put the token in the fragment.** A `ResetPassword::createUrlUsing()` or `InvitationUrl::createUrlUsing()` callback that points at the Martis pages builds the link with `Martis\Support\TokenLink::url()`. A callback that still calls `route('martis.password.reset', [...])` or `route('martis.invitations.accept', ...)` now produces `?token=…` or a bare `?<token>`: both are redirected to the fragment form, but that first request still logs the token.
- **Tokens already in your access logs stay valid until they expire.** Purge those logs if people who should not hold the tokens can read them, or revoke and resend pending invitations (they live `MARTIS_INVITATIONS_TTL_HOURS`, 72 by default).
- **The magic-link page no longer checks the token on load.** An expired or invalid link shows the confirmation page, and the sign-in `POST` answers `422`, after which the page goes to `/login?magic_link=expired` (or `invalid`) as before.
- A log filter or WAF rule written for `/reset-password/{token}` or `/invitations/accept/{token}` should also match `/reset-password` and `/invitations/accept`.

**New brand settings and a published config.** v2.6.0 adds `brand.auth_logo`, `brand.auth_logo_dark`, `brand.manifest`, `brand.apple_touch_icon`, `brand.theme_color` and `loader.logoDark`, and `loader.logo` now reads `MARTIS_LOADER_LOGO`. Laravel does not merge nested config keys, so a `config/martis.php` published before v2.6.0 ignores their environment variables until you copy the new lines into its `brand` and `loader` blocks (from `vendor/martis/martis/config/martis.php`). See [A lockup for the authentication card](https://getmartis.com/docs/reference/configuration#a-lockup-for-the-authentication-card-v260), [Head tags](https://getmartis.com/docs/reference/configuration#head-tags-v260) and [Loader](https://getmartis.com/docs/customization/loader).

**A lockup wider than the menu's logo box is scaled, not cut.** A sidebar or top-navigation lockup wider than its box (208 px in the default sidebar, 220 px in the top navigation) used to lose its right-hand side; it now shrinks to fit, keeping its proportions. A theme that widened `--sidebar-width` only to avoid the cut can drop that rule. The loader logo is now scaled to fit its square instead of being stretched.

## Upgrading to v2.5.0 from v2.4.x

v2.5.0 serves the docs MCP server through the official [`laravel/mcp`](https://github.com/laravel/mcp) package instead of `php-mcp/server` and ReactPHP. `php-mcp/server` kept `symfony/finder` below 8 and, through `react/http`, `psr/http-message` at 1, so `composer require martis/martis` on a fresh Laravel 13 app had to downgrade `symfony/finder` and `guzzlehttp/guzzle`. It no longer does.

**If you published `config/martis.php`, replace its `mcp` block** with the package's new block (copy it from `vendor/martis/martis/config/martis.php`), or delete the block to use the package defaults. Laravel does not merge nested config keys, so your published block keeps the removed `host`, `port` and `health_port` keys. Until you fix it, Martis does not register the HTTP route (it logs a warning naming the old keys, such as `martis.mcp.port`, instead of failing the boot) and `martis:agents` refuses to wire the MCP.

### Requirements

- Laravel 12 apps need `laravel/framework` 12.41.1 or later (`laravel/mcp` requires `illuminate/json-schema` ^12.41.1). Laravel 13 is unaffected.
- `laravel/mcp` ^1.0 conflicts with `laravel/boost` before 2.9.0 and with a host's own `laravel/mcp` 0.x. Update them together (`composer update martis/martis laravel/boost -W`). If your own `composer.json` requires `laravel/mcp` 0.x, move the MCP servers you built on it to 1.x and raise that constraint first (`composer require laravel/mcp:^1.0 -W`).

### The docs MCP server

- `php artisan martis:mcp-serve` is removed. Over stdio an agent now spawns `php artisan mcp:start martis-docs`; over HTTP the server is a route of your app. Run `php artisan martis:agents --mcp-only` to rewrite your agents' MCP config and the `.env` block without touching your guideline files (`AGENTS.md`, `CLAUDE.md`, ...).
- The default transport is now `stdio`. An `.env` written by an earlier `martis:agents` usually carries `MARTIS_MCP_TRANSPORT=http`: with it, Martis registers the route `POST /{MARTIS_PATH}/mcp` (`/martis/mcp` by default), which outside the `local` environment requires `MARTIS_MCP_HTTP_TOKEN`. Set `MARTIS_MCP_TRANSPORT=stdio` (or delete the line) if you do not need HTTP.
- The default HTTP path changes from `/mcp` to `/{MARTIS_PATH}/mcp`, and the URL `martis:agents` writes is built from `APP_URL` instead of a host and port.
- `MARTIS_MCP_HOST`, `MARTIS_MCP_PORT` and `MARTIS_MCP_HEALTH_PORT` are removed with the standalone daemon and its `/health` endpoint. Delete them (commented placeholders an earlier `martis:agents` wrote can stay or go). `martis:agents` refuses to wire the MCP while one of them is set.
- `martis:agents` no longer overwrites an existing guideline file (`AGENTS.md`, `CLAUDE.md`, ...) when it runs without a terminal and without `--force`: it prints `<file> already exists` and keeps the file. Before, a run from an agent, CI or `docker compose exec -T` rewrote them. Pass `--force` where a script relied on the regeneration.
- `martis:agents` does not rewrite `.env` lines that already exist, so hints an earlier run wrote, such as `# MARTIS_MCP_URL=http://localhost:8091/mcp`, stay with their old values. Delete them (or set the new URL).
- Martis registers the service provider of `laravel/mcp` itself, so a `dont-discover` entry for `laravel/mcp` in your `composer.json` no longer applies. With Laravel Passport installed, `laravel/mcp` adds an `mcp:use` scope. In an app that calls `Mcp::oauthRoutes()`, the 401 of the Martis route advertises the app's OAuth metadata, but the route accepts only `MARTIS_MCP_HTTP_TOKEN`.
- A systemd unit or a docker-compose service that ran `martis:mcp-serve` as a daemon is no longer needed: the app itself serves the MCP.
- The three tools keep their names, inputs and payloads.

## Upgrading to v2.4.0 from v2.3.x

v2.4.0 closes a security audit. Several defaults now fail closed. Go through the list below: the first five concern most apps. Then republish the config, the language files and the assets:

```
php artisan vendor:publish --tag=martis-config --force   # or add the new keys by hand
php artisan martis:publish-assets
php artisan optimize:clear
```

### Define the viewMartis gate

When the app defines no `viewMartis` gate, the panel now answers `403` outside the `local` and `testing` environments, as Nova's `viewNova` does. Define it in `app/Providers/MartisServiceProvider.php`; the stub `martis:install` publishes ships it active:

```
/**
 * @var list<string>
 */
protected array $panelEmails = [
    // 'admin@example.com',
];
 
protected function registerGates(): void
{
    Gate::define('viewMartis', fn ($user) => app()->environment(['local', 'testing']) || in_array($user->email, $this->panelEmails, true));
}
```

(v2.4.0 shipped the list inline in the `in_array()` call; v2.10.1 moved it to the property, see [Upgrading to v2.10.1](https://getmartis.com/docs/getting-started/upgrading#upgrading-to-v2101-from-v2100).)

To keep a non-local environment open without a gate, list it in `MARTIS_PANEL_OPEN_ENVIRONMENTS` (default `local,testing`). A resource without a policy stays open to every panel user, as in Nova, and now logs a warning outside those environments. See [Authorization → Panel access](https://getmartis.com/docs/auth/authorization#panel-access-viewmartis).

### Set APP_URL

Every emailed link (magic link, password reset, invitation, email verification, email change) is built from `APP_URL`, never from the request's host. Set it to the URL the panel is served on, as an absolute `http(s)` URL; Martis throws a clear error otherwise. Signed links are signed over that host, so serve them there.

### File uploads refuse active content

A `File` field answers `422` for HTML, SVG, XML, script and PHP files (`html`, `htm`, `xhtml`, `shtml`, `svg`, `svgz`, `xml`, `xsl`, `js`, `mjs`, `php`, `phtml`, `phar`, `php3`-`php8`, `pht` and similar). A field that must accept one lists it in `acceptedTypes()` or calls `allowActiveContent()`; serve such files from another origin or as downloads. `Image` is unchanged.

### Lenses start from the resource's index scope

A lens query now applies the resource's `scopes()` and `indexQuery()`, as the index does, for its rows, actions, filters, counts and cards. A lens that deliberately shows what the index hides (an all-tenants view for staff) opts out with `public static bool $withoutIndexScope = true;` (see [Lenses](https://getmartis.com/docs/core/lenses)).

### A BelongsTo without relatedResource()

It now resolves the resource of the related model and runs the relatable checks, `viewAny` and the policies on writes. A related model with no registered resource is refused (`422`); a `relatedResource()` key that is not registered throws, naming the field and the key. Declare `relatedResource()` or register the resource.

A `BelongsTo` or `MorphTo` value whose id is a JSON boolean, a non-integer number, a list or a nested map answers `422` and is never written (a boolean `true` used to reach the foreign key as `1`, past the relatable checks).

### Magic links

The emailed link opens a confirmation page, and the sign-in is `POST /api/auth/magic-link/consume`. Links mailed before the upgrade still work: a `GET` of the old URL is redirected to the confirmation page. A custom client posts `email` and `token` (and `replace_session: true` to replace another user's session).

### Profile email change

`PATCH /api/profile` needs `current_password` when the email changes and answers `pending_email`: the address changes when the user follows the link sent to it (`MARTIS_PROFILE_EMAIL_CHANGE_TTL`, 60 minutes by default). A custom profile client sends the password and shows the pending state. The mailed link opens a confirmation page and changes nothing; the change is a CSRF-protected `POST` to the same signed URL (a client that followed the link with a `GET` must now post). Asking for the mail is limited to 5 per hour per user and per address (`MARTIS_PROFILE_EMAIL_CHANGE_ATTEMPTS`, `0` turns it off), answering `429` past it.

### SSO

- `identity_match_attribute => 'email'` adopts a local account only when its email is verified (`email_verified_at`) or `martis.auth.registration.enabled` is `false`. With registration open, an existing row with an unverified address now refuses the SSO sign-in (`sso_account_unverified`) instead of being adopted: verify it, link it by `external_id`, or remove it. A model without an `email_verified_at` column cannot prove a verified address, so with registration open it is refused too: add the column (and verify the rows), or use `identity_match_attribute => 'external_id'`. With registration disabled nothing changes.
- Sign-in no longer sets a remember-me cookie. Set `'remember' => true` on a provider to restore it; access then outlives the IdP session. Remember-me cookies issued while SSO always remembered (v2.3 and earlier) are **not revoked** by the upgrade: they stay valid until they expire (`auth.guards.{guard}.remember`, 576000 minutes by default) or the user's `remember_token` rotates, so access removed at the IdP does not end those sessions. To end them at once, rotate the `remember_token` column of the SSO users (`UPDATE users SET remember_token = NULL`) or sign them out.
- The SSO origin cookie written by v2.3 (no server-held nonce) no longer counts: a user who comes back through a remember-me cookie loses the SSO origin until the next SSO sign-in, so the forced password change gate may meet them as a password user, and the federated logout is skipped.
- Azure: set `tenant` (`AZURE_TENANT_ID`) so identities of other tenants are rejected; `martis:sso azure` scaffolds it. A multi-tenant registration should match accounts by `external_id`.

### Two-factor authentication and sign-in limits

- Users with 2FA meet the challenge once after the upgrade (the old session flag is not trusted).
- Regenerating recovery codes needs `current_password`; the user is emailed.
- New limits, all configurable: `MARTIS_LOGIN_THROTTLE_EMAIL_ATTEMPTS` / `_MINUTES` (100 wrong passwords in 15 minutes per account, whatever the IP, counted until a right one clears them; the magic-link request has a bucket of its own; 0 turns it off), `MARTIS_2FA_THROTTLE_ATTEMPTS` (5), `MARTIS_2FA_THROTTLE_IP_ATTEMPTS` (15), `MARTIS_2FA_THROTTLE_MINUTES` (1), `MARTIS_2FA_LOCKOUT_ATTEMPTS` (5, 0 turns it off) and `MARTIS_2FA_LOCKOUT_MINUTES` (15).
- Forgot password answers `200` for an unknown email too.
- The `id` of a row of the browser sessions API is now an opaque handle (an HMAC of the session id), sent back to revoke a session; the raw session id revokes nothing.

### Impersonation

The `martis-impersonate` gate receives the target as its second argument; a one-argument closure keeps working, and a two-argument one can refuse a target that outranks the operator. A closure that needs the target refuses an id that does not exist. `canImpersonate()` on the operator and `canBeImpersonated()` on the target are honoured. `Impersonation::start()` throws `ImpersonationRefusedException`, still a `RuntimeException`.

### Queued actions with a secret field

A queued action (`ShouldQueue`) whose `Password` or `sensitive()` field has a value is dispatched with an encrypted payload: workers need the app's `APP_KEY` to read it (they already do for sessions and any `ShouldBeEncrypted` job). Jobs already queued before the upgrade keep their plain payload.

### Audit log of a hard-deleted record

The values in the events of a record that no longer exists (hard-deleted) are masked for everybody. Before, they were judged on a model hydrated from the event (or, in earlier builds, skipped `view`), so a tenant fence kept in a global scope did not hold after the delete. To let some viewers read them, define the record-independent gate: `Gate::define('martis-view-deleted-audit', fn ($user, string $resourceClass, string $uriKey) => $user->is_auditor)`. The `viewAny` of the resource and the field visibility still apply when it allows. Soft-deleted records are unchanged: they are still loaded and judged.

### Plan locks

`lockedFor()` and `requirePlan()` now answer `403` with `locked: true` and the `lock` on every data endpoint, the dashboard card compute route included (it answered `200 { locked: true }`). A route of your own that names a Martis resource, dashboard or tool adds the `martis.gate` middleware to be gated the same way.

### Relationships, actions and fields

- Relationship flags (`canCreate`, `canUpdate`, `canDelete`, `canAttach`, `canDetach`) are enforced by the server: a request through a relationship whose flag is off answers `403`.
- A Trix or Markdown field that accepts attachments declares `withFiles()` (with a disk when not the panel's storage disk). Schedule `martis:attachments:prune`. A custom caller of `POST /api/attachments/upload` sends the resource, field and record.
- A hook that wants its message shown to the user throws `Martis\Exceptions\UserFacingException`; any other exception gives a generic message outside `app.debug`.
- A custom BooleanFilter `apply()` receives only the keys its `options()` declares: read explicit keys, never use them as column names.
- A metric `?range=` outside `ranges()` falls back to the default; Sparkline writes above `maxPoints` or with non-numeric items fail validation.
- `ActionResponse::redirect()`, `download()` and `openInNewTab()` refuse `javascript:`, `data:` and other non-http(s) URLs.
- A custom picker that read another column from a relatable row names it as the field's title or subtitle attribute; the context-free relatable endpoint serialises only the related resource's declared `titleAttribute()` (when sent as `title_attribute`) and ignores `subtitle_attribute` and any other column.
- `api_docs.middleware` defaults to `null`, the Martis stack; a published `['web', 'auth']` is completed with the Martis guards.
- Field `help()`, HTML tooltips and gate messages (`messageHtml`) lose `style`, `id`, `name`, forms and form controls when rendered (scripts, handlers and unsafe URLs were already removed). An inline `style="color:red"` no longer applies: use a `class` (kept for this developer-written markup) or plain text.
- Markdown and Trix content keeps only the classes legitimate content carries (`language-*` on Markdown code, the Trix attachment classes) and loses `label`, `fieldset`, `legend`, `datalist`, `output` and every `<input>` but the disabled task-list checkbox. A document that relied on utility classes (`class="text-red-500"`) loses them.
- A notification `action_url` and an external menu link in the command palette may be `mailto:` / `tel:` and open in a new tab, as in the sidebar.

### Extensions

- An element that set `data-pr-tooltip-html="true"` by hand shows its text literally: use `{...htmlTooltip(text, position)}` or `trustHtmlTooltip(el)` from `@martis/runtime`.
- To run the panel under a Content-Security-Policy, set a nonce (`Vite::useCspNonce()`) and send `script-src` / `style-src` with it: see [Configuration → Content Security Policy](https://getmartis.com/docs/reference/configuration#content-security-policy-v240).
- `vite.extensions.config.ts` is copied once: set `build.sourcemap` to `false` in yours and delete `public/vendor/martis-user/extensions.js.map`.
- Build API paths with `apiPath` / `pathSegment` from `@martis/runtime`, not `encodeURIComponent()`: Laravel decodes `%2F` before routing, so an encoded slash in a record key could reach another route. Refresh the shim with `php artisan vendor:publish --tag=martis-extension-shims --force` to import the named exports (the default export `runtime.apiPath` works without it). A path value holding a literal `%2F` now throws `ApiError` (400) and sends nothing.

### Scaffolds

Generated code is yours and keeps its old behaviour. Review policies generated by `martis:policy` before v2.4.0 (they allowed everything). Regenerate the invitation and role actions (`martis:invitations --force`, `martis:roles --force`) or apply their role check by hand; republish customised stubs (`martis:stubs`). `martis:user --password` warns: use `--password-stdin`.

## Upgrading to v2.3.0 from v2.2.x

Nothing is required unless one of the changes below concerns your app.

### Password rules follow Password::defaults()

Every Martis surface that sets a password validates with your app's `Password::defaults()`: Profile, registration, password reset, invitation accept and `martis:user` (see [Authentication → Password rules](https://getmartis.com/docs/auth/authentication#password-rules)). Without app defaults:

- **Profile** accepts any password of 8 or more characters. It required mixed case and numbers; declare them to keep that: `Password::defaults(fn () => Password::min(8)->mixedCase()->numbers())`.
- **`martis:user`** refuses a password shorter than 8 characters. A boot script that sets a shorter one exits 1.

**A `Password::defaults()` that Laravel cannot use now fails loudly.** `Password::default()` replaces anything that is not an `Illuminate\Contracts\Validation\Rule` with `Password::min(8)`, silently: a closure that returns an array of rules, a string, or a `ValidationRule` (what `php artisan make:rule` generates) was enforced nowhere. Martis now throws an `InvalidArgumentException` naming what the closure gave. Return a `Password` rule (`Password::min(12)->mixedCase()`) or another `Rule`. A closure that returns `null` still means "unset".

The `require*` methods of the `Password` field check Unicode classes, as Laravel's `Password` rule does: an accented capital counts as uppercase, and an accented letter no longer counts as a symbol.

### martis:user reads the password from standard input

`--password=...` stays, but `ps` shows it. Pipe it instead:

```
printf '%s\n' "$MARTIS_ADMIN_PASSWORD" | php artisan martis:user --no-interaction --email="$MARTIS_ADMIN_EMAIL" --password-stdin --if-missing
```

### Action responses

- **`ActionResponse::visit($path)`** navigates inside the SPA, to `$path` below the Martis base path, with its `$params` as the query string. It used to load the page in full and drop the params. Drop the base path from your calls: `visit('/martis/resources/users')` becomes `visit('/resources/users')`.
- **`modal()`** shows its component and **`emit()`** emits on `martisEventBus`; both used to show the generic success toast. Register the component of a `modal()` answer (see [Actions → Custom modal responses](https://getmartis.com/docs/core/actions#custom-modal-responses)).
- **`download()`** names the file after `$filename`.
- **The built-in bus events fire.** `martis:record-created/updated/deleted/restored` and `martis:action-executed` were documented and never emitted. A listener that never ran will now run. Their `id` (and the `ids` of `martis:action-executed`) is always a string, whatever emitted it: compare with `String(record.id)`, not a number.

### Action events honour $visible

A model that declares `$visible` logs only those attributes in its action events (`$hidden` ones stay masked).

### Forced password change (opt-in)

New, off by default: see [Authentication → Forced password change](https://getmartis.com/docs/auth/authentication#forced-password-change). An app with a published `config/martis.php` must copy the new `password_change` block into `auth` first: without it `MARTIS_AUTH_PASSWORD_CHANGE_ENABLED` is never read. A Tool whose routes pass their own middleware list gets a log warning when the list leaves out `martis.password.changed` while the gate is on; leave the list out, or add the alias.

`password` is now a reserved first segment for [registered routes](https://getmartis.com/docs/customization/custom-pages#path-rules): the change page lives at `/{martis-path}/password/change`. A page you registered under `password/...` is refused with a console error; move it under another first segment.

### Refresh the extension scaffold

v2.3.0 adds `PasswordChangeRequiredError` and the type `ActionResponseModalProps` to `@martis/runtime`, and `.shims/i18next.d.mts`, the i18next types the shims share. Refresh the shims:

```
php artisan vendor:publish --tag=martis-extension-shims --force
```

Then add two entries to the `paths` of your `tsconfig.extensions.json` (or republish it with `php artisan martis:install --force`, which rewrites the whole scaffold):

```
"i18next": ["./resources/js/martis-extensions/.shims/i18next.d.mts"],
"@martis/testing": ["./vendor/martis/martis/dist/testing/testing.d.mts"]
```

Your own `resources/js/martis-extensions/index.ts` is not refreshed either. To override the new [forced password change](https://getmartis.com/docs/auth/authentication#forced-password-change) page, add its key to `OVERRIDE_KEYS`, next to `EmailVerifyNoticePage`:

```
PasswordChangePage: 'auth:password-change',
```

Without it the override builds but never renders: `php artisan martis:component --type=password-change-page` writes the component, then fails naming this line.

### Test your extensions

New: [Testing extensions](https://getmartis.com/docs/customization/testing-extensions). The kit needs your app's React on the panel's major. `martis:install` writes `^18 || ^19`, which gives a fresh app React 19, so run `npm install react@^18 react-dom@^18` first: a test run on another major fails before any test, naming that command. A test that creates its own i18next instance also needs `npm install --save-dev i18next@26.0.4`.

## Upgrading to v2.2.0 from v2.1.x

Nothing is required.

### New names on @martis/runtime

v2.2.0 adds `routeRegistry`, `useDynamicCrumb`, `ForbiddenPage` and `NotFoundPage` (see [Custom pages](https://getmartis.com/docs/customization/custom-pages)). `martis:install` publishes the runtime shim once, so an existing extension imports them by name after refreshing it:

```
php artisan vendor:publish --tag=martis-extension-shims --force
```

Until then, read them from the default export: `import runtime from '@martis/runtime'`, then `runtime.routeRegistry`.

### Auth page overrides now render

An override registered under `auth:login`, `auth:register`, `auth:forgot-password`, `auth:reset-password`, `auth:email-verify-notice` or `auth:invitation-accept` never rendered: the router read those keys before the extension bundle loaded. From v2.2.0 it renders. If your extension still registers one you no longer want (for example `resources/js/martis-extensions/overrides/LoginPage.tsx`, which `martis:component --type=login-page` writes), delete it and run `npm run build:extensions`.

## Upgrading to v2.1.1 from v2.1.0

Nothing is required. The optional checks below concern apps set up with an earlier version.

### Migrations that add a column to Spatie's tables

`martis:roles --with-categories` and `martis:sso --with-migration` could date their migration at or before Spatie's `create_permission_tables`, which Spatie dates one second ahead. On a database built from scratch (CI, a new machine, a first deploy), the Martis migration then ran first, found no table and skipped its column. Check the order:

```
ls database/migrations | grep -E 'create_permission_tables|add_category_column_to_permissions_table|_group_name_to_roles_table'
```

If a Martis file is listed before `create_permission_tables`, rename it with a later timestamp, for example the current date and time (`2026_10_01_120000_add_category_column_to_permissions_table.php`). Laravel runs it once more under the new name: where the column exists, its `hasColumn()` check makes that run a no-op; where it is missing, the column is added. The old name stays recorded in the `migrations` table and the renamed file is recorded in a new batch, so do not roll that batch back with `migrate:rollback`: its `down()` would drop the column. On a database that already has the column, you can instead rename the row in the `migrations` table to the new file name.

### The extensions Vite config

`vite.extensions.config.ts`, which `martis:install` publishes, resolved its paths from `__dirname`, so with Vite 8 every `npm run build:extensions` warned that Vite's native config loader (announced as the default of a future major) does not support it. New installs resolve the paths from `import.meta.url`, which works with Vite 4 to 9, both config loaders and any Node version. To update an existing app, edit the file:

```
 import path from 'node:path'
+import {fileURLToPath} from 'node:url'
+
+// The directory of this file. `__dirname` only exists while Vite bundles
+// the config; Vite's native config loader does not define it.
+const rootDir = fileURLToPath(new URL('.', import.meta.url))
 
-const shimsDir = path.resolve(__dirname, 'resources/js/martis-extensions/.shims')
+const shimsDir = path.resolve(rootDir, 'resources/js/martis-extensions/.shims')
```

and, in `build.lib`:

```
-      entry: path.resolve(__dirname, 'resources/js/martis-extensions/index.ts'),
+      entry: path.resolve(rootDir, 'resources/js/martis-extensions/index.ts'),
```

Nothing breaks until Vite switches loaders, so the edit can wait. `php artisan martis:install --force` also writes the new file, but it rewrites every other scaffold file too.

## Upgrading to v2.1.0 from v2.0.x

v2.1.0 adds optional features and fixes. Nothing needs changing unless one of the two cases at the end of this section applies. There are three visible changes:

- **Dates and numbers follow the user's Martis language.** The following format in the language the user picked in Martis, not the browser's: Date, DateTime and Currency values, metric values, menu count badges and notification dates. With `pt_PT` a date reads `27/09/2026` and a number `1234,5`. The bundled `en` locale formats with US rules (`9/27/2026`, `1,234.5`) on every browser, where it used to follow the browser (`27/09/2026` on an `en-GB` browser). The Currency and Number inputs take the same decimal separator, and the metric charts their axes and tooltips. A Currency field with `locale()` keeps that locale. See [Internationalisation → Number and date formatting](https://getmartis.com/docs/customization/i18n#number-and-date-formatting).
- **The `aggregateVia()` tile shows a number, not EUR.** A column named like `amount`, `price` or `total` was shown as euros whatever the app's currency. It is now a plain number with up to two decimals.
- **A fresh install stops logging a 404 for the extensions bundle.** The shell leaves `/vendor/martis-user/extensions.js` out while the file does not exist, and `npm run build:extensions` brings it back with no other step. Any other `MARTIS_EXTENSIONS` URL is still loaded as configured.

New and optional:

- **Restrict the panel** with the `viewMartis` gate ([Authorization → Panel access](https://getmartis.com/docs/auth/authorization#panel-access-viewmartis)). Without it the panel is open only in the `local` and `testing` environments (v2.4.0; before, every signed-in user got in).
- **Install without migrating:** `php artisan martis:install --no-migrate`, then `php artisan migrate`.
- **Add palette commands** with `Martis::commandPalette()` ([Components → App commands](https://getmartis.com/docs/customization/components#app-commands-v210)).
- **Scope the notification centre** with `Martis::scopeNotificationsUsing()` ([Notifications → Scoping the notification centre](https://getmartis.com/docs/reference/notifications#scoping-the-notification-centre)).

When to act:

- **Your users expect another English format.** Add a regional code such as `en_GB` to `martis.preferences.locales` (and a label to `locale_labels`), and let those users pick it: it translates through `en` and formats with its region. See [Internationalisation → Number and date formatting](https://getmartis.com/docs/customization/i18n#number-and-date-formatting).
- **You published the shell template** (`vendor:publish --tag=martis-views`, `resources/views/vendor/martis/app.blade.php`). Your copy wins over the package's, so it keeps loading the unbuilt extensions bundle (the 404), and a user the `viewMartis` gate refuses gets the full SPA instead of the standalone screen. Republish it, or port three changes of the package's `resources/views/app.blade.php` into it: `panelForbidden` and `panelForbiddenImpersonating` in `window.MartisConfig`, `extensions: {!! json_encode(\Martis\Support\ExtensionBundles::urls()) !!}`, and the `scoped` key of `notifications`.

## Upgrading to v2.0.1 from v2.0.0

The action log, the throttle buckets, the Gate cache's `lookup()` the Tool route warning, the grouped user hooks, the relatable checks on writes, the relationship panels, the Action Events panel, the action log's columns and React Router 7 apply to every app; the other changes concern an app with a custom `MARTIS_GUARD`.

### Relationship writes follow the pickers

A create, update, inline create, attach, pivot update or Action run now answers **422** when a `BelongsTo`, `MorphTo`, `Tag` or attached record names a record its picker would not list, as Nova's `Relatable` rule does. v2.0 applied `relatableQuery()`, `relatable{PluralModelName}()` and `relatableQueryUsing()` to the pickers only and saved any id the request sent. See [Relationships → Writes follow the pickers](https://getmartis.com/docs/core/relationships#writes-follow-the-pickers).

**Who is affected:** an app whose relatable hooks are narrower than what it saves: a `relatableQuery()` that hides records a form or an API client still writes (an inactive owner, another tenant's record an admin assigns), a `relatableQueryUsing()` written only to sort or shorten the list, or a client that writes soft-deleted related records. The same writes now also need `viewAny` on the related resource, the related record's `add{Model}` policy ability for a `BelongsTo` / `MorphTo` (as the `HasMany` panel on its page already needs), `attachAny{Model}` / `attach{Model}` for the records a `Tag` adds and `detach{Model}` for the ones it removes, and a `BelongsTo` / `MorphTo` fails when the picked record's inverse `HasOne` / `MorphOne` already holds another record (Nova's `relationshipIsFull()`). As in Nova, the value is checked on every save: a record that already points at a target outside the query answers 422 on its next update until the target changes. The `BelongsTo`, `MorphTo` and `Tag` fields of `Repeater` rows are checked too.

**What to change:** widen the hook to what the app writes (branch on `$request->route('resource')` or on the field passed to `relatable{PluralModelName}()` when only one picker should be narrow), send `{attribute}_trashed=true` with a soft-deleted target (the edit form sends the target's `trashed: true` back by itself), grant the policy abilities above, and look for records whose stored target the hooks now exclude, which cannot be saved unchanged. An API client that attaches with a 3-argument `relatableQueryUsing()` closure sends the same `?form[attribute]=value` draft on the attach as on the attachable list.

### The action log is closed by default

The Action Events resource (the `martis_action_events` audit log) was readable by every panel user, `original` and `changes` included, whatever fields those users could see on the records. From v2.0.1 it is closed until you open it, and it masks the values the viewer could not read on the record:

- **Access.** A deny-by-default gate, `view-martis-action-events`, decides who reads the log, unless a policy for `Martis\Models\ActionEvent` defines `viewAny` / `view` (then the policy decides, as before). Without access the index and the detail answer `403`, the sidebar entry and the command palette's *Recent activity* disappear, and a relationship panel that lists the log leaves the detail page (see the next section).
- **Hidden values.** A value in `original` / `changes` reads `******` unless the viewer may see that attribute on the record's own detail page (a visible field, through a resource that lets the viewer view the record). Attributes no field shows, such as `password`, are masked too, and so are the pivot columns of a pivot action whose pivot field the viewer may not see. Rows already stored are unchanged.
- **`$hidden` attributes are stored masked.** From v2.0.1 an event stores `******` for each `$hidden` attribute of the model (or of the pivot model) an action changed, as Nova does. Code that read those values from `martis_action_events` gets the mask for new rows.

**What to change:** grant the gate to the users who should read the log, in `app/Providers/MartisServiceProvider.php` (or any service provider):

```
use Illuminate\Support\Facades\Gate;
 
Gate::define('view-martis-action-events', fn ($user) => $user->is_admin);
```

An app with an `ActionEventPolicy` that defines `viewAny` and `view` needs no change. A custom resource for the `ActionEvent` model keeps its own authorization; apply `ActionEventRedactor::redact()` to its `original` / `changes` fields to mask the same values. See [Actions → Who can read the audit log](https://getmartis.com/docs/core/actions#who-can-read-the-audit-log-v201).

### Password reset picks the Martis guard's broker

Password reset now runs on the password broker whose provider is the Martis guard's (`GuardCatalog::martisPasswordBroker()`): `MARTIS_AUTH_PASSWORD_BROKER` when set, else the app's default broker when it reads the Martis guard's users, else the first broker that does. v2.0.0 used `MARTIS_AUTH_PASSWORD_BROKER`, `users` by default, whatever the guard, so beside an `admins` guard the forgot-password form reset the site user with the admin's email. A broker of another provider, an unknown broker, or none that fits now throws a `Martis\Auth\PasswordBrokerConfigurationException` naming `martis.auth.passwordReset.broker` (the endpoint answers 500 and reports it).

**What to change**, with an own guard and password reset on: declare a broker for the guard's provider in `config/auth.php` (`passwords`). A `config/martis.php` published before v2.0.1 holds `env('MARTIS_AUTH_PASSWORD_BROKER', 'users')`, which now throws with an own guard: change the default to `env('MARTIS_AUTH_PASSWORD_BROKER')`, or set the variable to the guard's broker. See [Authentication → Which password broker resets a password](https://getmartis.com/docs/auth/authentication#which-password-broker-resets-a-password).

### The Martis throttles have their own buckets

The API throttle's middleware is `throttle:{max},{decay},martis-api:{guard}:` (`RouteMiddleware::throttlePrefix('api')`), the 2FA challenge's and the verification resend's carry `martis-2fa:{guard}:` and `martis-verification:{guard}:`. Laravel keys a user's bucket on `sha1()` of the identifier alone, so the Martis guard's user 5 shared a bucket with a site route throttled per user for the site user 5, and the resend and the challenge counted in the API's bucket. The counters in flight start over once, on deploy. A test that asserts the route's middleware list reads the new string.

### The per-request Gate cache takes the user

`RequestScopedAbilityCache::lookup()` takes the user instead of its id, and keys it by morph class and id: `lookup($user->id, ...)` throws a `TypeError`. The package does not call it.

### Routes a tool registers in boot() log a warning

A route under a tool's path (`ToolRoutes::prefix($tool)`, or the v1.x `martis/api/tools/{uriKey}`) registered with `['web', 'martis.auth']`, or with a list that leaves out the 2FA challenge or email verification while it is on, now logs a warning naming the tool and the route, once per tool and PHP process, as a `loadRoutes()` list already did in v2.0.0. The route keeps its middleware. Move it to `ToolRoutes::middleware($this)` or to `loadRoutes()` ([Tools → Routes a tool registers in `boot()`](https://getmartis.com/docs/customization/tools#routes-a-tool-registers-in-boot)); a route meant to skip the challenge belongs outside the tool's path.

### Shared sessions and notifications tables

The Martis migrations of these tables now shape their user columns on the users of every guard that writes them, with a string column when the keys differ. They skip a table that exists, which Laravel 11+ creates with a `bigint` `user_id`: with a Martis guard keyed by UUID or ULID beside the site's bigint users, widen the columns once, as [Installation → The shared `sessions` and `notifications` tables](https://getmartis.com/docs/getting-started/installation#the-shared-sessions-and-notifications-tables) shows.

### User hooks run grouped

No code change is needed. A user hook written with a top-level `orWhere()` now runs grouped everywhere Martis composes it with something else, as Eloquent runs a local scope: the resource's `scopes()` and `indexQuery()` on the index page and its count badge, each index filter's `apply()`, the resource's `searchQuery()`, each filter a lens's `withFilters()` applies, and the pickers' `relatableQuery()`, `relatable{PluralModelName}()` and `relatableQueryUsing()`. v2.0.0 appended the filters, the search term and the lower picker layers to the hook's last `or` clause only, so `where('tenant_id', 1)->orWhere('shared', true)` listed every record of the tenant whatever the filters and the search said, and a filter or a picker closure written with `orWhere()` could list another tenant's records. A list that relied on that precedence now shows fewer records; wrap the hook's clauses in `where(fn ($q) => ...)` yourself only if you meant the looser reading. Hooks that add only `and` clauses produce the same SQL.

### The panel runs on React Router 7

The SPA moved from React Router 6 to React Router 7 (library mode, `createBrowserRouter` as before), which closes GHSA-wrjc-x8rr-h8h6 and GHSA-337j-9hxr-rhxg. It needs Node 20 or later to build (only for building the package itself: an app installs the prebuilt assets). URLs and pages are unchanged.

**Extensions** (custom tools, fields, cards and overrides built with `npm run build:extensions`) keep working without a rebuild. They never bundle React Router: their Vite config sends `react-router-dom` to a shim that reads the host's copy off `window.Martis.runtime.reactRouterDom`, and every name that shim exports (`Link`, `NavLink`, `Outlet`, `Navigate`, `Route`, `Routes`, the routers, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`, `useMatch`, `useResolvedPath`, `useNavigationType`, `generatePath`, `matchPath`, `matchRoutes`) exists in React Router 7. What changes for extension code:

- **`window.Martis.runtime.reactRouterDom` is React Router 7's `react-router-dom` module**: every export of `react-router`, with the DOM `RouterProvider`. Names React Router 7 removed are gone from it (`json`, `defer`, `AbortedDeferredError`, the `UNSAFE_` internals of v6), so code that read one off the shim's default export gets `undefined`.
- **Behaviour of the v7 future flags** now applies to the host's router: navigations run in `React.startTransition`, and a relative link inside a splat route resolves from the splat's own path. An extension that navigates with absolute paths (`navigate('/resources/users')`, `<Link to="/tools/deployments">`) sees no difference. `navigate()` may return a promise; there is nothing to await for a plain navigation.
- **Type declarations.** After you republish the shims (`php artisan vendor:publish --tag=martis-extension-shims --force`), `react-router-dom.d.mts` carries the React Router 7 types. Six type names React Router 7 no longer exports are gone: `FutureConfig`, `Hash`, `JsonFunction`, `Pathname`, `Search` and `V7_FormMethod` (use `string` or `Path['pathname']` for the path parts).
- **`import ... from 'react-router'`** also works in a scaffold published from v2.0.1 (`martis:install --force`): its Vite config and `tsconfig.extensions.json` send `react-router` to the same shim. An older scaffold keeps importing from `react-router-dom`, or adds the two lines by hand (see [Installation → Extensions and React Router 7](https://getmartis.com/docs/getting-started/installation#extensions-and-react-router-7-v201)).

### Relationship panels follow the related resource's viewAny

A relationship panel (`HasMany`, `HasOne`, `MorphMany`, `MorphOne`, `BelongsToMany`, `MorphToMany`, and `HasManyThrough`, `HasOneThrough`, `HasOneOfMany`, `MorphOneOfMany`) was shown, and its records listed, to any user who could view the parent record. From v2.0.1 it follows Nova: a user the related resource does not let `viewAny` does not see the panel on the detail page, and its routes (the list, the card, the attachable list, attach, detach, the pivot update and the pivot actions) answer `403`. v2.0 already refused the writes.

**What to change:** grant `viewAny` on the related resource to the users who should keep seeing the panel, and confine the rows they see with `indexQuery()`. See [Relationships → Panels follow the related resource's `viewAny`](https://getmartis.com/docs/core/relationships#panels-follow-the-related-resources-viewany-v201).

### An Action Events panel on Actionable models

As in Nova, the detail page of a model that uses `Martis\Concerns\Actionable` now ends with a collapsable **Action Events** panel listing its action log, for the users who may read the log (the `view-martis-action-events` gate or an `ActionEvent` policy). A resource that already declares a `MorphMany` to the action event resource keeps its own and gets no second one.

**What to change:** nothing to get the panel. To leave it out of a resource, override `shouldAddActionsField()` to return `false`. A resource that overrides `fieldsForDetail()` keeps working: the panel is added after it. See [Actions → The Action Events panel](https://getmartis.com/docs/core/actions#the-action-events-panel-v201).

### The action log shows Nova's columns

The built-in `ActionEventResource` now lists Nova's columns: **ID, Name, Initiated By** (the user's name instead of `User ID`), **Target** (`Project: Apollo`, linked when the viewer may view the record, instead of the model class), **Status** (Waiting, Running, Finished, Failed, Denied instead of the stored `queued` / `completed` / ...) and **Happened At** (was Executed At). The detail page drops the batch id and the `actionable_*` columns and shows `original` / `changes` as key/value tables, only when the event holds a diff. Labels are translated (`martis::action_events`). The stored rows do not change.

**What to change:** nothing, unless a subclass of `ActionEventResource` called `parent::fieldsForIndex()` (the override is gone: the index now comes from `fields()`) or reads the field labels. See [Actions → Columns and detail fields](https://getmartis.com/docs/core/actions#columns-and-detail-fields-v201).

## Upgrading to v2.0 from v1.x

Require the new major; a `^1.x` constraint never installs it:

```
composer require martis/martis:^2.0
```

Then change the app as the sections below say, and deploy it with the steps in [Deploying the upgrade](https://getmartis.com/docs/getting-started/upgrading#deploying-the-upgrade).

### Choice-field options follow Nova's order (high impact)

`Select::options()` and `MultiSelect::options()` read `[value => label]`, the order Nova uses for `Select`, `MultiSelect` and `BooleanGroup`: the array key is what the field stores, the array value is what the user sees. v1.x read the array the other way round, `[label => value]`.

| Call | v1.x stored | v2.0 stores |
| --- | --- | --- |
| `options(['Draft' => 'draft'])` | `draft` | `Draft` |
| `options(['draft' => 'Draft'])` | `Draft` | `draft` |
| `options(fn () => User::pluck('name', 'id')->all())` | the name | the id |
| `options(['draft', 'published'])` | `draft`, `published` | `0`, `1` |

Nothing fails at runtime when an array keeps the v1 order: the field stores the label instead of the value. Review every call.

**What to change:**

1. **Flip every associative array written label first.** `['Draft' => 'draft']` becomes `['draft' => 'Draft']`, and `pluck('id', 'name')` becomes `pluck('name', 'id')`.
2. **Turn lists into maps.** A list is read as Nova reads it, keyed 0, 1, 2…, so `['draft', 'published']` now stores `0` and `1`. When each value is its own label, pass `array_combine($values, $values)`; for an enum, pass the class (`options(Status::class)`) instead of a list of its values. A list of numbers shifts by one value with no error: `options([1, 2, 3])`, `range(1, 12)` or `array_column(Rating::cases(), 'value')` stored the numbers themselves on v1.x and now store 0, 1, 2, so a stored 1 shows as "2" and picking "3" stores 2.
3. **Rewrite MultiSelect groups in Nova's format.** `['Backend' => ['PHP' => 'php']]` becomes `['php' => ['label' => 'PHP', 'group' => 'Backend']]`. The old format now throws an `InvalidArgumentException` that names the field and the option.
4. **Replace `optionsFromMap()` with `options()`.** It was removed: `options()` reads the same `[value => label]` map and also takes a closure. A call left behind fails with an undefined-method error.
5. **Check `searchOptionsUsing()` closures.** They return the same shapes as `options()`, in the same order, so a list is keyed 0, 1, 2 and stores a position in that search's results, which changes with the term. Return `[value => label]`, for example `->pluck('name', 'id')`, or `array_combine($values, $values)` when each value is its own label. A closure that returns a non-empty list logs a warning, at most once per field per request.
6. **Review what else reads a flipped array.** On v1.x one array could serve both a field and a `SelectFilter` (for example a `Post::STATUSES` constant). Filters keep `[label => value]` (see below), so once the array is flipped for the field, the filter shows `draft` as the label and applies `where status = 'Draft'`: zero results, no error. Give the filter `array_flip(Post::STATUSES)`, or its own array. Review the other readers of a flipped array the same way: a `Rule::in(array_values(...))` rule now lists the labels (use `array_keys()`), and a Badge map or a `displayUsing()` lookup built from it is now keyed by value.
7. **Update generated code.** The `BulkAssignRole` action that `martis:roles` generates builds its role picker with `pluck('id', 'name')`; with v2.0 it would receive the role name: `Role::find('Admin')` answers "That role no longer exists" on MySQL and SQLite, and throws a `QueryException` on PostgreSQL, whose integer id column rejects the text. Change it to `pluck('name', 'id')` (new scaffolds already do). If you published the stubs with `martis:stubs`, update `stubs/martis/roles-bulk-assign-role-action.stub` the same way. The `AGENTS.md` / `CLAUDE.md` primers that `martis:agents` wrote under v1.x recommend `options($enum::values())`, which v2.0 reads as a list: regenerate them with `php artisan martis:agents --force` (update `stubs/martis/agents/AGENTS.md.stub` first if you published it).
8. **Subclasses.** A subclass of `Select` or `MultiSelect` that overrides `options()` must accept `iterable|Arrayable|string|\Closure`: `options()` now takes a Collection or any other iterable, as in Nova, and `MultiSelect::options()` takes an enum class too.

**Finding the calls:**

```
grep -rnF -e '->options(' -e 'optionsFromMap(' -e 'searchOptionsUsing(' app/ Modules/ packages/
```

Add every other folder that holds PHP source (a `src/` or `domain/` folder, local packages, a modular layout); `grep` warns about a folder that does not exist and searches the others. Every `Select` and `MultiSelect` hit needs a look. A filter's `public function options(Request $request)` keeps its order, but check the array it returns when a field shares it (step 6). A multi-line chain shows only its `options(` line, so read each hit in its file.

**Records saved with the wrong order.** A field whose array was already written value first under v1.x (for example `['admin' => 'Administrator']`) stored the label. v2.0 reads that array correctly, so those records hold a label where a value is expected: fix the stored values with a data migration.

**The stored-label warning.** Reading a record whose stored value matches the label of a static option and the value of none logs a warning, in production too, at most once per request for each model class and field, naming the model, the record, the field and the value. It fires in both cases above: an array still written label first, and a record saved while it was. For options given as a list, it also fires for a stored value that is the label of another option (the list of numbers of step 2). It reads the stored value, before `resolveUsing()`, and skips computed fields and a `Select` with `allowCustomValues()`. It names the first matching record only and never checks options that come from a closure (the `pluck()` case), so to find every affected record, query the column for values that are labels.

Both warnings can log a false positive: a field can store a valid 0-based position that also happens to read as another option's label, for example a Nova rating that stores `0..4` on purpose and shows `"1".."5"` (`[0 => '1', 1 => '2', ...]`, `array_combine(range(0, 4), range(1, 5))`, or `options([1, 2, 3, 4, 5])` ported straight from Nova). Reading a stored `1` there is correct, not stale data, but would otherwise warn on every request. Call `withoutOptionOrderWarnings()` on that field once the array is confirmed right; it silences the plain stored-label warning and the list-shift warning for that field, and the `searchOptionsUsing()` list warning, which a resolver that filters a fixed list and keeps its keys can trigger when the search term is empty.

**What does not change:**

- **Filters** keep Nova's filter order, `[label => value]`: a filter's `options(Request $request)` is unchanged, exactly as in Nova. An array a filter shares with a field is the exception: see step 6.
- **`BooleanGroup`** already read `[flag key => label]`.
- **Enum classes** (`options(Status::class)`) store the case value, as before; `MultiSelect` now accepts them too.
- **The field payload** keeps its shape (`options: [{label, value, group?}]`), so custom frontend components keep working. `group` is new on `Select`, whose dropdown now shows grouped options under their headings.

### Relationship writes need the related resource's viewAny

Creating, editing or deleting a record through a relationship panel (`HasMany`, `HasOne`, `MorphMany`, `MorphOne`, and their endpoints) now needs the related resource's `viewAny`, as its own per-id endpoints do since v1.34.0 and as Nova does. v1.x checked only the parent's `viewAny` / `view` and the related record's `create` / `update` / `delete`.

A user whose policy denies `viewAny` on the related resource gets a 403 on those writes, and the panel no longer offers Create, Edit, Delete, Restore or Force delete. In v2.0.0 it still listed the records, as in v1.x; from v2.0.1 the panel is hidden too (see [Relationship panels follow the related resource's `viewAny`](https://getmartis.com/docs/getting-started/upgrading#relationship-panels-follow-the-related-resources-viewany)).

**What to change:** if that user should keep writing through the panel, grant `viewAny` on the related resource and confine what they see with `indexQuery()`. A resource that is not `routable()` keeps working as a relation target. See [Authorization → `viewAny` is the entry gate](https://getmartis.com/docs/auth/authorization#viewany-is-the-entry-gate).

### Through relationships offer no Create

`HasOneThrough` and `HasManyThrough` never offer Create, as in Nova, and a create through a Through relationship answers 403. Edit and Delete now show by default, under the related resource's policies. `canCreate()` has no effect; `canCreate(true)` logs a warning naming the field. See [Relationships → Upgrading the Through fields from 1.x](https://getmartis.com/docs/core/relationships#upgrading-the-through-fields-from-1x).

### Relationship panels list what the related index lists

A `HasMany`, `HasManyThrough`, `MorphMany`, `BelongsToMany` or `MorphToMany` panel now applies the related resource's `scopes()` and `indexQuery()`, as its index does and as Nova's relationship index does. v1.x listed every record of the relationship, including the ones those hooks hide (another tenant's, archived ones).

**What to change:**

1. **Check hooks that should not apply to panels.** An `indexQuery()` meant for the index page only (an order, a default filter) now also shapes the panels; test `$request->route('relationship')` to tell a panel apart (it is not set on the counts a parent's index computes).
2. **Know what reaches a panel.** On a plain `HasMany` / `MorphMany` panel the hooks run grouped on the panel's query: an `orWhere()` stays inside the group, their order comes before the panel's `?sort=` (as on the index), and a hook that joins must select its table's columns, as the index needs (a search there, as on the index, fails when the joined table has a column of the same name). On a Through or pivot panel, and for every count, they run on a fresh query and the panel keeps the keys they return: their order and aliases do not reach the rows (a panel cannot sort by an alias only the hook adds). The key subquery costs little: a 100-row page of a parent index that counts three relationships runs 2 queries instead of the 302 of the per-row counts before v2.0, about an order of magnitude faster in one machine's run with 100,000 and with 500,000 related rows. Columns need no qualifying there.

The `BelongsToMany` and `MorphToMany` panels also apply their soft-delete filter now; before, *Only trashed* listed the active records.

The same hooks now shape what counts the related records: the relationship count a `HasMany`, `MorphMany`, `BelongsToMany` or `MorphToMany` field shows on the index (`showOnIndex()`), now computed with the page instead of per row, and the one-of-many "1 of N" count and `aggregateVia()` tile. The hooks narrow the relationship's own definition and nothing else: a global scope the relationship removes (`->withoutGlobalScope(...)`) stays removed, as in 1.x.

The page's count resolves the relationship as Laravel's `withCount()` does, on a model that holds no record, where v1.x counted on each loaded record. A relationship method that reads the parent's attributes gets `null` there: one that throws without its record is still counted per row, as in v1.x, but one that reads `null` counts the wrong rows. **Check** that the relationships you count on the index (`showOnIndex()`) do not depend on the parent's attributes.

A one-record card (`HasOne`, `HasOneOfMany`, `HasOneThrough`, `MorphOne`, `MorphOneOfMany`) now shows its record only when the user may `view` it, as Nova hides that panel; otherwise the card is not rendered at all (its endpoint answers `meta.hidden: true`) and its Edit and Delete answer 404. Grant `view` where the card should show the record. A custom card component that reads the endpoint should treat `meta.hidden` as "render nothing". Creating a second record on a `HasOne` / `MorphOne` card answers `422` with the reason as its `message` (it answered `500`); a one-of-many card now takes more records, as its many relationship does.

### A one-record card write names its record

`PUT` and `DELETE` on `…/has-one/{relationship}` and `…/morph-one/{relationship}` need `?relatedId=` with the id of the record the client read from the card's `GET`: `422` without it, `409` when the relationship holds another record by then (nothing is written). A stale id answers `409` before the policy is checked; a missing id answers `422` after it, so a denied user gets `403`. The Martis card sends it.

**What to change:** an API client that calls those endpoints adds the id it read. A custom card component (one registered in place of the `HasOne` / `MorphOne` card) that edits or deletes through them sends `?relatedId=` with the id it showed, keeps that id from the click to the confirm (a refetch may swap the record meanwhile), and on a `409` reloads the card and shows the response's `message`.

### Creating a record needs viewAny

`POST /api/resources/{resource}` and the inline create (its form and its store) answer `403` when the user may not list the resource, as its show, update and destroy endpoints do since v1.34.0. v1.x checked `create` only.

The create form's own endpoints (`sync-field` and `fields/{field}/options` in the create context) need it too, and the inline create buttons of `BelongsTo`, `MorphTo`, `Tag`, `BelongsToMany` and `MorphToMany` are offered only when the related resource allows both `create` and `viewAny`.

**What to change:** grant `viewAny` to a user who should create records, and confine what they see with `indexQuery()` if needed.

### Actions: who may run them, and on which records

An action run on records changed in v2.0:

- **`canRun()` and the policy must both allow it.** The per-row map the index and the panels send (`_actionAuthorization`) now includes the resource's `runAction` / `runDestructiveAction` policy (falling back to `update` / `delete`), as the run always did: an action the policy refuses shows disabled instead of failing when run. A `canRun()` does not replace the policy, unlike Nova 5.
- **Trashed records run.** A selected record the resource soft-deletes is looked up with the trashed ones, since the index and the panels list them.
- **A partial selection runs on what resolves.** Ids outside the resource's `scopes()` / `indexQuery()`, or that no longer exist, are left out and the action runs on the others, as in Nova.
- **404 when nothing resolves.** A run whose selected records all fail to resolve answers `404` (`One or more selected resources could not be found.`) instead of running `handle()` on nothing and answering "Done".
- **422 with an empty selection.** An action that is not `standalone()` and names no record answers `422` (`resources`), as a pivot action already did. A `standalone()` action runs on no record, whatever the request names.
- **`scopes()` apply.** A run looks records up through the resource's declarative `scopes()`, then `indexQuery()`, as the index lists them.
- **A panel's run finds what the panel lists.** A run from a `HasMany`, `HasManyThrough` or `MorphMany` panel (`viaResource`, `viaResourceId`, `viaRelationship`) and a pivot action on a `BelongsToMany` / `MorphToMany` panel resolve the records the panel lists: the relationship's rows (a global scope it removes stays removed), the related resource's `scopes()` and `indexQuery()`, and the trashed ones only when the panel offers its trashed filter (`canViewTrashed()`). A pivot action no longer runs on a row those hooks hide (it answers 404), and a `standalone()` action run with a relationship the parent does not declare answers 404.
- **A queued action handles what the run resolved.** Its job (`ExecuteAction`, `ExecutePivotAction` for a pivot action) reloads the records by key without global scopes, as Laravel restores a job's models, so it handles every record the run resolved: a trashed one, one only the panel's relationship reaches. v1.x reloaded them through the model's global scopes, which in a queue worker (no user, no tenant) could leave records out.

**What to change:** a client that posts to `/actions/{action}` without `resources` for an action that is not standalone must send the ids, or declare the action `standalone()`.

### The global search and the pivot routes apply scopes()

The global search (`/api/search`, the Cmd+K palette) and the parent lookup of a `BelongsToMany` panel and of the pivot routes (pivot actions, their fields and pickers, the pickers of the pivot fields, on `belongs-to-many` and `morph-to-many`) now run the resource's declarative `scopes()` before `indexQuery()`, as the index does. v1.x ran `indexQuery()` alone there, so a resource that confined its tenants with `scopes()` showed another tenant's records in the palette.

- A record the scopes hide no longer shows in the palette, and the `total` of its group no longer counts it.
- A `BelongsToMany` panel, a pivot action or a pivot field picker whose parent record the scopes hide answers `404`, as it already did for a parent `indexQuery()` hides.
- On those surfaces and on an action run, an `orWhere()` in `scopes()` or `indexQuery()` is grouped before the term, the key or the selected ids are added. v1.x appended them to its last clause only, so a hook such as `where('tenant_id', 1)->orWhere('shared', true)` made the palette list the tenant's records whatever the term, a panel resolve the first record of the tenant instead of the one it names, and an action on one selected record run on every record of the tenant.

**What to change:** nothing when `scopes()` holds tenancy or visibility rules: they now apply where the docs said they would. A scope meant to trim the index page only (an `archived = false` default that users should still reach from the palette) belongs in a [filter](https://getmartis.com/docs/core/filters) instead. See [Authorization → Declarative query scopes](https://getmartis.com/docs/auth/authorization#declarative-query-scopes).

### Tool routes run the Martis API middleware

`Tool::loadRoutes()` called without a middleware list gives a tool's routes `ToolRoutes::middleware($tool)` (`Martis\Tools\ToolRoutes`): the middleware of the package's protected API routes (your `martis.middleware` and `martis.auth_middleware`, the impersonation expiry, the 2FA challenge, the user's locale, email verification when it is enabled, the API throttle), then `martis.tool:{uriKey}`. v1.x defaulted to `['web', 'martis.auth']`.

- A user who signed in with a password but has not passed the 2FA challenge gets `423` from a tool route (a redirect to the challenge for a page request), and a user who has not verified an email the app requires gets `409` (a redirect to the notice), as from the rest of the API. v1.x let both through.
- A user the tool is hidden from (`canSee()`, its policy) gets `404` from its routes, as from the tool's page.
- A tool's routes count toward the API throttle, `MARTIS_THROTTLE_MAX` (120) requests per `MARTIS_THROTTLE_DECAY` (1) minute per user, shared with the rest of the API. A tool that polls often answers `429` past it.
- They run in the user's locale, and an impersonation past its limit stops before they run.
- **With a `MARTIS_PATH` other than `martis`, they move** from `/martis/api/tools/{uriKey}/...` to `/{MARTIS_PATH}/api/tools/{uriKey}/...` (`ToolRoutes::prefix()`), where the SPA's `api` client calls them: on v1.x they stayed under `/martis`, so the client answered 404. Nothing moves with the default path.

The signature keeps its v1.x type, `array $middleware`, so a tool that overrides `loadRoutes()` with the v1.x signature still loads, and a list passed explicitly is used as given, as in v1.x. A list that leaves out the 2FA challenge while `MARTIS_2FA_ENABLED` is on (the default), or email verification while it is on, and the v1.x list `['web', 'martis.auth']` itself, log a warning that names the tool, once per tool and PHP process.

**What to change:**

1. **Drop the middleware argument** of every `loadRoutes()` call that passes `['web', 'martis.auth']` (or forwards it from an override): that list keeps the v1.x stack, without the 2FA challenge, and logs the warning. Pass a list only for another stack: `[...ToolRoutes::middleware($this), 'can:imports.run']` adds an ability, and a route that must answer before the 2FA challenge or to users the tool is hidden from keeps its own list, which is used exactly as given.
2. **Routes a tool registers in `boot()` with `Route::middleware(['web', 'martis.auth'])->prefix('martis/api/tools/...')`**, the pattern these docs showed, keep that weaker stack and that path (from v2.0.1 they log a warning naming the tool and the route): switch them to `Route::middleware(ToolRoutes::middleware($this))->prefix(ToolRoutes::prefix($this))`, or move them to a routes file loaded by `$this->loadRoutes()`, as [Tools → Routes a tool registers in `boot()`](https://getmartis.com/docs/customization/tools#routes-a-tool-registers-in-boot) shows. A route of your own that `martis.auth` alone guards skips the 2FA challenge the same way: use the `martis.api` middleware group.
3. **A client that calls a tool route by a hard-coded `/martis/api/tools/...` URL** with a custom `MARTIS_PATH` follows the new path, or goes through the SPA's `api` client (`api.get('/api/tools/...')`). To keep the old URL, pass `prefix: 'martis/api/tools/{uriKey}'`.
4. **A tool that polls** raises `MARTIS_THROTTLE_MAX`, or passes a list without the throttle.

See [Tools → Tool routes and their middleware](https://getmartis.com/docs/customization/tools#tool-routes-and-their-middleware).

### The schema cache expires after a day

The `schema` cache layer now expires after a day by default (`MARTIS_CACHE_SCHEMA_TTL=1440`); v1.x kept it with no expiration. Every cache key also carries the installed `martis/martis` version, so an upgrade of the package rebuilds every layer on its own and leaves the previous version's entries behind.

**What to change:**

1. **Check the TTL.** An empty `MARTIS_CACHE_SCHEMA_TTL=` (the `.env` block the v1.x docs showed) and a `config/martis.php` published before v2.0 keep "no expiration". Set `MARTIS_CACHE_SCHEMA_TTL=1440`, or republish the config and merge your changes.
2. **Prune on the database and file stores.** Those stores delete an expired entry only when its key is read again, which an old version's key never is. Run `php artisan martis:cache:prune` after each upgrade, or schedule it. Redis and memcached need nothing.

See [Cache → Invalidation](https://getmartis.com/docs/reference/cache#invalidation).

### Custom themes

A theme generated with `martis:theme` before v2.0 may need three edits:

1. **`--martis-dur-sm` and `--martis-brand-500` do nothing any more.** No stylesheet defined them; the package now reads `--martis-dur-fast` and `--martis-accent` where it read them, and `martis:theme:diff` lists them as *Unknown to package* (exit 2). Set `--martis-dur-fast` and `--martis-accent` instead.
2. **Delete the two logo heights**, `--martis-brand-logo-height-auth` and `--martis-brand-logo-height-menu`, unless you mean to override `MARTIS_BRAND_LOGO_HEIGHT_AUTH` / `_MENU`: the old stub declared them on `:root` (the menu one at 28px), which silenced those `.env` knobs.
3. **Set the short typography names.** The package CSS reads `--martis-text-*`, `--martis-weight-*` and `--martis-leading-*`; a theme that sets only `--martis-font-size-*`, `--martis-font-weight-*` or `--martis-line-height-*` changes almost nothing.

See [Theming](https://getmartis.com/docs/customization/theming#variable-reference).

### martis:install asks only on a terminal

`martis:install`, and every other Martis command that asks (the generators' "Overwrite?", `martis:user`, `martis:agents`, `martis:sso`'s role mapping, the "Run pending migrations now?" of `martis:invitations`, `martis:roles` and `martis:sso`), asks a question only when the input is interactive **and** stdin is a real TTY. A generator run through a pipe leaves an existing file alone unless you pass `--force`, printing "already exists" and exiting 0. `martis:user` without a terminal needs `--email` and `--password` (it exits 1 naming the missing one, and creates no user; `--name` defaults to `Martis Admin`). The three scaffold commands now run their migrations through a pipe: `yes n | php artisan martis:invitations` used to answer "no" and skip them, so pass `--no-migrate` to skip them. Through a pipe or `docker compose exec -T` every question takes its default: optional features you pass no flag for stay off, the avatar column is `profile_picture`, and `--existing-avatar-column` needs `--avatar-column`. Before v2.0 the avatar column questions read the pipe, and `--force` rewrote any `*_create_notifications_table.php` / `*_create_sessions_table.php`, the application's own included.

**What to check:**

1. **A `users.y` column.** `yes | php artisan martis:install --with-profile` answered `y` to the avatar column question: look for a migration adding `y` to `users` in `database/migrations` and `MARTIS_AVATAR_COLUMN=y` in `.env`. Roll the migration back (or drop the column), delete it, set `MARTIS_AVATAR_COLUMN` to the column you want and run the installer again with `--with-profile --avatar-column=<column>` (and `--with-2fa` when you use two-factor authentication): without a terminal, a feature you pass no flag for is turned off.
2. **Your own notifications or sessions migration.** If you ran `martis:install --force` over a migration you created with `make:notifications-table` or `make:session-table`, it now holds the Martis stub: compare it with your version control and restore it. `--force` leaves it alone from v2.0.
3. **Scripts that relied on a prompt.** Pass the flags (`--with-profile`, `--with-2fa`, `--avatar-column=…`, `martis:user --email=… --password=…`, `--no-migrate`) instead.
4. **A `martis:user` run through a pipe.** `yes | php artisan martis:user` created an admin with the email, name and password `y`, its email already verified: look for a user with the email `y` and delete it.

### martis.auth runs where Laravel's auth runs

`MartisAuthenticate`, the `martis.auth` middleware, implements Laravel's `AuthenticatesRequests`, as Laravel's and Nova's `Authenticate` do, so the router's middleware priority now runs it right after the session starts: before the throttle, the route bindings (`SubstituteBindings`) and any middleware that is not in the priority list, including one appended to the `web` group or listed in `martis.middleware`. v1.x ran it after them. The protected routes' throttle therefore counts per signed-in user (with a custom `MARTIS_GUARD` it counted per IP, see [A custom Martis guard](https://getmartis.com/docs/getting-started/upgrading#a-custom-martis-guard)), and an unauthenticated request to a protected route is answered before the throttle counts it, as with Laravel's `auth` and `throttle`.

**What to change:** a middleware that must run before authentication, such as one that selects a tenant's database connection, must be in the app's middleware priority list, as it must for Laravel's `auth` (stancl/tenancy registers its own there):

```
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->prependToPriorityList(
        \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
        \App\Http\Middleware\InitializeTenant::class,
    );
})
```

### Deploying the upgrade

Run these in each environment, after the code with the flipped arrays is deployed there, in this order:

```
php artisan martis:publish-assets
php artisan martis:cache:clear schema
php artisan martis:cache:prune
```

1. **Republish the assets.** The grouped `Select` dropdown and the fix for a `''` option ship in the frontend bundle; without the republish the browser keeps the v1 bundle.
2. **Clear the schema cache, after the deploy.** It holds the options as they were read, for up to a day by default, and with no expiration when `MARTIS_CACHE_SCHEMA_TTL` is empty or `config/martis.php` was published before v2.0 (see [The schema cache expires after a day](https://getmartis.com/docs/getting-started/upgrading#the-schema-cache-expires-after-a-day)); a new package version does not help here, because the arrays you flipped are your app's code. Cleared before the new code is deployed, the next page opened caches the old options again, so clear it after the deploy, in every environment.
3. **Prune the old cache entries**, on the database and file stores (a no-op on the others).

## A custom Martis guard

With a custom `MARTIS_GUARD`, the panel's requests now authenticate as that guard's user everywhere (`MartisAuthenticate` calls `auth()->shouldUse()`, as Laravel's `auth` middleware and Nova's do). Policies, `Gate::before()` / `Gate::after()` callbacks, gates, `canSee()` closures, observers and anything else that reads `auth()->user()` or `auth()->id()` during a panel request receive an instance of that guard's provider model: before, they got the app's default guard's user, which was null, or the site user when both guards were signed in in the same browser (the panel's policies then evaluated the site account). A closure type-hinted on the default model (`fn (User $user)`) now throws a `TypeError`: widen it (`Authenticatable`) or check the instance. The notification bell needs that model to use `Illuminate\Notifications\Notifiable` (without it the bell reads as off and the log says why; or set `MARTIS_NOTIFICATIONS_ENABLED=false`). The action log's and the preferences' user relation resolve that model, and impersonation runs on that guard unless `MARTIS_IMPERSONATION_GUARD` names another. The protected routes' rate limit counts per Martis user rather than per IP (the throttle ran before the guard switch in v1.x, see [`martis.auth` runs where Laravel's `auth` runs](https://getmartis.com/docs/getting-started/upgrading#martisauth-runs-where-laravels-auth-runs)).

The flows that find or create the Martis guard's users use that guard's provider: the magic link (v1.x looked the email up in `users` and signed that user into the Martis guard, which then loaded the admin with the same id), the registration and invitation accept (their email must be unique among that guard's users, not in `users`), the email verification link, and `php artisan martis:user`, which creates that guard's user, as Nova's `nova:user` does. With `MARTIS_GUARD` unset, all of them follow the app's default guard, as the panel does.

When the guard's model has its own table (an `admins` guard beside the site's `users`), two more things differ:

- **The Martis tables reference that table.** A fresh install creates `martis_user_preferences.user_id` and `invitations.invited_by` / `accepted_user_id` with foreign keys to it, and adds the two-factor and avatar columns to it. An install migrated before v2.0.0 has them on `users`: saving an admin's preferences fails on the foreign key, or ties the row to the site user with that id. Run the migration below.
- **Browser sessions read as unsupported.** Laravel's `sessions.user_id` holds the id of the guard that wrote the row, with no table: the same id is an admin in one row and a site user in another. When the session guards of `config/auth.php` (with the Martis and the default guard) sign in users of more than one table, the profile's Browser sessions section says so instead of listing or revoking another person's sessions, and `revoke_sessions_on_demote` is skipped with a warning in the log. Guards that share one table, through any provider or model, keep both.

Checklist for an app with `MARTIS_GUARD` set to its own guard:

- Widen closures and policy methods type-hinted on the default user model, or check the instance.
- Add `Illuminate\Notifications\Notifiable` to the Martis guard's model, or set `MARTIS_NOTIFICATIONS_ENABLED=false`.
- Leave `MARTIS_IMPERSONATION_GUARD` unset (it follows `MARTIS_GUARD`), or set it to the same guard; a config file published before v2.0.0 has `'web'` as its default, so republish it or set the variable. An operator signed in by a guard of other users is recorded in the audit row's `fields.operator_type` / `operator_id`, with no `user_id`.
- Rows the action log wrote before the upgrade with the default guard's ids now resolve through the Martis guard's model. From v2.0.0 an event caused by another guard's user records no actor: a role change or an invitation event outside the panel (in a site request, even from a browser that also holds a panel session, in a job, in a command) records none (or the inviter), and an authorization denial is recorded only while the Martis guard is the request's guard.
- Impersonation now acts on the Martis guard's users: the operator and the target are both, for instance, admins. To impersonate the site's users, set `MARTIS_IMPERSONATION_GUARD` to the site's guard, on which the operator must then be signed in too.
- With password reset enabled, declare a password broker (`config/auth.php` → `passwords`) whose provider is the Martis guard's. From v2.0.1 Martis picks it when `MARTIS_AUTH_PASSWORD_BROKER` is unset, and throws when that variable, or the `'users'` default of a `config/martis.php` published before v2.0.1, names a broker of another provider (see [Password reset picks the Martis guard's broker](https://getmartis.com/docs/getting-started/upgrading#password-reset-picks-the-martis-guards-broker)).
- If a boot script runs `php artisan martis:user --if-missing`, it now checks and creates the Martis guard's user.
- When the guard's model has its own table, run this migration once (with your table in place of `admins`):

```
<?php
 
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
 
return new class extends Migration
{
    public function up(): void
    {
        $admins = 'admins'; // the table of the Martis guard's model
 
        if (Schema::hasTable('martis_user_preferences')) {
            // v1.x saved the preferences of the default guard's user (the
            // site user signed in the same browser, if any): theme, accent,
            // density and locale only, so the table starts over.
            Schema::drop('martis_user_preferences');
            Schema::create('martis_user_preferences', function (Blueprint $table) use ($admins) {
                $table->id();
                // foreignUuid() / foreignUlid() for a UUID / ULID key.
                $table->foreignId('user_id')->unique()->constrained($admins)->cascadeOnDelete();
                $table->string('theme', 16)->default('dark');
                $table->string('accent', 16)->default('martis');
                $table->string('brand_color', 9)->nullable();
                $table->string('density', 16)->default('comfortable');
                $table->string('locale', 10)->default('en');
                $table->boolean('reduced_motion')->default(false);
                $table->timestamps();
            });
        }
 
        if (Schema::hasTable('invitations')) {
            // The inviters and the accepted accounts are already the Martis
            // guard's users; an id that names none of them is cleared first.
            foreach (['invited_by', 'accepted_user_id'] as $column) {
                DB::table('invitations')
                    ->whereNotIn($column, DB::table($admins)->select('id'))
                    ->update([$column => null]);
            }
 
            Schema::table('invitations', function (Blueprint $table) use ($admins) {
                $table->dropForeign(['invited_by']);
                $table->dropForeign(['accepted_user_id']);
                $table->foreign('invited_by')->references('id')->on($admins)->nullOnDelete();
                $table->foreign('accepted_user_id')->references('id')->on($admins)->nullOnDelete();
            });
        }
    }
};
```

When the model's key type differs from `users.id` (a UUID model beside bigint users), change the two invitation columns' type (`$table->uuid('invited_by')->nullable()->change()`) before adding their foreign keys. If you use two-factor authentication or the avatar, add their columns to the Martis guard's table too: copy `vendor/martis/martis/stubs/add_two_factor_columns.php.stub` (and `add_profile_picture_column.php.stub`, with your avatar column in place of `profile_picture`) to a new file in `database/migrations/` and run `php artisan migrate`; both add only the columns the table lacks, to the Martis guard's table.

Troubleshooting

Resources
