Dashboards
Dashboards are the pages that group metric cards, and Martis supports as many as you need. Each has its own set of cards, filters, and authorization.
See Metrics for the metric types (
ValueMetric,TrendMetric,PartitionMetric,ProgressMetric,ActivityFeedMetric,EndpointTableMetric) you mount on a dashboard.
Creating a Dashboard
use Illuminate\Http\Request;use Martis\Dashboards\Dashboard; class SalesDashboard extends Dashboard{ public function __construct() { parent::__construct('Sales'); } public function cards(Request $request): array { return [ TotalRevenue::make('Revenue')->width(4), SalesPerDay::make('Daily Sales')->width(8), SalesByRegion::make('By Region')->width(6), MonthlyTarget::make('Target')->width(6), ]; }}Cards sit on a 12-column grid that collapses to a single column below 768px; width() applies from there, with widthMd() / widthLg() for per-breakpoint spans. See Metrics → Card Width.
Registering Dashboards
Register dashboards in app/Providers/MartisServiceProvider.php (published by martis:install):
// app/Providers/MartisServiceProvider.phpprotected function registerDashboards(): void{ Martis::dashboards([ \App\Martis\Dashboards\MainDashboard::class, \App\Martis\Dashboards\SalesDashboard::class, ]);}When multiple dashboards are registered, the host decides per dashboard whether it sits in the sidebar or nests inside another dashboard (v1.10.5+).
- Root dashboards (default;
parent()returnsnull) appear in the sidebar'sDASHBOARDSgroup, one entry each. The first registered root doubles as the panel root link (/) so deep-link bookmarks and the sidebar stay in sync. - Nested dashboards (declared via
Dashboard::under('parent-uri-key')) are hidden from the sidebar and surface as a tab strip inside their parent's view. Each tab is a link to that dashboard's own address (/dashboards/child-uri-key, or/for the first registered dashboard), so switching tabs flips the URL, every tab is bookmarkable individually, Back returns to the previous tab, and middle-click / open-in-new-tab work. The dashboard's filters start empty after a switch: a tab links to the dashboard's bare address, without the?filters=parameter.
class HomeDashboard extends Dashboard{ public function __construct() { parent::__construct(name: 'Home', uriKey: 'home'); }} class RegimeHistoryDashboard extends Dashboard{ public function __construct() { parent::__construct(name: 'Regime history', uriKey: 'regime-history'); $this->under('home'); // appears as a tab inside HomeDashboard }} class ProLabDashboard extends Dashboard{ public function __construct() { parent::__construct(name: 'Pro Lab', uriKey: 'pro-lab'); // No `under(...)` — Pro Lab stays as a top-level sidebar entry. }}Result: sidebar lists Home and Pro Lab only. The Home page renders a tab strip with Home and Regime history; flipping to Regime history changes the URL to /dashboards/regime-history while the user stays on the Home group's tab strip, and the Home tab leads back to / (Home is the first registered dashboard, so / is its address).
Before v1.37.3 the tabs wrote a local selection that the route parameter shadowed, so on a /dashboards/{uriKey} page a click changed nothing and children were reachable only by URL.
Both surfaces share the same /api/dashboards payload and respect canSee() per dashboard. Children inherit nothing from their parent — they declare their own cards(), filters(), name(), etc. The relationship is purely a navigation shortcut.
Refresh Button
Override showRefreshButton() to add a manual refresh button:
public function showRefreshButton(): bool{ return true;}Authorization
Three layers, picked per dashboard.
Hard hide via canSee() — the dashboard is filtered out before the menu builds. User never knows it exists.
Martis::dashboards([ MainDashboard::make('Main'), SalesDashboard::make('Sales')->canSee(fn ($r) => $r->user()->isAdmin()),]);Declarative Policy class (v1.11.0+) — bind a Laravel Policy and the view ability decides:
class ProLabDashboard extends Dashboard{ public static ?string $policy = \App\Martis\Policies\ProLabPolicy::class;}Auto-discovery follows martis.policy_namespace — ProLabDashboard resolves to App\Martis\Policies\ProLabPolicy. No Gate::policy() call is needed: the check runs through Laravel's Gate and Martis registers the resolved policy for the dashboard class on the first check (v1.36.0; earlier versions hid the dashboard unless the host registered the policy by hand). A policy the host registered for the dashboard class is used when neither $policy nor the convention apply. See Authorization → How policy instances are resolved.
Soft-gate with upsell modal (v1.11.0+) — keep the dashboard visible (with a badge), intercept the click, show a customisable modal. Useful for plan-gated features where hiding the entry kills the upsell. See Soft-gates and badges for the full API.
$this->withBadge('Pro', 'accent') ->requirePlan('pro') ->lockPreset('pro');Dashboard Filters (Martis Extension)
Martis allows declarative filters on dashboards that affect all cards. This reuses the same Filter system from resources:
use Martis\Filters\DateRangeFilter;use Martis\Filters\SelectFilter; class SalesDashboard extends Dashboard{ public function filters(Request $request): array { return [ RegionFilter::make('Region')->span(4), DateRangeFilter::make('Period')->column('created_at')->span(8), ]; }}Filters support ->span() for layout control (1-12 column grid, from 768px; below it every filter takes the full row). Default spans: select = 3, date-range = 6. See Filters → Filter Grid Layout.
Filter values are passed to each card's compute endpoint and automatically applied to all built-in query helpers (count, sum, average, etc.).
A filter the user may not see (->canSee(fn (Request $request) => ...) returning false) is left out of the dashboard payload and is not applied by the compute endpoint either: a filters parameter that names it is ignored, so a breakdown reserved for managers stays closed to a user who sends its key by hand, as on a resource index. A calculate() that reads ?filters= from the request on its own (a custom metric that skips the built-in query helpers) gets the raw parameter: it must check authorizedToSee($request) on the filter itself before using a value.
The compute endpoint of a locked dashboard (lockedFor(), see Soft-gates) answers { locked: true, lock: {...} } and computes nothing, as the dashboard route does.
Filters in the URL (v2.9.0)
The active filters live in the dashboard's address, as the JSON object {"<filter uriKey>": <value>} in the filters query parameter. It is the parameter and the shape a resource index deep link uses (MenuItem::filter()) and the one each card's compute endpoint receives. So a filtered dashboard survives a reload, can be bookmarked or shared, and a link can open it with filters set:
// /martis/dashboards/sales?filters=%7B%22region%22%3A%22eu%22%7D$url = url(config('martis.path').'/dashboards/sales').'?'.http_build_query([ 'filters' => json_encode(['region' => 'eu']),]);- A change in the filter panel rewrites the address in place: a date range or a multi-select takes several picks, and Back should not step through each one. A card's
setFilters()adds a history entry by default, so Back returns to the filters before it. - A filter's
default()is written to the address when the dashboard opens without afiltersparameter. A filter the address sets wins over the defaults, as on a resource index. - Clearing the last filter removes the parameter. On a dashboard with a filter default it leaves
?filters={}instead, so a reload or a shared link keeps the defaults cleared. - Setting the filters the dashboard already has (a second click on the same row) changes nothing and adds no history entry. Other query parameters and the
#fragmentof the address are kept. - A key that names none of the dashboard's filters, a value the filter's control could not show, and a parameter that is not a JSON object are ignored. Which values a filter accepts stays the server's call: the compute endpoint resolves each value, as for any request.
- Switching dashboards (a tab, the sidebar) starts from no filters.
The value of each built-in filter:
| Filter | Value | Example |
|---|---|---|
SelectFilter | the option's value | "eu", 7 |
MultiSelectFilter | a list of option values | ["eu", "us"] |
BooleanFilter | the checked options, each true | {"active": true, "archived": true} |
DateFilter | YYYY-MM-DD | "2026-10-09" |
DateRangeFilter | from and/or to, each YYYY-MM-DD | {"from": "2026-10-01", "to": "2026-10-09"} |
a filter with componentKey() | whatever its control writes |
Layout Type
Dashboard::layoutType(): string returns the identifier the frontend uses to pick a layout renderer for the cards collection. Defaults to the standard grid; override only when shipping a custom dashboard component:
public function layoutType(): string{ return 'kanban'; // matches a key the frontend knows how to render}Custom Component
Dashboard::componentKey(string) swaps the entire dashboard view for a custom React component. The component must be registered in componentRegistry and able to consume the standard dashboard payload (cards, filters, meta):
SalesDashboard::make('Sales')->componentKey('custom-sales-dashboard');Use component(): ?string to read the configured key back.
Custom cards (filter-reactive)
A single card can render a bespoke React component instead of a native metric shape. There are two patterns, depending on where the data comes from:
1. A component-keyed metric — server-computed, filter-scoped (recommended). A Metric that declares a component (fluent ->componentKey('…'), or by overriding component()) renders its calculate($request) result through your component. The metric's calculate() already receives the dashboard's active filters (?filters=…), so the card is filter-reactive with zero client-side filter code. Your component receives { metric, result }:
// PHP: a metric whose calculate() reads the active filters and returns rows.class TopMissedQueries extends Metric{ public function component(): ?string { return 'card:top-missed-queries'; } public function calculate(Request $request) { /* filter-scoped rows */ }}// resources/js/martis-extensions/cards/TopMissedQueries.tsxexport default function TopMissedQueries({ metric, result }: { metric: MetricDefinition; result: Record<string, unknown> }) { return <MyTable rows={result.rows as Row[]} /> // already filter-scoped}2. A plain Card — client-fetched. A Card (which has no compute endpoint) receives the dashboard's active filters as a prop so it can fetch its own filter-scoped data:
import type { ActiveFilters, SetDashboardFilters } from '@martis/runtime' export default function MyCard({ card, filters, setFilters }: { card: MetricDefinition; filters: ActiveFilters; setFilters: SetDashboardFilters }) { // re-fetch whenever `filters` changes}Since v1.30.0: a component-keyed metric's computed result is fed into the component (previously the custom component received only the static card descriptor and never the result), and plain custom cards receive
filters.
Setting the filters from a card (v2.9.0)
A plain Card also receives setFilters, so a card can drive the dashboard: a row click selects a filter value, and the filter panel, every card and the address follow, with one request per card. setFilters(patch) merges the patch into the active filters (null, '' or [] clears a filter); setFilters(current => next) replaces the whole set. Each call adds a history entry, so Back undoes it; setFilters(patch, { replace: true }) rewrites the current entry instead.
export default function ProjectsOverview({ filters, setFilters }: { filters: ActiveFilters; setFilters: SetDashboardFilters }) { // ... return rows.map((row) => ( <tr key={row.id} onClick={() => setFilters({ project: row.id })} aria-selected={filters.project === row.id}> ... </tr> ))}A component nested inside a card, or the component of a component-keyed metric (which receives { metric, result }), gets the same pair from useDashboardFilters(). It returns null outside a dashboard:
import { useDashboardFilters } from '@martis/runtime' function ProjectRow({ row }: { row: { id: number; name: string } }) { const dashboard = useDashboardFilters() return <button type="button" onClick={() => dashboard?.setFilters({ project: row.id })}>{row.name}</button>}The hook and the ActiveFilters, SetDashboardFilters, DashboardFiltersUpdate, DashboardFiltersOptions and DashboardFiltersContextValue types come from @martis/runtime since v2.9.0. An extension set up before it republishes the shims to import them (php artisan vendor:publish --tag=martis-extension-shims --force). A card scaffolded by martis:card types filters and setFilters already.
Metadata
Pass arbitrary metadata to the frontend with withMeta(). The values surface verbatim in the dashboard payload's meta block, so custom layout types or dashboard components can read them via meta.*:
SalesDashboard::make('Sales')->withMeta([ 'briefing' => 'Live every weekday at 09:00 WET.', 'owner' => 'sales-leadership@example.com',]);Customising the breadcrumb (v1.10.3+)
By default the breadcrumb on /martis/dashboards/{uriKey} reads Dashboard::name() — the page heading and the deepest crumb stay in lock-step. Set a dedicated label when you want them to diverge (compact crumb + verbose heading, branded crumb + neutral heading, etc.):
SalesDashboard::make('Quarterly sales briefing') ->withBreadcrumb('Sales');The breadcrumb is the only thing that changes — name() still feeds the page heading, the sidebar entry, the document.title, and the menu shortcut from MenuItem::dashboard().
For a translation-friendly variant, override breadcrumb() so the label re-resolves on every request and honours locale switches:
public function breadcrumb(): ?string{ return (string) __('app.dashboards.sales.breadcrumb');}Return null to fall back to name().
First paint (v2.19.0)
The panel shell embeds the list of dashboards the signed-in user may see in its boot payload (window.MartisConfig.dashboards, the same list GET /api/dashboards answers, with each dashboard's live lock state). It is left out (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 opened by a full page load (a bookmark, a reload, a link from an email) therefore shows its own name in the breadcrumb, the tab title, the sidebar's DASHBOARDS group and the tab strip on the first render, without waiting for a request. The server-rendered <title> names it too (Team · Acme).
When the list is not known yet (the shell could not resolve it, so the SPA requests it), nothing names the wrong dashboard in the meantime:
- the deepest crumb is an empty, fixed-width placeholder, never "Dashboard";
- the tab title stays as it was (the server's title, or the previous page's);
- the sidebar's
DASHBOARDSgroup shows a placeholder row the height of an entry; - the cards are skeletons in the shape the dashboard's cards had on its last load in this browser (their grid spans only, kept in
localStorageundermartis-dashboard-layoutsand cleared when the user signs out; a session that expires leaves them, since they hold no names or values), or three generic cards the first time.
The "Dashboard" sidebar entry and crumb of an install with no dashboards appear once the list is known to be empty. A cards dashboard and its tab strip no longer wait for GET /api/navigation; only the built-in summary view (layout: 'default', or an install without dashboards) does.
Before v2.19.0 the SPA requested the list after it booted, and until it answered the breadcrumb and the sidebar read "Dashboard", the tab title fell back to the generic default, and the cards were three skeletons whatever the dashboard held.
Sidebar icon (v1.11.4+)
The auto-built DASHBOARDS section renders a <SquaresFourIcon> next to every entry by default. Use withIcon(string) to swap in any Phosphor icon registered in the iconRegistry (chart-line-up, rocket-launch, gear-six, …):
class HomeDashboard extends Dashboard{ public function __construct() { parent::__construct(name: 'Home', uriKey: 'home'); $this->withIcon('chart-line-up'); }}Pass null to clear the override and fall back to the default glyph. Custom Martis::mainMenu(...) resolvers still override the icon per item via MenuItem::icon(...) — withIcon() only feeds the auto-build path.
Consumers can register additional icons via iconRegistry.register('my-icon', MyIconComponent) at boot before the icon name resolves.
Artisan Command
php artisan martis:dashboard SalesDashboardCache Configuration
Metrics versus custom cards (v2.20.0). Only a
Metricis computed by its own request (GET /api/dashboards/{d}/cards/{card}), so only a metric has a cache lifetime of its own:$cacheSeconds/cacheForSeconds(),live(), andmartis.cache.metrics.default_ttl_seconds(see Metrics: caching). A customCardis serialised with the dashboard shape, cached per user under thedashboardslayer, and itsmetais whatever it held at that moment. For a card with a cached or live value, build it as a metric.
Martis extension — global defaults in addition to per-metric caching.
Cache defaults live under the cache block of config/martis.php. Each subsystem (metrics, dashboards, navigation, schema) has its own {enabled, ttl} pair, plus a master kill-switch (cache.enabled):
'cache' => [ 'enabled' => env('MARTIS_CACHE_ENABLED', true), 'metrics' => [ 'enabled' => env('MARTIS_CACHE_METRICS_ENABLED', true), 'ttl' => env('MARTIS_CACHE_METRICS_TTL', env('MARTIS_CACHE_METRICS', 5)), ], 'dashboards' => [ 'enabled' => env('MARTIS_CACHE_DASHBOARDS_ENABLED', true), 'ttl' => env('MARTIS_CACHE_DASHBOARDS_TTL', env('MARTIS_CACHE_DASHBOARDS', 5)), ], // navigation / schema follow the same shape],ttl is in minutes; null means "until cleared" (the dashboards layer defaults to 5 minutes, and the list entry is additionally keyed by a fingerprint of the dashboards the user is authorized to see, so registering, removing or re-gating a dashboard is visible on the next request; see cache.md). Individual metrics override the global value via cacheFor(). Runtime overrides set with php artisan martis:cache:disable {type} survive restarts and take precedence over both layers. See metrics.md — Cache Configuration for the full block.
Default Dashboard
Martis\Dashboards\DefaultDashboard is the built-in landing dashboard. Register it alongside your custom dashboards to expose the default surface as a selectable tab:
Martis::dashboards([ \Martis\Dashboards\DefaultDashboard::class, MyCustomDashboard::class,]);The default dashboard renders, top to bottom:
- Welcome hero card — animated gradient surface with the configured heading, description, and the package version resolved dynamically from Composer's installed tag. Override the copy by publishing
lang/vendor/martis/*/resources.phpand editingwelcome_card_heading/welcome_card_description. Hide the card viaconfig('martis.dashboard.showWelcomeCard', false)or theMARTIS_DASHBOARD_SHOW_WELCOME_CARD=falseenv flag. - Resource count stats (total, groups, active).
- Resource shortcut cards derived from the registered navigation.
When no dashboards are registered at all, the same default view renders as a fallback so existing installations stay functional.
Density-aware surfaces
StatCard, MetricCard, the resource shortcut cards, and the welcome hero all tighten their internal padding under html[data-density="dense"]. The mapping is CSS-only — the hook is the class prefix each surface already carries (martis-stat-card, martis-metric-card-head / -body, martis-resource-card, mwc-root) — so consumer-built metric cards that follow the same class convention pick up the density response automatically.
API
List dashboards
GET /api/dashboardsThe panel shell embeds the same list in its boot payload (window.MartisConfig.dashboards, v2.19.0); see First paint.
Get dashboard with cards and filters
GET /api/dashboards/{dashboard}Compute a metric
GET /api/dashboards/{dashboard}/cards/{card}?range=30&filters={...}A dashboard soft-locked for the user (lockedFor(), requirePlan()) answers 403 with its lock payload here, as does a locked card; its page endpoint answers 200 { locked: true, lock } and withholds the cards (v2.4.0, see Soft-gates → What a lock stops on the server). A filter the user may not see, or is locked from, is not applied to the card.