Documentation menu
HomeDocsGetting StartedUpgrading

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

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

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.

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.

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 and Actions.

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.

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.

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

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

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

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.

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

  • 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 (/^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.

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

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

  • 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 and i18n.

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

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), and answers a write a unique index refuses with a 422 on the field (When the database index refuses the write). 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). 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). 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).
  • 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).
  • A replacement sidebar or top bar keeps the skip links working with the same ids. See Keyboard and screen-reader contract: 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.

  • 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), 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, Head tags and 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 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.)

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.

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

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

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

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

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.

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.

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

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.

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.

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.

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.

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

Callv1.x storedv2.0 stores
options(['Draft' => 'draft'])draftDraft
options(['draft' => 'Draft'])Draftdraft
options(fn () => User::pluck('name', 'id')->all())the namethe id
options(['draft', 'published'])draft, published0, 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.

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

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.

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.

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 instead. See 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() 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.

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.

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.

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

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