Skip to content

Releases

v2.23.0

Choose a tag to compare

@lmelomoura lmelomoura released this 12 Nov 13:58
b1b5bbe

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's sessions.user_id (default null: every session guard 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 the web guard Laravel merges into every app: SSO revokeFor(), authz.revoke_sessions_on_demote and the profile's Browser sessions then work. A value that is not a list of guard names, a name that is not in auth.guards, or a guard whose driver is not session throws, 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.breakGlass in the published auth config. A boolean: password sign-in is off and auth.password_login.break_glass_emails holds 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 carry data-field and data-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. The column block of a field gains textAlign and hideBelow (both null by 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, and silent() 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.
  • FieldError and useFieldError() are exported from @martis/runtime, so a custom input draws its error the way the built-in fields do and gets aria-invalid and aria-describedby. FieldWrapper uses the same component with the same markup.
  • A throttle bucket for the metrics. martis.throttle.metrics_max_attempts and metrics_decay_minutes (MARTIS_THROTTLE_METRICS_MAX, MARTIS_THROTTLE_METRICS_DECAY, default null: 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.enabled false turns both off.

Changed

  • The login page opens the password form on ?password_login=1 only when passwordLogin.breakGlass is true. With password sign-in off and an empty break_glass_emails, the parameter is ignored (nobody could use the form). The flag is computed without validating break_glass_emails, so a malformed list never fails a page render: it is reported by the break-glass sign-in that reads it, naming martis.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 40px dd indent is reset). It was ignored there before. Fields inside a Section honour fullWidth() (the whole row of the section grid, whatever colSpan() says) and hideLabelOnDetail(), and carry data-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-select stays 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 dashboards cache 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 and uriKey).
  • 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

Choose a tag to compare

@lmelomoura lmelomoura released this 12 Nov 09:53
9dee550

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 S in their own session) could get a signed-in user of panel A to open /{martis}/sso/issue/{audience}?state=S by a top-level navigation. Panel A signed for that user with S, the callback failed the state check in the victim's browser, but the jti was 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-time jti, 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 the throttle alias) runs the verification up to and including the jti first, 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 and ttl_seconds advice, and why neither Sec-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 (reason state) 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 environment refuses a token that carries the env claim (reason environment): the issuer is locked and the verifier would have ignored it. environment_claim is removed on both sides (the claim is always env): 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.
  • HEAD on the callback of a signed-token provider is 405, answered before anything is read or recorded, so a link scanner that probes with HEAD does not spend the token.

Added

  • Issuer audit events. Martis\Events\SsoTokenIssued (user, audience, request id) and Martis\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 same request_id; the SSO token issued line 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 throwing claims class, 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 of SsoTokenIssued stops 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 (the cannot_sign 500 included) even with zend.exception_ignore_args off. The request_id of 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: the jti record, 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 in cache.stores throws, 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, an array or null store throws outside the test suite.
  • Translatable provider label. martis.auth.sso.providers.<name>.label is a string or a map of locale => string, resolved on the login page the way auth.copy values are (exact locale, prefix, en, first entry), through the helper both now share (resolveCopyValue in authCopy.ts).

Changed

  • A configuration error of a signed-token provider block is a 500 on the callback, naming the key, not an unknown refusal with the generic text; a refusal is an SsoException only. The refusal of an expired state says it is older than the ten-minute lifetime (reason still state).
  • 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 label that is neither a string nor a map of strings throws, naming the key. It used to be dropped without a word. An empty-string label now reads as unset (the button says "Continue with "); it rendered an empty button before.
  • A signed-token callback whose jti was already used answers replayed even when its state is also wrong (it answered state before).

Tests: 6540 Pest + 2587 Vitest = 9127 passing.

v2.21.0

Choose a tag to compare

@lmelomoura lmelomoura released this 12 Nov 04:31
076cf4b

[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.auth is an allow-list. The login page, which is public, received the whole martis.auth array: 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 only enabled, label, icon and the keys it lists in a new optional public array, and only for enabled providers. The other blocks of the page shell (theme, search, notifications and 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/callback loaded the provider but read its settings under the name as typed, so the defaults applied (email matching, no session limit).
  • An on_no_role_match value other than deny, guest or callable throws, 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 PASETO v4.public token (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, the state kept in the session and a one-time jti, each refusal with its own reason. The issuing panel enables martis.auth.sso.issuer and serves GET /{martis}/sso/issue/{audience} behind the full panel stack: it redirects only to the audience's configured callback_url, can require a gate ability per audience (ability), refuses during an impersonation and carries email_verified. Martis\Sso\SignedToken\TokenIssuer, php artisan martis:sso:keys, martis:sso <name> --driver=signed-token and martis:sso <audience> --issuer. See SSO, section 18.
  • Reason for a refused SSO callback. Martis\Sso\SsoException (reason, optional messageKey shown on the login page), event Martis\Events\SsoFailed (provider, reason, exception) and a log line with provider, reason, request_id, exception and a message with credentials masked. Every refusal has a reason; a plain exception is unknown and 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(); event Martis\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 with sso_reauth_url for JSON, a redirect otherwise) and revalidate_every_minutes with the Martis\Sso\Contracts\RevalidatesSessions contract (stillValid($user, $externalId)). MartisSso::revokeFor($userId) ends a user's SSO sessions on every session driver, cycles the remember token and, on the database driver, deletes the session rows. logout_url may be an invokable class. Middleware martis.sso.session joins the authenticated stack.
  • Switch for password sign-in. martis.auth.password_login.enabled (MARTIS_PASSWORD_LOGIN_ENABLED, default true): off, the login page shows the SSO buttons only, POST /login and POST /api/auth/login answer 403 before the controller, and password reset is off. break_glass_emails lists 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 the Martis\Contracts\ChallengeVerifier contract: middleware martis.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 as auth:challenge and send its token as challenge_token; useAuthChallenge, AuthChallenge and AuthChallengeProps are exported from @martis/runtime. A Cloudflare Turnstile example is in Authentication.
  • Toast lifetime. martis.toast.durations per severity in milliseconds (null = until closed; defaults unchanged at 5000) and per call addToast(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_attributes accepts external_id; per provider link_by_email and provision_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 => true restores the old order.
  • remember => true cannot be combined with max_session_minutes or revalidate_every_minutes on 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_id never 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 when link_by_email is 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.sso and the login page shows it as an error toast.
  • php artisan martis:sso azure did not insert its block into a freshly published config: the commented example counted as "already declared".

v2.20.0

Choose a tag to compare

@lmelomoura lmelomoura released this 11 Nov 09:50
fce34b5

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 by registerFieldDisplay/Input and registerResourceFieldDisplay/Input: a tool, card, filter, action, override or field component is fetched from its own chunk the first time it is drawn. The component resolve() 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 idempotent componentRegistry.preload(key) and preload: 'idle' (fetch once the browser is idle after the app mounts; 'visible' behaves as 'never'). register(key, loader) recognises a plain () => import(...) loader, but only registerLazy is 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, default true).
  • martis.extensions_versioned_urls (MARTIS_EXTENSIONS_VERSIONED_URLS, default true) and a new page, Extensions: caching, with the exact Cache-Control headers for the versioned entry, the hashed chunks and everything else, for Caddy, nginx and Apache. The vite.extensions.config scaffold now writes code-split chunks to chunks/[name]-[hash].js.
  • Sidebar count badges cached on their own. Resource::menuCountCache() (static) and Tool::menuCountCache() return ['scope' => 'shared'|'user', 'ttl' => <seconds>] or null (the default: the count lives in the per-user badge map, as before). A shared count is computed once for every admin; the scope registered with Martis::cacheScopeUsing() is still part of its key, so tenants never share a count. The entries live in the navigation cache layer (cleared by martis: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, default null): 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) (and forgetMenuCounts()): 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 own menuCount(); a failure is reported as a MenuCountFailedException (badge key batch) 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, default null) for a metric that sets none, Metric::$cacheSeconds / cacheForSeconds() per metric, and Metric::$live / live() for a metric that is never cached. They go through the metrics cache 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 of HasMany, HasManyThrough, MorphMany, BelongsToMany and MorphToMany panels, 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, canSee callbacks, accessors and canRun(). 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 to canRun(), called once per page. A row the answer omits falls back to the per-row canRun(), 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 an UnexpectedValueException naming 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 overrides authorizedToRun() 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, default false): a lazy load while a page's rows are serialized throws in local/testing and logs a warning elsewhere. Scoped to the page's rows and restored afterwards; Laravel's global Model::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, and searchableRelations()), warns when one unindexed OR-ed column forces a sequential scan, and with --write generates a non-transactional migration (CREATE EXTENSION IF NOT EXISTS pg_trgm, CREATE INDEX CONCURRENTLY IF NOT EXISTS, a down() 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) and columns (only the columns named by col:term tokens). Config default martis.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 under public/ (ExtensionBundles::urls()), so it can be cached as immutable. URLs with a query string or fragment, absolute URLs and missing files are unchanged. Set extensions_versioned_urls to false to 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

Choose a tag to compare

@lmelomoura lmelomoura released this 10 Nov 11:44
e5c86a9

[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 list GET /api/dashboards answers, 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 user viewMartis refuses. A dashboard that throws or whose meta() cannot be encoded drops the seed (reported), never the page.
  • usePageTitle(segment, { pending }) and useDynamicCrumb(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 declare handle.crumbPending to 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/dashboards answered (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-team read Support team for a dashboard called Team) and / showed the generic default; both now read the dashboard's name() from the dashboards the user may see.
  • A cards dashboard waited for GET /api/navigation before drawing its tab strip and cards; only the built-in summary view needs it.

v2.18.3

Choose a tag to compare

@lmelomoura lmelomoura released this 09 Nov 15:36
ccc87d9

[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 (a MenuItem::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:end component 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 in topbar: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

Choose a tag to compare

@lmelomoura lmelomoura released this 09 Nov 06:55
bcf4f53

[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. DrawerShell locked the panel to its configured width (config('martis.drawer.width'), 720px by default) with min-width and flex-basis, which beat its max-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 is min(<configured>, 100vw). The panel is 100dvh tall, 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: fixed at the saved offset, since overflow: hidden alone 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 writing body.style.overflow.

v2.18.1

Choose a tag to compare

@lmelomoura lmelomoura released this 07 Nov 23:24
d54b3af

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 as resources/images/logo.svg and resources/images/martis-icon.svg. An app that sets its own logo (brand.logo in config/martis.php, MARTIS_BRAND_LOGO) is unaffected.

v2.18.0

Choose a tag to compare

@lmelomoura lmelomoura released this 07 Nov 22:17
3a06c79

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

Choose a tag to compare

@lmelomoura lmelomoura released this 05 Nov 10:55
c93ce30

[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 a dependsOn() 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 with withLabel() 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. KeepSlot keeps 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, and Resource::$maxInlineButtons moves the row buttons past a limit there. See Actions → Buttons and the "..." menu.
  • confirmText(), confirmButtonText() and cancelButtonText() 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 the UploadedFile, 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, so default(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.
  • Badges field: one value drawn as several pills, with Badge'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=desc still 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, since dependsOn() was introduced.
  • A relation field shows its related resource's title. A BelongsTo (and Tag, BelongsToMany, MorphToMany) without titleAttribute() read name and ignored the related resource, so a model without a name column showed #4 in the cell while the picker showed the right label. It now uses the related resource's title() when that resource declares a title, as Nova does; an explicit titleAttribute() 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:Table override. The schema's defaultSort is [{ attribute, direction }] and defaultSortDirection is gone. A custom component:Table override receives sort (the list of keys) and onSort(attribute, additive) instead of sortBy, sortDir and onSort(attribute). The table no longer re-sorts its rows on the client: the server's order is shown as it comes.
  • ActionContract::confirmText(), confirmButtonText() and cancelButtonText() accept string|Closure. A class that implements the contract directly (not through Action) widens its parameter type.