# Dashboards · Martis docs

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

Source: https://getmartis.com/docs/core/dashboards

# 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](https://getmartis.com/docs/core/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](https://getmartis.com/docs/core/metrics#card-width).

## Registering Dashboards

Register dashboards in `app/Providers/MartisServiceProvider.php` (published by `martis:install`):

```
// app/Providers/MartisServiceProvider.php
protected 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()` returns `null`) appear in the sidebar's `DASHBOARDS` group, 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](https://getmartis.com/docs/core/dashboards#filters-in-the-url-v290).

```
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](https://getmartis.com/docs/auth/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](https://getmartis.com/docs/core/gates) 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](https://getmartis.com/docs/core/filters#filter-grid-layout-span).

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](https://getmartis.com/docs/core/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()`](https://getmartis.com/docs/core/menus#dashboard-lens--filter-items)) 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()`](https://getmartis.com/docs/core/dashboards#setting-the-filters-from-a-card-v290) 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 a `filters` parameter. 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 `#fragment` of 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.tsx
export 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](https://getmartis.com/docs/core/dashboards#filters-in-the-url-v290) 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 `DASHBOARDS` group 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 `localStorage` under `martis-dashboard-layouts` and 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 SalesDashboard
```

## Cache Configuration

> **Metrics versus custom cards (v2.20.0).** Only a `Metric` is 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()`, and `martis.cache.metrics.default_ttl_seconds` (see [Metrics: caching](https://getmartis.com/docs/core/metrics#caching)). A custom `Card` is serialised with the dashboard shape, cached per user under the `dashboards` layer, and its `meta` is 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](https://getmartis.com/docs/reference/cache#the-four-built-in-layers)). 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](https://getmartis.com/docs/core/metrics#cache-configuration-martis-extension) 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.php` and editing `welcome_card_heading` / `welcome_card_description`. Hide the card via `config('martis.dashboard.showWelcomeCard', false)` or the `MARTIS_DASHBOARD_SHOW_WELCOME_CARD=false` env 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/dashboards
```

The panel shell embeds the same list in its boot payload (`window.MartisConfig.dashboards`, v2.19.0); see [First paint](https://getmartis.com/docs/core/dashboards#first-paint-v2190).

### 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](https://getmartis.com/docs/core/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.

Metrics

Soft-gates & badges
