Repository navigation
Releases
v2.23.0
Minor release from three batches of consumer requests: SSO operations (a check command, a declared list of session guards, a stricter signed token, the break-glass flag on the login page), table and detail presentation (full-width detail rows, column alignment, responsive columns, a sticky actions column, dropdown selects), and behaviour (the dashboard cache follows the cards, an emit message for actions, a field error helper for custom inputs, a throttle bucket for the metrics). One bug is fixed: SSO revocation on the Redis cache store. Read Upgrading to v2.23.0: the login page now opens the password form on ?password_login=1 only when a break-glass address is configured, and a field that already calls fullWidth() changes on the detail page.
Security
- A signed token whose payload or footer JSON repeats a key is refused. The check runs at any depth and compares keys after decoding, so escaped spellings of one key collide. The refusal reason is
malformed. Tokens Martis issues never repeat a key, so nothing legitimate is affected.
Added
martis.auth.session_guards. Declare which guards write Laravel'ssessions.user_id(defaultnull: everysessionguard counts, as before). With a list, only the Martis guard (always counted) and the listed guards decide whether a session row can be attributed to one person, so an admin-only app is no longer read as ambiguous because of thewebguard Laravel merges into every app: SSOrevokeFor(),authz.revoke_sessions_on_demoteand the profile's Browser sessions then work. A value that is not a list of guard names, a name that is not inauth.guards, or a guard whose driver is notsessionthrows, naming the key and the guard. The warnings name the guards behind the tables. The list must be complete: a guard that signs in people but is left out can make Martis delete, or list, the session of another person with the same id.php artisan martis:sso:check. Read-only validation of the issuer block, each enabled signed-token provider block and the SSO cache store, with the readers the runtime uses. One line per block, never prints a key or any part of one, exit code 1 on any failure and 0 when all is well or SSO is off. For a deploy script.passwordLogin.breakGlassin the published auth config. A boolean: password sign-in is off andauth.password_login.break_glass_emailsholds an entry. The list itself is never published, and the flag is computed without validating the list.Field::hideLabelOnDetail()hides a field's label on the detail page and the drawer (the label stays for screen readers). Detail rows carrydata-fieldanddata-type.Resource::detailKicker()sets, hides (false) or keeps (null) the small heading above the detail page.Field::textAlign('left'|'center'|'right')aligns an index column, header and cells; a right-aligned column uses tabular figures. Thecolumnblock of a field gainstextAlignandhideBelow(bothnullby default). It does not apply to relationship tables yet.Field::hideBelow('sm'|'md'|'lg'|'xl')hides an index column on viewports narrower than 640, 768, 1024 or 1280 px. It does not apply to relationship tables yet.Resource::stickyActions()pins the actions column to the right edge of the index while the table scrolls sideways, with the background of the row in every state (stripe, hover, selected) in light and dark.ActionResponse::emit()takes an optional message, andsilent()drops the toast.emit($event, $data, 'Saved')shows that text instead of the generic one;emit(...)->silent()shows none. The event is still emitted and the table still refreshes; an existing call produces the same payload as before.FieldErroranduseFieldError()are exported from@martis/runtime, so a custom input draws its error the way the built-in fields do and getsaria-invalidandaria-describedby.FieldWrapperuses the same component with the same markup.- A throttle bucket for the metrics.
martis.throttle.metrics_max_attemptsandmetrics_decay_minutes(MARTIS_THROTTLE_METRICS_MAX,MARTIS_THROTTLE_METRICS_DECAY, defaultnull: nothing changes). When set, the routes that read a dashboard or compute a metric count against a bucket of their own and never also against the API bucket. An empty variable reads as unset; a value that is set and is not a positive whole number throws, naming the key.martis.throttle.enabledfalse turns both off.
Changed
- The login page opens the password form on
?password_login=1only whenpasswordLogin.breakGlassis true. With password sign-in off and an emptybreak_glass_emails, the parameter is ignored (nobody could use the form). The flag is computed without validatingbreak_glass_emails, so a malformed list never fails a page render: it is reported by the break-glass sign-in that reads it, namingmartis.auth.password_login.break_glass_emails, as in v2.22.0. - Detail rows and the drawer honour
Field::fullWidth(): one column, the label above the value, the value aligned with the label (the browser's 40pxddindent is reset). It was ignored there before. Fields inside aSectionhonourfullWidth()(the whole row of the section grid, whatevercolSpan()says) andhideLabelOnDetail(), and carrydata-field/data-type, the same as plain rows. - The per-page and trashed selects on the index, lenses and relationship tables are PrimeReact dropdowns instead of native selects. The class
martis-perpage-selectstays on the root, and the focusable input carries the accessible name.
Fixed
- SSO revocation on the Redis cache store. Redis hands the revocation time back as a string and it was read as an unknown type, so a user who was revoked and later re-enabled was refused at every sign-in until the marker expired. The marker now reads as an integer or a string of digits; any other value still counts as revoked.
- The cached dashboard payload follows the cards. The
dashboardscache layer keyed the payload by user and locale only, so a card or filter added, removed or reordered in code was not shown until the TTL ran out. The key now carries an order-sensitive fingerprint of the cards and filters (class anduriKey). - A right- or center-aligned column with a plain (not sortable) header moves the header with the cells. The sticky actions cell matches the colour of its row in dark mode.
v2.22.0
Minor release that hardens the signed-token SSO of v2.21.0 after a review of the replay surface: the one-time jti is used up before the state check, the state expires, the environment lock cannot be half configured, the issuer reports what it signs and refuses, the SSO cache store is configurable and the button label can be translated. Read Upgrading to v2.22.0 first: three of the changes alter what a panel accepts or throws.
Security
- State fixation of the signed token is narrowed to a request that never arrives. An attacker who started the flow at panel B (state
Sin their own session) could get a signed-in user of panel A to open/{martis}/sso/issue/{audience}?state=Sby a top-level navigation. Panel A signed for that user withS, the callback failed the state check in the victim's browser, but thejtiwas recorded last, so the token stayed good for its lifetime and whoever read that URL (an access log, a proxy, an extension) could replay it in the attacker's browser and be signed in as the victim. The verifier now checks, in this order, the structure, the signature, the claims and times, the environment, the one-timejti, and only then the state: a validly signed, in-time token for this audience is used up by its first arrival whatever the state. The record does not wait for the callback's rate limit: the callback route's middleware (SpendSignedToken, which then applies the same rate limit through thethrottlealias) runs the verification up to and including thejtifirst, so a callback answered 429 has still spent the token. The replay window is closed for a callback that reaches the panel; it stays open when the victim's request never arrives (a tab closed mid-redirect, a content blocker, a proxy that drops it, an on-path party), which SSO, section 18 says with the log andttl_secondsadvice, and why neitherSec-Fetch-Site, PKCE nor a consumer-signed state would help. - The state expires after ten minutes. The state
redirect()keeps in the session carries its creation time and is refused (reasonstate) when older than 10 minutes, a class constant with no config key. It is still pulled once. - The environment lock cannot be half configured. A consumer with no
environmentrefuses a token that carries theenvclaim (reasonenvironment): the issuer is locked and the verifier would have ignored it.environment_claimis removed on both sides (the claim is alwaysenv): a block that still sets it throws, naming the key and saying it was removed in v2.22.0. With a configurable name a locked issuer and an unlocked verifier disagreed without any error. HEADon the callback of a signed-token provider is 405, answered before anything is read or recorded, so a link scanner that probes withHEADdoes not spend the token.
Added
- Issuer audit events.
Martis\Events\SsoTokenIssued(user, audience, request id) andMartis\Events\SsoIssueDenied(user or null, audience, reason, request id), dispatched by the issue route. A refusal also logs a warning (SSO token issue denied) with the samerequest_id; theSSO token issuedline keeps its text. Reasons:unknown_audience(only while the issuer is enabled: a disabled issuer stays a silent 404),not_navigation,invalid_state,impersonating,forbidden. The HTTP answers do not change, the audience is cut to 64 characters and reduced to printable ASCII, and neither the token nor the state reaches an event or a log line. A failure while signing (a user with no email, a throwingclaimsclass, an unusable issuer block) is recorded as the sixth reason,cannot_sign, and the exception goes on (500). Refusals by the route's middleware (not signed in, 2FA, email verification, the panel gate, throttle) happen before the controller and raise no event. Listeners run inside the request: a throwing listener ofSsoTokenIssuedstops the sign-in. The state, the token and the secret key are#[\SensitiveParameter]in the signed-token classes, so they do not appear in a stack trace (thecannot_sign500 included) even withzend.exception_ignore_argsoff. Therequest_idof the issuer and of the failed-callback line is bounded by one helper (printable ASCII, 128 bytes). martis.auth.sso.cache_store(MARTIS_SSO_CACHE_STORE,null= the default store). One helper,Martis\Sso\SsoCache, owns the lookup and every SSO cache call uses it: thejtirecord, the revocation, the origin nonces, the ended-session markers and the Azure tenant lookup. The store must be shared by every server, must not evict early and must not be flushed on a deploy. A name that is not incache.storesthrows, naming the key; an empty value is unset; with SSO off the non-SSO paths (password sign-in, sign-out, the plain 401) never touch the cache; with SSO on, anarrayornullstore throws outside the test suite.- Translatable provider
label.martis.auth.sso.providers.<name>.labelis a string or a map oflocale => string, resolved on the login page the wayauth.copyvalues are (exact locale, prefix,en, first entry), through the helper both now share (resolveCopyValueinauthCopy.ts).
Changed
- A configuration error of a signed-token provider block is a 500 on the callback, naming the key, not an
unknownrefusal with the generic text; a refusal is anSsoExceptiononly. The refusal of an expired state says it is older than the ten-minute lifetime (reason stillstate). - An unusable SSO cache store fails a sign-in before anyone is signed in: password (SPA and form), magic link and invitation call
SsoCache::assertUsable()ahead of the guard (a no-op with SSO off), so the answer is the error and nobody is authenticated. The magic link and the invitation are not spent. - A provider
labelthat is neither a string nor a map of strings throws, naming the key. It used to be dropped without a word. An empty-stringlabelnow reads as unset (the button says "Continue with "); it rendered an empty button before. - A signed-token callback whose
jtiwas already used answersreplayedeven when its state is also wrong (it answeredstatebefore).
Tests: 6540 Pest + 2587 Vitest = 9127 passing.
v2.21.0
[2.21.0] — 2026-11-12
Minor release from ten consumer requests about sign-in: a panel that signs in only through SSO, including through another Martis panel. It fixes external_id matching, stops publishing the auth config to the browser, and adds a signed-token provider, session limits and revocation for SSO sessions, a switch for password sign-in, a challenge (captcha) hook and configurable toast lifetimes. Four behaviours change without any config: read Upgrading to v2.21.0. Republish the assets (php artisan martis:publish-assets), the config and the extension shims.
Security
window.MartisConfig.authis an allow-list. The login page, which is public, received the wholemartis.autharray: every SSO provider block (role maps, tenant ids, logout URLs, any key an app added), column names and broker names. Only the keys the SPA reads are published now (Martis\Auth\PublishedAuthConfig); of a provider block onlyenabled,label,iconand the keys it lists in a new optionalpublicarray, and only for enabled providers. The other blocks of the page shell (theme,search,notificationsand so on) are still published whole.- An empty external id no longer matches a local row. With
identity_match_attribute = external_id, an identity whose id was empty matched a row whose id column was empty. It is refused (missing_external_id). - An SSO callback path in another letter case is a 404.
/sso/AZURE/callbackloaded the provider but read its settings under the name as typed, so the defaults applied (email matching, no session limit). - An
on_no_role_matchvalue other thandeny,guestorcallablethrows, naming the key. It used to sign the user in with no role.
Added
- Signed token between two Martis apps.
driver => 'signed-token'in a provider block signs a panel's users in through another Martis panel with one redirect and a 60-second, one-time PASETOv4.publictoken (Ed25519,ext-sodium, no new dependency). The consumer (Martis\Sso\Providers\SignedTokenProvider) checks the signature against every configured public key (rotation), issuer, audience, expiry, age, an optional environment lock, thestatekept in the session and a one-timejti, each refusal with its own reason. The issuing panel enablesmartis.auth.sso.issuerand servesGET /{martis}/sso/issue/{audience}behind the full panel stack: it redirects only to the audience's configuredcallback_url, can require a gate ability per audience (ability), refuses during an impersonation and carriesemail_verified.Martis\Sso\SignedToken\TokenIssuer,php artisan martis:sso:keys,martis:sso <name> --driver=signed-tokenandmartis:sso <audience> --issuer. See SSO, section 18. - Reason for a refused SSO callback.
Martis\Sso\SsoException(reason, optionalmessageKeyshown on the login page), eventMartis\Events\SsoFailed(provider, reason, exception) and a log line withprovider,reason,request_id,exceptionand a message with credentials masked. Every refusal has a reason; a plain exception isunknownand keeps the generic text. - SSO hooks accumulate.
MartisSso::afterLogin($callback, provider: 'azure')appends and can be limited to a provider;onNoRoleMatchUsing()takes a provider too (provider-specific wins over global);MartisSso::forgetHooks(); eventMartis\Events\SsoLoggedIn(user, identity, provider) once per sign-in. - SSO session limits. Per provider
max_session_minutes(an SSO session older than that goes through the provider again: 401 withsso_reauth_urlfor JSON, a redirect otherwise) andrevalidate_every_minuteswith theMartis\Sso\Contracts\RevalidatesSessionscontract (stillValid($user, $externalId)).MartisSso::revokeFor($userId)ends a user's SSO sessions on every session driver, cycles the remember token and, on thedatabasedriver, deletes the session rows.logout_urlmay be an invokable class. Middlewaremartis.sso.sessionjoins the authenticated stack. - Switch for password sign-in.
martis.auth.password_login.enabled(MARTIS_PASSWORD_LOGIN_ENABLED, defaulttrue): off, the login page shows the SSO buttons only,POST /loginandPOST /api/auth/loginanswer 403 before the controller, and password reset is off.break_glass_emailslists addresses that may still sign in with a password (form at?password_login=1); every other outcome is the same 403. A warning is logged when the panel is left with no way in. - Challenge (captcha) hook.
martis.auth.challenge(enabled,verifier,site_key,applies_to) and theMartis\Contracts\ChallengeVerifiercontract: middlewaremartis.challenge:<surface>on the login, forgot, reset, magic-link, register and invitation POST routes refuses with 422 when the verifier says no or fails (fail closed). The forms render the component registered asauth:challengeand send its token aschallenge_token;useAuthChallenge,AuthChallengeandAuthChallengePropsare exported from@martis/runtime. A Cloudflare Turnstile example is in Authentication. - Toast lifetime.
martis.toast.durationsper severity in milliseconds (null= until closed; defaults unchanged at 5000) and per calladdToast(type, message, { life, dismissOnNavigate }). - The SPA follows the intended URL after sign-in. The login, 2FA challenge and forced password change responses carry
intended_url(a same-origin path under the panel only), so a visitor sent to sign in on the way to a page lands on it. sync_user_attributesacceptsexternal_id; per providerlink_by_emailandprovision_on_deny.
Changed
- A refused SSO identity is not written. Roles are decided before the local user is created or updated; where the role mapping needs the user, the write runs in a transaction that is rolled back on a refusal.
provision_on_deny => truerestores the old order. remember => truecannot be combined withmax_session_minutesorrevalidate_every_minuteson a provider: it throws, naming the keys.- A config value that is set but unusable throws naming its key:
identity_match_attribute,identity_external_id_column,logout_url, the session limits, the challenge and password-login blocks,toast.durations. - A failure after the user step of an SSO callback (a throwing hook, a unique index) is a refusal on the login page with
SsoFailed, not a 500.
Fixed
identity_match_attribute = external_idnever stored the id, so the second sign-in of an auto-created user was refused. The id column is written on create, an existing row can be linked by email whenlink_by_emailis on, and a row holding a different id is never re-linked.- A refused SSO sign-in showed nothing on the login page. The callback flashed its message but the SPA never read it, so the visitor was sent back to the login page in silence. The shell publishes it once as
window.MartisConfig.flash.ssoand the login page shows it as an error toast. php artisan martis:sso azuredid not insert its block into a freshly published config: the commented example counted as "already declared".
v2.20.0
Minor release from a consumer change request: opt-in performance building blocks for apps with many resources and extensions. Every default keeps the previous behaviour, apart from a ?v= version suffix on the extension bundle URL. Also ships the November dependency updates. Republish the assets (php artisan martis:publish-assets) and, to use lazy components, the extension scaffold. Read Upgrading to v2.20.0.
Added
- Extension components loaded on demand.
componentRegistry.registerLazy(key, () => import('./X'), { fallback, preload }), also accepted byregisterFieldDisplay/InputandregisterResourceFieldDisplay/Input: a tool, card, filter, action, override or field component is fetched from its own chunk the first time it is drawn. The componentresolve()returns carries its own Suspense boundary and error state, so every place that renders a registry component (tool pages, dashboard and metric cards, filters, overrides, action modals and drawers, field display and input, shell slots) works with a lazy one and the call sites are unchanged; eager registrations render exactly as before.componentRegistry.isLazy(key), an idempotentcomponentRegistry.preload(key)andpreload: 'idle'(fetch once the browser is idle after the app mounts;'visible'behaves as'never').register(key, loader)recognises a plain() => import(...)loader, but onlyregisterLazyis guaranteed. See Overrides → Lazy registration. reloadOnStaleChunk(error)on@martis/runtime: a tab opened before a deploy that asks for a chunk the deploy replaced reloads (at most once per 60 seconds per tab, never while offline) instead of showing a blank panel; a chunk that still cannot load shows an error state in place of that component only, and its Try again fetches the chunk again. The lazy wrapper applies it itself. Switch:martis.extensions_reload_on_stale(MARTIS_EXTENSIONS_RELOAD_ON_STALE, defaulttrue).martis.extensions_versioned_urls(MARTIS_EXTENSIONS_VERSIONED_URLS, defaulttrue) and a new page, Extensions: caching, with the exactCache-Controlheaders for the versioned entry, the hashed chunks and everything else, for Caddy, nginx and Apache. Thevite.extensions.configscaffold now writes code-split chunks tochunks/[name]-[hash].js.- Sidebar count badges cached on their own.
Resource::menuCountCache()(static) andTool::menuCountCache()return['scope' => 'shared'|'user', 'ttl' => <seconds>]ornull(the default: the count lives in the per-user badge map, as before). Asharedcount is computed once for every admin; the scope registered withMartis::cacheScopeUsing()is still part of its key, so tenants never share a count. The entries live in thenavigationcache layer (cleared bymartis:cache:clear navigation, skipped by the per-request bypass) and also serve the counts the menu embeds. See Cache. martis.cache.navigation.badge_ttl_seconds(MARTIS_CACHE_BADGE_TTL_SECONDS, defaultnull): the badge map TTL in seconds instead of whole minutes. A set but unusable value throws, naming the key.Martis::menuCountsUsing(Closure(Request, Collection $resourceClasses): array)(andforgetMenuCounts()): resolve several resource counts in one query. Called once per badge map, lazily, with only the resources that are navigable, opted in, authorised and unlocked; a class it leaves out keeps its ownmenuCount(); a failure is reported as aMenuCountFailedException(badge keybatch) and every class falls back.MartisCache::rememberFor($type, $key, $callback, $seconds):remember()with a TTL in seconds (same key, scope segment, switches and bypass).- Metric TTL in seconds.
martis.cache.metrics.default_ttl_seconds(MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS, defaultnull) for a metric that sets none,Metric::$cacheSeconds/cacheForSeconds()per metric, andMetric::$live/live()for a metric that is never cached. They go through themetricscache layer (clear, switches, bypass and cache scope apply). Custom cards have no server-side compute and keep travelling with the dashboard shape cache. See Metrics and the live-versus-cached table in Cache. Resource::prefetchIndexPage(Request $request, Collection $rows, PageContext $context): opt-in hook (default no-op) called once per listed page by the resource index, a lens and the tables ofHasMany,HasManyThrough,MorphMany,BelongsToManyandMorphToManypanels, after the page is read and before its rows are serialized. Never called when an action resolves its records, nor by detail, edit, create or count endpoints, nor by pickers. See Resources → Avoiding per-row queries on index pages.Martis\Support\PageContext: request-scoped store of per-row facts (put,putMany,get,has,PageContext::current($request)), readable from field resolvers,canSeecallbacks, accessors andcanRun(). Empty outside a listing request, so a reader falls back to its own rule.Action::canRunForRows(Closure(Request, Collection $rows): array<key, bool>): batched alternative tocanRun(), called once per page. A row the answer omits falls back to the per-rowcanRun(), otherwise it is denied; keys are compared as strings; a key that is no row of the batch (a list instead of a map) throws anUnexpectedValueExceptionnaming the action. The run, the fields request, pivot runs and the detail page decide with the same rule, so an action hidden for a row also refuses to run on it. A subclass that overridesauthorizedToRun()keeps its rule: a row runs only when both allow it. See Actions.Martis\Testing\AssertsFlatQueries:assertFlatQueries($seed, $request, few: 2, many: 20)asserts a request runs the same number of queries with few and many rows and lists the queries that repeated.martis.dev.prevent_lazy_loading_in_listings(MARTIS_DEV_PREVENT_LAZY_LOADING_IN_LISTINGS, defaultfalse): a lazy load while a page's rows are serialized throws inlocal/testingand logs a warning elsewhere. Scoped to the page's rows and restored afterwards; Laravel's globalModel::preventLazyLoading()is never turned on.php artisan martis:search-index {resource?} {--write} {--connection=}: reports the trigram (gin_trgm_ops) indexes behind each resource's database search (own fields, every searchable column whoever may see it, andsearchableRelations()), warns when one unindexed OR-ed column forces a sequential scan, and with--writegenerates a non-transactional migration (CREATE EXTENSION IF NOT EXISTS pg_trgm,CREATE INDEX CONCURRENTLY IF NOT EXISTS, adown()that drops them). Schema-qualified tables are supported. Read-only by default; a notice and exit 0 on other drivers.Resource::searchMode()/$searchMode:contains(default, unchanged SQL),prefix(ILIKE 'term%', index friendly) andcolumns(only the columns named bycol:termtokens). Config defaultmartis.search.match_mode(MARTIS_SEARCH_MATCH_MODE); an unknown mode throws, naming the resource and the value. New page Search performance on PostgreSQL.SearchResolver::searchTargets(): the own and relation columns the database search ORs together.
Changed
- The extension bundle URL carries
?v=<content hash>when it is a root-relative path to an existing file underpublic/(ExtensionBundles::urls()), so it can be cached as immutable. URLs with a query string or fragment, absolute URLs and missing files are unchanged. Setextensions_versioned_urlstofalseto keep the bare URL. - Dependencies: the November npm, Composer and GitHub Actions updates (i18next 26.5, PHPStan 2.3), with the type and analysis fixes they needed.
v2.19.0
[2.19.0] — 2026-11-10
Minor release from a consumer report: a dashboard named itself "Dashboard" until the list of dashboards loaded. Nothing has to change in an app; republish the assets (php artisan martis:publish-assets). Read Upgrading to v2.19.0.
Added
- The boot payload carries the dashboards (
window.MartisConfig.dashboards): the listGET /api/dashboardsanswers, live lock state included, built by one class (Martis\Dashboards\DashboardList) for both. The SPA seeds its['dashboards']query with it, so the breadcrumb, the tab title, the sidebar's DASHBOARDS group and the tab strip name the dashboard on the first render, and the request leaves the critical path. Null wherever the API would refuse the list: before sign-in, on the 2FA challenge page and on the "no access" page of a userviewMartisrefuses. A dashboard that throws or whosemeta()cannot be encoded drops the seed (reported), never the page. usePageTitle(segment, { pending })anduseDynamicCrumb(label, { pending })(@martis/runtime): while the label loads, the title stays as it is and the deepest crumb is an empty, fixed-width placeholder, instead of the generic default and the route's static label. A package route can declarehandle.crumbPendingto keep its static label until its page has published one (the dashboard routes do).- Dashboard skeletons in the dashboard's own shape: the grid spans of a dashboard's cards are remembered in this browser (
localStorage,martis-dashboard-layouts, cleared when the user signs out), and its next cold load draws its skeletons in that layout instead of three generic cards.
Fixed
- A dashboard opened by a reload or a bookmark read "Dashboard" in the breadcrumb and the sidebar, the generic "Admin Control" tab title and a three-card skeleton until
GET /api/dashboardsanswered (one to three seconds on a slow link). With the boot payload it is right from the first paint; without it, the crumb, the title and the sidebar show placeholders, never a wrong name. The single "Dashboard" sidebar entry and crumb of an install without dashboards only appear once the list answered empty. - The server-rendered title guessed a dashboard's name from its address (
support-teamreadSupport teamfor a dashboard calledTeam) and/showed the generic default; both now read the dashboard'sname()from the dashboards the user may see. - A cards dashboard waited for
GET /api/navigationbefore drawing its tab strip and cards; only the built-in summary view needs it.
v2.18.3
[2.18.3] — 2026-11-09
Patch release from a consumer report batch: the filter panel and the top-bar search. Nothing has to change in an app; republish the assets (php artisan martis:publish-assets). Read Upgrading to v2.18.3.
Fixed
- A
?filters=link opened the filter panel. The resource index forced the panel open when it took its filters from the URL (aMenuItem::filter()->applies()item, a shared link), so every filtered menu entry pushed the table down under the full set of filter controls. The panel now opens collapsed, the applied filters showing as pills next to Filters (n); the user opens it with the button, and a saved view still restores a panel the user left open. A?search=link and an index without state were already collapsed; a lens and a dashboard never opened the panel by themselves. - A lens collapsed its filter panel after each change. The panel is keyed by the URL's filters, so it started over, closed, every time a filter was picked or cleared. The lens page now holds the open state, and the panel stays open until the user closes it.
- A
topbar:endcomponent pushed the bell into the search box. The top bar's search was laid over the bar, centred, with a fixed 660px budget for its neighbours; a right-hand cluster wider than that (a status menu intopbar:end, a long user name) slid under it. The search now takes its room in the row: near the centre of the bar while both sides fit (the breadcrumbs and the right-hand cluster get equal room; the collapse button shifts it by a few pixels), moved left when the right-hand cluster is wider, narrowed down to 160px when the bar runs out of room, while the breadcrumbs truncate. It never overlaps the breadcrumbs or the right-hand cluster.
v2.18.2
[2.18.2] — 2026-11-09
Patch release from a consumer report: drawers on phones. Nothing changes on desktop; republish the assets (php artisan martis:publish-assets). Read Upgrading to v2.18.2.
Fixed
- A drawer was wider than a phone.
DrawerShelllocked the panel to its configured width (config('martis.drawer.width'), 720px by default) withmin-widthandflex-basis, which beat itsmax-width: 100vw, so on a 390px screen the left part of every drawer (title, labels, the start of each line) was off screen. At 768px or less (the layout's mobile breakpoint) a drawer is now the full viewport and hides the expand and fullscreen buttons; above that its width ismin(<configured>, 100vw). The panel is100dvhtall, so the iOS address bar no longer hides the footer. Applies to every drawer: create, update, detail, quick and action drawers. - The page scrolled behind an open drawer. On iOS Safari a swipe scrolled the page under the backdrop instead of the drawer body. While a drawer is open the document is now pinned (
position: fixedat the saved offset, sinceoverflow: hiddenalone does not stop iOS) and restored at the same scroll position on close; the drawer body contains its own overscroll and the backdrop ignores touch scrolling. The lock is reference-counted, so a drawer opened from a drawer holds one lock with it, and the mobile sidebar now uses the same lock instead of writingbody.style.overflow.
v2.18.1
Patch release: the new Martis logo. Nothing changes in an app; republish the assets (php artisan martis:publish-assets) to see it.
Changed
- New Martis logo: a hexagon holding an M folded from one ribbon, with a spark at the fold, in the cobalt to violet accent. It replaces the cube in the bundled sign-in logo (
resources/images/logo.png), the default menu icon (resources/images/martis-icon.png) and the README; vector copies ship asresources/images/logo.svgandresources/images/martis-icon.svg. An app that sets its own logo (brand.logoinconfig/martis.php,MARTIS_BRAND_LOGO) is unaffected.
v2.18.0
Minor release from a consumer report: a record of a resource with a detail override (DrawerOverride::detail() or a custom one) now opens over its index without reloading the page, and a resource can name the browser tab of its records with documentTitle(). Read Upgrading to v2.18.0.
Added
Resource::documentTitle(): ?string: the browser tab title of a record's detail, page or override.null(the default) keeps"{singular label}: {title()}". The detail response carries it as_documentTitle. See Resources → Browser tab title.
Changed
- A detail override opens over the index in place. The row click and the default View action push the record URL and keep the index mounted: the list stays on screen and is not fetched again, the bundled drawer opens at once with its own skeleton, and closing (or the back button) steps back to the index entry with its page, sort and filters. A direct visit to the record URL renders the index with the override on top once the schema has loaded. The top progress bar and the menu badge refresh no longer run for an open or a close. Before, the row click showed a full-page loader until the schema and the record arrived, then mounted the index again, and closing mounted it once more. See Overrides → Detail overrides open over the index.
Fixed
- A drawer swapping in for another one stacked a history entry per swap. Each detail → update → detail swap pushed one more Back sentinel, so after editing, Back (or closing) stayed on the record URL. The drawer that swaps in now takes over the previous one's sentinel, and a pop that leaves the drawer's page is left to the page instead of being taken for a Back press. The Back sentinels of drawers and modals now carry the router's location state, so closing a modal over a drawer no longer makes the router read a location without it.
v2.17.0
[2.17.0] — 2026-11-05
Minor release from a batch of consumer reports: fields that a sibling shows or hides, actions that hide what a record cannot run or keep its slot, flat items in the row menu, confirmation texts that name the record, file fields in action modals, a create form prefilled from its link, a Badges field, and multi-column sorting. Two bugs found on the way are fixed: no dependsOn() change ever reached the live form, and a relation field ignored its related resource's title. Read Upgrading to v2.17.0.
Added
hide()/show()for a field, from adependsOn()callback, as Nova's dependent fields: a hidden field is not drawn and its value is left out of the submit, so an update keeps the stored value; a panel, section or tab whose fields are all hidden is not drawn. The callback also runs when the form opens. Changing a label from the callback withwithLabel()is now documented. See Fields → Show, hide and relabel a field from a sibling.Action::whenInapplicable(InapplicableAction::Disabled | Hide | KeepSlot): how an action is drawn for a record it cannot run, on the index rows, the relationship panels and the detail page.KeepSlotkeeps an invisible placeholder so the row icons line up. A group whose actions are all left out, and an empty "..." menu, are not drawn. The detail payload now carries the record's_actionAuthorization, so the detail page greys out (or leaves out) what the run would refuse. See Actions → An action a record cannot run.Action::inRowMenu()puts an inline action in the row's "..." menu as a flat item, andResource::$maxInlineButtonsmoves the row buttons past a limit there. See Actions → Buttons and the "..." menu.confirmText(),confirmButtonText()andcancelButtonText()take a closure(Request, ?Model, int $count), resolved with the record the modal runs on by the fields request the modal already makes. See Actions → A text that names the record.- File fields in the action modal: a run whose fields hold a file posts multipart, and
handle()receives theUploadedFile, validated by the field's own rules. A queued action that receives an upload throws, as Nova does not support it either. See Actions → File fields. Resource::acceptsPrefill(): a create page forwards the query keys the resource declares to its schema, sodefault(fn ($request) => $request->query('of'))prefills the form from a link; a value the user could not pick is dropped.Field::sticky()keeps a field's value for the next record on "Create & add another". See Resources → Prefill the create form from a link.Badgesfield: one value drawn as several pills, withBadge's types and mapping. See Fields → Badges.- Sorting by several columns.
?sort=category:asc,position:asc,locale:desc(up to 3 keys;?sort=title&direction=descstill works) on the index, lenses and relationship panels. Shift+click (or Shift+Enter) on a header adds a column or cycles it asc, desc, removed, with 1/2/3 badges, and a "Sorted by" chip on the index toolbar reorders, flips, removes or adds keys, for keyboard and touch.defaultSort()may return an ordered['column' => SortDirection]array, and its keys break the ties of the user's sort, so one click on a column keeps the resource's order inside it.Field::sortUsing(Closure)sorts a column by an expression (a related table's order, a computed key). The index keeps the list in its sticky view. See Resources → Sorting by several columns.
Fixed
dependsOn()changes reach the form. The sync endpoint answers{ data: descriptor }and the form stored the whole envelope as the field's override, so no change made in a callback (required, options, help, label, visibility) was ever applied on the client, sincedependsOn()was introduced.- A relation field shows its related resource's title. A
BelongsTo(andTag,BelongsToMany,MorphToMany) withouttitleAttribute()readnameand ignored the related resource, so a model without anamecolumn showed#4in the cell while the picker showed the right label. It now uses the related resource'stitle()when that resource declares a title, as Nova does; an explicittitleAttribute()still wins. See Fields → BelongsTo.
Changed
- The detail page greys out the actions its record cannot run, from the same verdict as an index row, as Nova's detail menu does. Before, it offered every action and the run refused the record.
- A list always ends its order on the primary key, after the requested and default keys, so pagination is stable: rows that tie on a non-unique column may come in another order than before. A relationship panel with no requested sort now follows its related resource's
defaultSort(). - Schema payload and
component:Tableoverride. The schema'sdefaultSortis[{ attribute, direction }]anddefaultSortDirectionis gone. A customcomponent:Tableoverride receivessort(the list of keys) andonSort(attribute, additive)instead ofsortBy,sortDirandonSort(attribute). The table no longer re-sorts its rows on the client: the server's order is shown as it comes. ActionContract::confirmText(),confirmButtonText()andcancelButtonText()acceptstring|Closure. A class that implements the contract directly (not throughAction) widens its parameter type.