Upgrading
These docs describe Martis v2. Martis v1.x read
SelectandMultiSelectoptions 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-selectmust target.p-dropdown.martis-perpage-select; the class stays on the root and thearia-labelis kept. Other native selects (repeater, MorphTo, metric range, preferences, sort chip) are unchanged. - The field
columnblock gainedtextAlignandhideBelow, bothnull. Code or a test that asserts its exact shape must include them.textAlignandhideBelowapply to the index table only, not to relationship tables yet. ?password_login=1is ignored whenauth.password_login.break_glass_emailsis 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 publishedpasswordLogin.breakGlass). A customauth:logincomponent that opens the form on the parameter should readconfig.auth.passwordLogin.breakGlasstoo. The flag is computed without validating the list, so a malformedbreak_glass_emailsdoes 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 whethersessions.user_idcan 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 inauth.guards, or a guard whose driver is notsessionthrows, 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_MAXand 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. Runphp artisan martis:sso:checkin 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
dashboardslayer 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
FieldErroranduseFieldErrorfrom@martis/runtimeafter refreshing the shims.ActionResponse::emit()takes an optional message andsilent()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
jtiis 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, answersreplayed. A listener or a log alert keyed onSsoFailedreasonstatefor a repeated callback seesreplayedinstead. 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'sthrottle:<max>,<decay>middleware is nowMartis\Http\Middleware\SpendSignedToken:<max>,<decay>: it spends the token of a signed-token provider and then applies the same limit through the app'sthrottlealias (sothrottleWithRedis()keeps working), whatever the provider. A host that declares the callback route itself, or lists its middleware by hand, should use it in place ofthrottlethere. 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
GETahead of the browser uses the token up, and the browser's own request is refused asreplayed. AHEADrequest on the callback of a signed-token provider is answered 405 before anything is read or recorded, so a scanner that probes withHEADfirst 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
environmentstops signing in until the consumer sets it too. The issuer adds theenvclaim to every token; a consumer with noenvironmentnow refuses a token that carries it (reasonenvironment, the log line says to setenvironment). Setenvironmentin 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_claimis removed, on the issuer block and on every provider block. The claim that carries the environment is alwaysenv; 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 issuerclaimsclass may no longer setenveither (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
unknownrefusal with the generic text on the login page). A refusal is anSsoExceptiononly, so a log alert keyed onSSO callback failedfor a badpublic_keysentry or an emptyissuernow finds the error in the exception log instead. - A provider
labelthat is neither a string nor a map of strings throws when the login page is built, namingmartis.auth.sso.providers.<name>.label. It was dropped without a word before. A map oflocale => stringis new and optional. A customauth:logincomponent that readsconfig.auth.sso.providers.*.labelas a string must handle a map now (resolveCopyValue(label, locale)fromauthCopy.tsdoes); 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
stateand 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 incache.storesthrows, 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, anarrayornullstore (the default one included, when the key is unset) now throws outside the test suite: set the key to a shared store, or pointcache.defaultat one. Changing the store on a running panel orphans the revocation times, the origin nonces and the one-timejtirecords of the old one: change it at a quiet moment and runrevokeFor()again for anyone revoked recently. Test doubles that mocked theCachefacade'sadd()for the one-timejtimust now answerCache::store().- New events
Martis\Events\SsoTokenIssuedandMartis\Events\SsoIssueDeniedon the issuing side, and a warning log line for a refused issue. They run inside the request, so a throwing listener ofSsoTokenIssuedstops 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.authcarries only what the SPA reads. It no longer has: any key of an SSO provider block other thanenabled,labelandicon; disabled providers;passwordReset.broker;registration.default_role;magicLink.ttlMinutesandmagic_link.auto_register;emailVerification.notice_url;passwordChange.column; the snake_case duplicates (magic_link,email_verification,password_change); the flatgoogleblock; any key an app added. A customauth:logincomponent that read one of them lists the provider keys it needs in that provider'spublicarray, or fetches the rest from an endpoint of the app. Onlyauthis 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"), andMartisSso::resolveUserUsing()is not called for it. An app that pre-provisions rows through refused sign-ins setsprovision_on_deny => trueon the provider. external_idmatching 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: setlink_by_email => trueon the provider to link it on its next sign-in (the row must pass the same email check as theemailstrategy), or fill the column yourself. A customresolveUserUsing()still wins.- Values that were silently ignored now throw, naming the key: an unknown
identity_match_attribute, a non-stringidentity_external_id_column, anon_no_role_matchother thandeny,guestorcallable(it signed the user in role-less), alogout_urlthat is neither a string nor null. - SSO callback URLs are lower case.
/sso/Azure/callbackis 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); theemailkey is gone. A failure after the user was resolved (a throwingafterLoginhook, 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 customauth:logincomponent reads it fromwindow.MartisConfig.flash.sso. - New refusal reason
unlinked_account: anexternal_idsign-in whose address is held by a local row with no id, withlink_by_emailoff. It was reported asaccount_unverified; a listener onSsoFailedor a log alert keyed on that reason adds the new one. - The SPA waits 300 ms before it leaves for
login?expired=1on a 401, so a response that carriessso_reauth_urlcan win. MartisSso::afterLogin()appends. Registering twice ran only the last closure; both run now, in order.MartisSso::forgetHooks()clears them.RouteMiddleware::authenticated()includesmartis.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|githubnow 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_minutesorrevalidate_every_minuteson a provider needremember => falseon 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-sodiumis required on both. Start withphp artisan martis:sso:keys, then SSO, section 18. - Subclasses:
IdentityResolver's protectedfind()is nowfindByEmail()/findByExternalId(), andcreate()/syncAttributes()take the provider and its config;SsoSession::start()andSsoIdentityhave 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 underpublic/(the default/vendor/martis-user/extensions.jsdoes). 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 setMARTIS_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) forreloadOnStaleChunkin the runtime shim and the code-splittingvite.extensions.config, thennpm run build:extensions. Existing eager registrations keep working unchanged. - New public members on
Action(canRunForRows(),hasBatchedCanRun(),authorizedToRunRows()),Resource(prefetchIndexPage(),menuCountCache(),searchMode(),$searchMode),Tool(menuCountCache()) andMetric($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.ActionContractandResourceContractare unchanged, so a class that implements a contract without extending the base class keeps working, without the new behaviour. martis.search.match_modeis the new search matching key.martis.search.modeis 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-rowcanRun(), 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 itsname()./dashboards/support-teamreadSupport 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. Abrand.page_titleorMartis::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, asGET /api/dashboardsdoes. The pages served before sign-in, the 2FA challenge and the "no access" (403) page carry no list. A dashboard whoseauthorizedToSee()throws, or whosemeta()cannot be encoded as JSON, no longer breaks the page: the error is reported and the SPA requests the list itself. usePageTitle()anduseDynamicCrumb()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/dashboardswas 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), sincedependsOn()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,BelongsToManyorMorphToManywithouttitleAttribute()now shows the related resource'stitle()when that resource declares one (atitleAttribute()other thanid, or its owntitle()), instead of thenameattribute. Where the cell showed#4, it now shows the record's title; a field that setstitleAttribute()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'sdefaultSortis now[{ attribute, direction }](nodefaultSortDirection), and a customcomponent:Tableoverride receivessortandonSort(attribute, additive)instead ofsortBy,sortDirandonSort(attribute). A saved index sort (sticky view) is read once in its old shape. ActionContract.confirmText(),confirmButtonText()andcancelButtonText()takestring|Closure: a class that implements the contract directly (not throughAction) 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), withposition: fixedand 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-panelor the list's own class..martis-belongs-to-dropdownlost itsposition: absolute,left,right,z-indexandmargin-topand became a flex column (.martis-belongs-to-optionsgainedflex: 1 1 auto; min-height: 0): a component of your own that borrowed the class for anabsolutepanel sets those itself, and a stylesheet that setsdisplayon it keepsflex. See Components → Picker lists float above modals and drawers. - A resource's sidebar item stays active when the URL carries a
?filters=that noMenuItem::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
documentregistered 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 valuehandle()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 adefault()you did not want on screen, or move it to the model or the database. An app that overrides the action modal (component:ActionModalin 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 handlenull(bulk, standalone, no record named). An app that hard-coded the record insidefields()can drop it. The fields endpoint answers 404 for aresourceIdthe 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. CallshowOnIndex()on one you want there. - An Action class of your own that declares a method named
selectedModel()orwithSelectedModel()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,resourceIdandcontext. A display that spreads its props onto a DOM element now passes them through too; pick the props it renders instead. To useuseRunAction(),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_rememberedmarker 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". SetMARTIS_2FA_TRUST_REMEMBER_ME=falseto keep the challenge on every new session, the behaviour up to v2.14.x. A publishedconfig/martis.phpwithout the key keeps the default (on). -
Declare
opensInNewTab()on an action whose run answersActionResponse::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 isopenInNewTab(). An app that overrides the action modal (component:ActionModalin 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 withnoopener,noreferrer, which made a blocked tab undetectable; the panel now opens a blank tab, cuts itsopenerand then sends it to the URL, with or withoutopensInNewTab(). Under the browser's default referrer policy another site receives the panel's origin only, not the path. Serve the panel withReferrer-Policy: no-referrerif 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. SetconfirmText()for your own sentence; overrideactions.confirm_runinlang/vendor/martisto change the default. -
A theme that restyles
.martis-notif-panelkeepsright: var(--martis-notif-shift, 0px)andmax-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:endcomponents 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 newplacementprop ('topbar','drawer'or'breadcrumbs', typeTopbarSlotPropsfrom@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 receivesonToggleSidebar(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-breadcrumband.martis-breadcrumb-labelclasses. - The shell's content column is
minmax(0, 1fr)(it was1fr) 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-familyand--font-feature-settingsvariables 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-settingsitself can drop those two lines. --martis-font-feature-settingsis 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 tonormalor to that family's features. An existing theme without it keeps the default;php artisan martis:theme:difflists it under "Missing in consumer" and exits2, 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_FAVICONset, the panel linked the file twice, asimage/x-iconand as a 32x32image/png. It now emits one<link rel="icon">typed by the extension (.svg→image/svg+xml,.png→image/png,.ico→image/x-icon; notypefor an extension it does not know). An app that publishedresources/views/vendor/martis/app.blade.phpkeeps 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.phppublished before v2.12.0 has noeditablekey and keeps every preference editable. To use the switch, add the key to thepreferencesblock (copy it from the package config) or republish the config: settingMARTIS_PREFERENCES_EDITABLEwithout the key throws anInvalidArgumentExceptionthat says so, as does an unknown name in the list. See Preferences → Choosing which preferences users may change. theme.allowToggle => falseis applied by the server. Up to v2.11.1 it only hid the theme pickers: a theme a user had stored earlier still applied, andPUT /api/preferencesstill saved one. Now the stored theme is ignored, everyone getspreferences.defaults.theme(MARTIS_DEFAULT_THEME), and a request that changes the theme answers422. 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/preferencesstill saved abrandColor, and a stored one applied. Now a hex is refused with422, a stored one is ignored, and an accent stored ascustomresolves topreferences.defaults.accent.brandColor: nullis still accepted and still clears adefaults.brandColorwhen a user picks a swatch. If your users rely on a stored hex, setMARTIS_ALLOW_BRAND_COLOR=true.meta.editableis a new key of the/api/preferencesresponse, andwindow.MartisConfig.preferences.editablea 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}/detachandPUT .../{relatedId}/pivotanswered200for a record that exists but is not attached to the parent (the detach changed nothing), and404for an id that does not exist, so the routes told another tenant's records apart from missing ids. Both now answer404with the body of a missing id (Related record not found.), before thedetach{Model}/updatePivot{Model}policy is asked. A client that detaches the same record twice gets404on the second call instead of200; the panel shows the message in its detach dialog. AnauthorizedToDetach()override or adetach{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 .../attachanswered404(Related record not found.) for an id that names no record and422(or403fromattach{Model}) for a record the picker hides, so it told them apart too. A missing id now fails like a hidden record,422onrelated_idwith 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 with422instead of being listed inmeta.errorsand skipped. A client that read the attach's404reads the422now. See Relationships → Writes follow the pickers. - A relationship with a custom
relatedKeyattaches the right value. ForbelongsToMany(Skill::class, 'course_skill', 'course_id', 'skill_code', 'id', 'code')(or themorphToMany()equivalent) the attach wrote the skill's primary key intoskill_code, and the detach and the pivot update looked the row up by it and found nothing (the detach answered200). They now usecode, 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/detachand.../abc/pivotanswer404, and an attach ofabcanswers422; on PostgreSQL they answered500.
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 alwaystrue, soResponse::deny(),Response::denyAsNotFound()andResponse::denyWithStatus()allowed the request (a security fix, also in v1.39.8). If a policy of yours returns aResponse, 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()answers404on the record endpoints. A record whoseview,update,delete,restore,forceDeleteorreplicatethe policy refuses this way answers404, with the body of a missing record, instead of403;Response::denyWithStatus()answers its status. To hide which records a narrower resource can reach (a headless relation target'speek, 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
BelongsTowithoutrelatedResource()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. ABelongsTowithoutrelatedResource()that answered422("Declare relatedResource() on the field.") because a headless resource shared the related model is now checked against the routable resource'srelatableQuery()and policies; if it should be checked against the headless one, declarerelatedResource()on the field. A workaround that reboundResourceRegistryto sort routable resources first can go. See Resources → A headless resource over a model that has a page. - A Gate ability that returns a
Responseis read the same way by the ability cache and the denial audit. Withmartis.authz.request_cacheon,RequestScopedAbilityCache::lookup()returnedtruefor an ability that returnedResponse::deny(); it now returnsfalse. Withmartis.audit.authz_denialson, a Gate denial that is aResponseis now written asauthz.denied, where only afalseanswer was before, so expect rows you did not get earlier. - A policy without
replicatehands the status of itscreateorupdatedenial to the replicate prefill. A policy that answersupdatewithResponse::denyAsNotFound()made the update route answer404and the prefill403; both answer404now. - Do not use
401or419inResponse::denyWithStatus()for a record. The panel reads a401as 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:installnever rewrites an existing provider, so aviewMartisgate published by v2.4.0 to v2.10.0 still checks the address against an empty inline list, which PHPStan reports asCall 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-providerfor 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-constructoronDashboardandTool), a filter'sapply()and a lens'squery()without generic types (missingType.generics), and arelatableQueryUsing(),MartisSso::syncRolesUsing()orMartisSso::afterLogin()arrow function that returns a value (argument.type) no longer fail. Remove the baseline entries or@phpstan-ignorecomments 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 invendor/martis/martis/stubs/. If you published the stubs withphp artisan martis:stubs, your copies instubs/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 conditionalrequired_*rule) is no longer required unconditionally. Before,Text::make('first_name')->rules(['nullable', 'required_if:type,person'])got an asterisk and a literalrequiredin front of its rules, so it was required on every request, and two mutually exclusiverequired_iffields made every create fail with a422. Now only the exactrequiredrule (orRule::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
BelongsTomap is reduced to its id before the rules run. A create from a relationship panel posted{ "contact_id": { "id": "13", "title": "Ana" } }, which aRule::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). AfillUsing()callback on aBelongsToreceives 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
idandtitlekeys to its id (and the update form dropped any object with aurlkey), whatever the field: an untouchedKeyValueor JSONCodefield whose map had such keys was saved as an id or left out. Only abelongs_tovalue is reduced now, and only a stored file (file,image,avatar,audio) is left out of an update. ABelongsTowhose target is soft-deleted now posts<attribute>_trashed: truebeside the id (Nova's opt-in), where the update form used to answer422on 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-manyormorph-onepanel refreshes that panel (it refreshed thehas-manyone). -
react-domandreact-dom/clientfor extensions. Thereact-domshim servescreatePortal,flushSync,unstable_batchedUpdatesandversion; a newreact-dom/clientshim servescreateRootandhydrateRoot. To use them (a library such as@dnd-kit/coreimportsunstable_batchedUpdates), republish the shims, then bring the Vite config andtsconfig.extensions.jsonup to date, because thereact-dom/clientalias and path live there and the shims tag leaves them alone:php artisan vendor:publish --tag=martis-extension-shims --forceThen add the alias and the
pathsentry shown in Refreshing the extension scaffold (/^react-dom\/client$/invite.extensions.config.ts,react-dom/clientintsconfig.extensions.json), or refresh the whole scaffold withphp artisan martis:install --force(it rewrites both files too: review the diff), and runnpm run build:extensions.The legacy root APIs
render,hydrate,findDOMNodeandunmountComponentAtNodestay out: React 19 removed them. An extension built with the earlier shims keeps building and running. See The react-dom shims. -
ResourceIconis on@martis/runtime. An extension imports it instead of bundling@phosphor-icons/reactcopies. 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 fromAPP_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, defaulten,pt_PT,pt_BR) replaces the literalpreferences.locales, andMARTIS_UI_LOCALE_LABELS(code:labelpairs, e.g."en_GB:English (UK),pt_PT:Português (Portugal)") is merged overpreferences.locale_labels, which gains anen_GBlabel. A publishedconfig/martis.phpkeeps the old literals until you merge the newlocales,locale_labelsandlocale_labels_envlines (or republish it withphp 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_BRafter the list drops it), a preset's and aMARTIS_DEFAULT_LOCALEoutsidepreferences.localesused 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'sdefault()rewrite it in place; a browser test that asserts the exact URL of a filtered dashboard sees the parameter. A card'ssetFilters()adds a history entry. - Custom cards receive a
setFiltersprop next tofilters. To type it, or to calluseDashboardFilters(), republish the shims:php artisan vendor:publish --tag=martis-extension-shims --force, thennpm run build:extensions. - A relationship panel answers a unique violation with
422, not500. 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 NULLor foreign key failure is no longer a duplicate. SQLite reports every constraint with the same code, and these answered422"A record with this value already exists."; they now answer500with 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'svalidation.uniqueline ("The Email has already been taken."), where it was the genericA record with this value already exists. Please use a unique value., which stays the response'smessage.
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=falsenow turns off the sign-in challenge. A user who enrolled while 2FA was on is no longer challenged, and the 2FA routes answer404(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:installrun without a TTY and without--with-2fawritesMARTIS_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 leavesecurityout ofprofile.sections: that hides the section and keeps 2FA in force.MARTIS_AVATAR_ENABLED=falseremoves the avatar endpoints.POSTandDELETE /api/profile/avataranswer404.MARTIS_PROFILE_EMAIL_EDITABLE=falserefuses a new address with422. A customProfileResourcewritten only to dropemailfromupdateRules()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_ENABLEDalready did, so withphp artisan route:cacherun it again (php artisan optimizedoes 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.phpkeeps working. Only the comments of itsprofileblock 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 asgetByRole('navigation', { name: 'Main navigation' }). A stylesheet that targetsaside.martis-sbtargets.martis-sb; one that uses a child selector (.martis-sb > .martis-sb-scroll) adds the.martis-sb-navlevel. - The top bar's breadcrumb wrapper is a
<div>..martis-tb-breadcrumbswas 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.phppublished before v2.7.0 ignoresMARTIS_LAYOUT_HEADER_FIRSTuntil you copy'header_first' => (bool) env('MARTIS_LAYOUT_HEADER_FIRST', false),into itslayoutblock. Thetopbar_startandtopbar_endcomponent keys are optional: without them the slots read their default keys,topbar:startandtopbar:end. overrides/TopbarStart.tsxandTopbarEnd.tsxneed two lines in yourindex.ts. The file is your app's own, so an install scaffolded before v2.7.0 addsTopbarStart: 'topbar:start',andTopbarEnd: 'topbar:end',to itsOVERRIDE_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"witharia-controlsandaria-expanded={sidebarOpen}on the menu button.useModalFocusgives 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}andGET /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-passwordandauth:invitation-acceptare now mounted at/reset-passwordand/invitations/accept, without a:tokensegment, souseParams().tokenanduseSearchParams().get('email')return nothing. Read them withuseAuthLinkParams()from@martis/runtime(Auth page overrides and emailed links), thennpm 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()orInvitationUrl::createUrlUsing()callback that points at the Martis pages builds the link withMartis\Support\TokenLink::url(). A callback that still callsroute('martis.password.reset', [...])orroute('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
POSTanswers422, after which the page goes to/login?magic_link=expired(orinvalid) as before. - A log filter or WAF rule written for
/reset-password/{token}or/invitations/accept/{token}should also match/reset-passwordand/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/framework12.41.1 or later (laravel/mcprequiresilluminate/json-schema^12.41.1). Laravel 13 is unaffected. laravel/mcp^1.0 conflicts withlaravel/boostbefore 2.9.0 and with a host's ownlaravel/mcp0.x. Update them together (composer update martis/martis laravel/boost -W). If your owncomposer.jsonrequireslaravel/mcp0.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-serveis removed. Over stdio an agent now spawnsphp artisan mcp:start martis-docs; over HTTP the server is a route of your app. Runphp artisan martis:agents --mcp-onlyto rewrite your agents' MCP config and the.envblock without touching your guideline files (AGENTS.md,CLAUDE.md, ...).- The default transport is now
stdio. An.envwritten by an earliermartis:agentsusually carriesMARTIS_MCP_TRANSPORT=http: with it, Martis registers the routePOST /{MARTIS_PATH}/mcp(/martis/mcpby default), which outside thelocalenvironment requiresMARTIS_MCP_HTTP_TOKEN. SetMARTIS_MCP_TRANSPORT=stdio(or delete the line) if you do not need HTTP. - The default HTTP path changes from
/mcpto/{MARTIS_PATH}/mcp, and the URLmartis:agentswrites is built fromAPP_URLinstead of a host and port. MARTIS_MCP_HOST,MARTIS_MCP_PORTandMARTIS_MCP_HEALTH_PORTare removed with the standalone daemon and its/healthendpoint. Delete them (commented placeholders an earliermartis:agentswrote can stay or go).martis:agentsrefuses to wire the MCP while one of them is set.martis:agentsno longer overwrites an existing guideline file (AGENTS.md,CLAUDE.md, ...) when it runs without a terminal and without--force: it prints<file> already existsand keeps the file. Before, a run from an agent, CI ordocker compose exec -Trewrote them. Pass--forcewhere a script relied on the regeneration.martis:agentsdoes not rewrite.envlines 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/mcpitself, so adont-discoverentry forlaravel/mcpin yourcomposer.jsonno longer applies. With Laravel Passport installed,laravel/mcpadds anmcp:usescope. In an app that callsMcp::oauthRoutes(), the 401 of the Martis route advertises the app's OAuth metadata, but the route accepts onlyMARTIS_MCP_HTTP_TOKEN. - A systemd unit or a docker-compose service that ran
martis:mcp-serveas 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 handphp artisan martis:publish-assetsphp artisan optimize:clearDefine 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).
Magic links
The emailed link opens a confirmation page, and the sign-in is POST /api/auth/magic-link/consume. Links mailed before the upgrade still work: a GET of the old URL is redirected to the confirmation page. A custom client posts email and token (and replace_session: true to replace another user's session).
Profile email change
PATCH /api/profile needs current_password when the email changes and answers pending_email: the address changes when the user follows the link sent to it (MARTIS_PROFILE_EMAIL_CHANGE_TTL, 60 minutes by default). A custom profile client sends the password and shows the pending state. The mailed link opens a confirmation page and changes nothing; the change is a CSRF-protected POST to the same signed URL (a client that followed the link with a GET must now post). Asking for the mail is limited to 5 per hour per user and per address (MARTIS_PROFILE_EMAIL_CHANGE_ATTEMPTS, 0 turns it off), answering 429 past it.
SSO
identity_match_attribute => 'email'adopts a local account only when its email is verified (email_verified_at) ormartis.auth.registration.enabledisfalse. 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 byexternal_id, or remove it. A model without anemail_verified_atcolumn cannot prove a verified address, so with registration open it is refused too: add the column (and verify the rows), or useidentity_match_attribute => 'external_id'. With registration disabled nothing changes.- Sign-in no longer sets a remember-me cookie. Set
'remember' => trueon 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'sremember_tokenrotates, so access removed at the IdP does not end those sessions. To end them at once, rotate theremember_tokencolumn 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 azurescaffolds it. A multi-tenant registration should match accounts byexternal_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) andMARTIS_2FA_LOCKOUT_MINUTES(15). - Forgot password answers
200for an unknown email too. - The
idof 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 answers403. - A Trix or Markdown field that accepts attachments declares
withFiles()(with a disk when not the panel's storage disk). Schedulemartis:attachments:prune. A custom caller ofPOST /api/attachments/uploadsends 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 outsideapp.debug. - A custom BooleanFilter
apply()receives only the keys itsoptions()declares: read explicit keys, never use them as column names. - A metric
?range=outsideranges()falls back to the default; Sparkline writes abovemaxPointsor with non-numeric items fail validation. ActionResponse::redirect(),download()andopenInNewTab()refusejavascript:,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 astitle_attribute) and ignoressubtitle_attributeand any other column. api_docs.middlewaredefaults tonull, the Martis stack; a published['web', 'auth']is completed with the Martis guards.- Field
help(), HTML tooltips and gate messages (messageHtml) losestyle,id,name, forms and form controls when rendered (scripts, handlers and unsafe URLs were already removed). An inlinestyle="color:red"no longer applies: use aclass(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 loseslabel,fieldset,legend,datalist,outputand every<input>but the disabled task-list checkbox. A document that relied on utility classes (class="text-red-500") loses them. - A notification
action_urland an external menu link in the command palette may bemailto:/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)}ortrustHtmlTooltip(el)from@martis/runtime. - To run the panel under a Content-Security-Policy, set a nonce (
Vite::useCspNonce()) and sendscript-src/style-srcwith it: see Configuration → Content Security Policy. vite.extensions.config.tsis copied once: setbuild.sourcemaptofalsein yours and deletepublic/vendor/martis-user/extensions.js.map.- Build API paths with
apiPath/pathSegmentfrom@martis/runtime, notencodeURIComponent(): Laravel decodes%2Fbefore routing, so an encoded slash in a record key could reach another route. Refresh the shim withphp artisan vendor:publish --tag=martis-extension-shims --forceto import the named exports (the default exportruntime.apiPathworks without it). A path value holding a literal%2Fnow throwsApiError(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:userrefuses 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-missingAction responses
ActionResponse::visit($path)navigates inside the SPA, to$pathbelow the Martis base path, with its$paramsas 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')becomesvisit('/resources/users').modal()shows its component andemit()emits onmartisEventBus; both used to show the generic success toast. Register the component of amodal()answer (see Actions → Custom modal responses).download()names the file after$filename.- The built-in bus events fire.
martis:record-created/updated/deleted/restoredandmartis:action-executedwere documented and never emitted. A listener that never ran will now run. Theirid(and theidsofmartis:action-executed) is always a string, whatever emitted it: compare withString(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 --forceThen 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 --forceUntil 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_PTa date reads27/09/2026and a number1234,5. The bundledenlocale formats with US rules (9/27/2026,1,234.5) on every browser, where it used to follow the browser (27/09/2026on anen-GBbrowser). The Currency and Number inputs take the same decimal separator, and the metric charts their axes and tooltips. A Currency field withlocale()keeps that locale. See Internationalisation → Number and date formatting. - The
aggregateVia()tile shows a number, not EUR. A column named likeamount,priceortotalwas 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.jsout while the file does not exist, andnpm run build:extensionsbrings it back with no other step. Any otherMARTIS_EXTENSIONSURL is still loaded as configured.
New and optional:
- Restrict the panel with the
viewMartisgate (Authorization → Panel access). Without it the panel is open only in thelocalandtestingenvironments (v2.4.0; before, every signed-in user got in). - Install without migrating:
php artisan martis:install --no-migrate, thenphp artisan migrate. - Add palette commands with
Martis::commandPalette()(Components → App commands). - Scope the notification centre with
Martis::scopeNotificationsUsing()(Notifications → Scoping the notification centre).
When to act:
- Your users expect another English format. Add a regional code such as
en_GBtomartis.preferences.locales(and a label tolocale_labels), and let those users pick it: it translates throughenand 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 theviewMartisgate refuses gets the full SPA instead of the standalone screen. Republish it, or port three changes of the package'sresources/views/app.blade.phpinto it:panelForbiddenandpanelForbiddenImpersonatinginwindow.MartisConfig,extensions: {!! json_encode(\Martis\Support\ExtensionBundles::urls()) !!}, and thescopedkey ofnotifications.
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 forMartis\Models\ActionEventdefinesviewAny/view(then the policy decides, as before). Without access the index and the detail answer403, 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/changesreads******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 aspassword, 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. $hiddenattributes are stored masked. From v2.0.1 an event stores******for each$hiddenattribute of the model (or of the pivot model) an action changed, as Nova does. Code that read those values frommartis_action_eventsgets 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.reactRouterDomis React Router 7'sreact-router-dommodule: every export ofreact-router, with the DOMRouterProvider. Names React Router 7 removed are gone from it (json,defer,AbortedDeferredError, theUNSAFE_internals of v6), so code that read one off the shim's default export getsundefined.- 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.mtscarries the React Router 7 types. Six type names React Router 7 no longer exports are gone:FutureConfig,Hash,JsonFunction,Pathname,SearchandV7_FormMethod(usestringorPath['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 andtsconfig.extensions.jsonsendreact-routerto the same shim. An older scaffold keeps importing fromreact-router-dom, or adds the two lines by hand (see Installation → Extensions and React Router 7).
Relationship panels follow the related resource's viewAny
A relationship panel (HasMany, HasOne, MorphMany, MorphOne, BelongsToMany, MorphToMany, and HasManyThrough, HasOneThrough, HasOneOfMany, MorphOneOfMany) was shown, and its records listed, to any user who could view the parent record. From v2.0.1 it follows Nova: a user the related resource does not let viewAny does not see the panel on the detail page, and its routes (the list, the card, the attachable list, attach, detach, the pivot update and the pivot actions) answer 403. v2.0 already refused the writes.
What to change: grant viewAny on the related resource to the users who should keep seeing the panel, and confine the rows they see with indexQuery(). See Relationships → Panels follow the related resource's viewAny.
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.0Then 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].
| Call | v1.x stored | v2.0 stores |
|---|---|---|
options(['Draft' => 'draft']) | draft | Draft |
options(['draft' => 'Draft']) | Draft | draft |
options(fn () => User::pluck('name', 'id')->all()) | the name | the id |
options(['draft', 'published']) | draft, published | 0, 1 |
Nothing fails at runtime when an array keeps the v1 order: the field stores the label instead of the value. Review every call.
What to change:
- Flip every associative array written label first.
['Draft' => 'draft']becomes['draft' => 'Draft'], andpluck('id', 'name')becomespluck('name', 'id'). - Turn lists into maps. A list is read as Nova reads it, keyed 0, 1, 2…, so
['draft', 'published']now stores0and1. When each value is its own label, passarray_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)orarray_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. - Rewrite MultiSelect groups in Nova's format.
['Backend' => ['PHP' => 'php']]becomes['php' => ['label' => 'PHP', 'group' => 'Backend']]. The old format now throws anInvalidArgumentExceptionthat names the field and the option. - Replace
optionsFromMap()withoptions(). 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. - Check
searchOptionsUsing()closures. They return the same shapes asoptions(), 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'), orarray_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. - Review what else reads a flipped array. On v1.x one array could serve both a field and a
SelectFilter(for example aPost::STATUSESconstant). Filters keep[label => value](see below), so once the array is flipped for the field, the filter showsdraftas the label and applieswhere status = 'Draft': zero results, no error. Give the filterarray_flip(Post::STATUSES), or its own array. Review the other readers of a flipped array the same way: aRule::in(array_values(...))rule now lists the labels (usearray_keys()), and a Badge map or adisplayUsing()lookup built from it is now keyed by value. - Update generated code. The
BulkAssignRoleaction thatmartis:rolesgenerates builds its role picker withpluck('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 aQueryExceptionon PostgreSQL, whose integer id column rejects the text. Change it topluck('name', 'id')(new scaffolds already do). If you published the stubs withmartis:stubs, updatestubs/martis/roles-bulk-assign-role-action.stubthe same way. TheAGENTS.md/CLAUDE.mdprimers thatmartis:agentswrote under v1.x recommendoptions($enum::values()), which v2.0 reads as a list: regenerate them withphp artisan martis:agents --force(updatestubs/martis/agents/AGENTS.md.stubfirst if you published it). - Subclasses. A subclass of
SelectorMultiSelectthat overridesoptions()must acceptiterable|Arrayable|string|\Closure:options()now takes a Collection or any other iterable, as in Nova, andMultiSelect::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'soptions(Request $request)is unchanged, exactly as in Nova. An array a filter shares with a field is the exception: see step 6. BooleanGroupalready read[flag key => label].- Enum classes (
options(Status::class)) store the case value, as before;MultiSelectnow accepts them too. - The field payload keeps its shape (
options: [{label, value, group?}]), so custom frontend components keep working.groupis new onSelect, whose dropdown now shows grouped options under their headings.
Relationship writes need the related resource's viewAny
Creating, editing or deleting a record through a relationship panel (HasMany, HasOne, MorphMany, MorphOne, and their endpoints) now needs the related resource's viewAny, as its own per-id endpoints do since v1.34.0 and as Nova does. v1.x checked only the parent's viewAny / view and the related record's create / update / delete.
A user whose policy denies viewAny on the related resource gets a 403 on those writes, and the panel no longer offers Create, Edit, Delete, Restore or Force delete. In v2.0.0 it still listed the records, as in v1.x; from v2.0.1 the panel is hidden too (see Relationship panels follow the related resource's viewAny).
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.
Relationship panels list what the related index lists
A HasMany, HasManyThrough, MorphMany, BelongsToMany or MorphToMany panel now applies the related resource's scopes() and indexQuery(), as its index does and as Nova's relationship index does. v1.x listed every record of the relationship, including the ones those hooks hide (another tenant's, archived ones).
What to change:
- 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). - Know what reaches a panel. On a plain
HasMany/MorphManypanel the hooks run grouped on the panel's query: anorWhere()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'srunAction/runDestructiveActionpolicy (falling back toupdate/delete), as the run always did: an action the policy refuses shows disabled instead of failing when run. AcanRun()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 runninghandle()on nothing and answering "Done". - 422 with an empty selection. An action that is not
standalone()and names no record answers422(resources), as a pivot action already did. Astandalone()action runs on no record, whatever the request names. scopes()apply. A run looks records up through the resource's declarativescopes(), thenindexQuery(), as the index lists them.- A panel's run finds what the panel lists. A run from a
HasMany,HasManyThroughorMorphManypanel (viaResource,viaResourceId,viaRelationship) and a pivot action on aBelongsToMany/MorphToManypanel resolve the records the panel lists: the relationship's rows (a global scope it removes stays removed), the related resource'sscopes()andindexQuery(), 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 astandalone()action run with a relationship the parent does not declare answers 404. - A queued action handles what the run resolved. Its job (
ExecuteAction,ExecutePivotActionfor 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
totalof its group no longer counts it. - A
BelongsToManypanel, a pivot action or a pivot field picker whose parent record the scopes hide answers404, as it already did for a parentindexQuery()hides. - On those surfaces and on an action run, an
orWhere()inscopes()orindexQuery()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 aswhere('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
423from a tool route (a redirect to the challenge for a page request), and a user who has not verified an email the app requires gets409(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) gets404from its routes, as from the tool's page. - A tool's routes count toward the API throttle,
MARTIS_THROTTLE_MAX(120) requests perMARTIS_THROTTLE_DECAY(1) minute per user, shared with the rest of the API. A tool that polls often answers429past it. - They run in the user's locale, and an impersonation past its limit stops before they run.
- With a
MARTIS_PATHother thanmartis, they move from/martis/api/tools/{uriKey}/...to/{MARTIS_PATH}/api/tools/{uriKey}/...(ToolRoutes::prefix()), where the SPA'sapiclient 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:
- 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. - Routes a tool registers in
boot()withRoute::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 toRoute::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 inboot()shows. A route of your own thatmartis.authalone guards skips the 2FA challenge the same way: use themartis.apimiddleware group. - A client that calls a tool route by a hard-coded
/martis/api/tools/...URL with a customMARTIS_PATHfollows the new path, or goes through the SPA'sapiclient (api.get('/api/tools/...')). To keep the old URL, passprefix: 'martis/api/tools/{uriKey}'. - 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:
- Check the TTL. An empty
MARTIS_CACHE_SCHEMA_TTL=(the.envblock the v1.x docs showed) and aconfig/martis.phppublished before v2.0 keep "no expiration". SetMARTIS_CACHE_SCHEMA_TTL=1440, or republish the config and merge your changes. - 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:pruneafter 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:
--martis-dur-smand--martis-brand-500do nothing any more. No stylesheet defined them; the package now reads--martis-dur-fastand--martis-accentwhere it read them, andmartis:theme:difflists them as Unknown to package (exit 2). Set--martis-dur-fastand--martis-accentinstead.- Delete the two logo heights,
--martis-brand-logo-height-authand--martis-brand-logo-height-menu, unless you mean to overrideMARTIS_BRAND_LOGO_HEIGHT_AUTH/_MENU: the old stub declared them on:root(the menu one at 28px), which silenced those.envknobs. - 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:
- A
users.ycolumn.yes | php artisan martis:install --with-profileansweredyto the avatar column question: look for a migration addingytousersindatabase/migrationsandMARTIS_AVATAR_COLUMN=yin.env. Roll the migration back (or drop the column), delete it, setMARTIS_AVATAR_COLUMNto the column you want and run the installer again with--with-profile --avatar-column=<column>(and--with-2fawhen you use two-factor authentication): without a terminal, a feature you pass no flag for is turned off. - Your own notifications or sessions migration. If you ran
martis:install --forceover a migration you created withmake:notifications-tableormake:session-table, it now holds the Martis stub: compare it with your version control and restore it.--forceleaves it alone from v2.0. - Scripts that relied on a prompt. Pass the flags (
--with-profile,--with-2fa,--avatar-column=…,martis:user --email=… --password=…,--no-migrate) instead. - A
martis:userrun through a pipe.yes | php artisan martis:usercreated an admin with the email, name and passwordy, its email already verified: look for a user with the emailyand 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-assetsphp artisan martis:cache:clear schemaphp artisan martis:cache:prune- Republish the assets. The grouped
Selectdropdown and the fix for a''option ship in the frontend bundle; without the republish the browser keeps the v1 bundle. - 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_TTLis empty orconfig/martis.phpwas 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. - 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_idandinvitations.invited_by/accepted_user_idwith foreign keys to it, and adds the two-factor and avatar columns to it. An install migrated before v2.0.0 has them onusers: 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_idholds 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 ofconfig/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, andrevoke_sessions_on_demoteis 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\Notifiableto the Martis guard's model, or setMARTIS_NOTIFICATIONS_ENABLED=false. - Leave
MARTIS_IMPERSONATION_GUARDunset (it followsMARTIS_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'sfields.operator_type/operator_id, with nouser_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_GUARDto 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 whenMARTIS_AUTH_PASSWORD_BROKERis unset, and throws when that variable, or the'users'default of aconfig/martis.phppublished 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.