Documentation menu
HomeDocsCore ConceptsMetrics

Metrics

Metrics compute and display analytical data on dashboards and resource index pages. Martis provides six built-in metric types: Value, Trend, Partition, Progress, ActivityFeed, and EndpointTable.

Metric Types

Value Metric

Displays a single number with optional period comparison.

use App\Models\User;
use Illuminate\Http\Request;
use Martis\Metrics\ValueMetric;
use Martis\Metrics\ValueResult;
 
class TotalUsers extends ValueMetric
{
public function calculate(Request $request): ValueResult
{
return $this->count($request, User::class)
->suffix(' users');
}
}

Query helpers: count(), sum(), average(), max(), min().

Result formatting:

$this->result(150)->prefix('$')->suffix(' revenue')
$this->count($request, User::class)->currency('€')
$this->sum($request, Order::class, 'total')->dollars()

Trend Metric

Displays time-series data as a line chart.

use App\Models\User;
use Illuminate\Http\Request;
use Martis\Metrics\TrendMetric;
use Martis\Metrics\TrendResult;
 
class UsersPerDay extends TrendMetric
{
public function calculate(Request $request): TrendResult
{
return $this->countByDays($request, User::class)
->showLatestValue();
}
}

Query helpers: countByDays(), countByWeeks(), countByMonths(), sumByDays(), sumByWeeks(), sumByMonths(), averageByDays(), averageByMonths().

Partition Metric

Displays grouped data as a doughnut chart.

use App\Models\User;
use Illuminate\Http\Request;
use Martis\Metrics\PartitionMetric;
use Martis\Metrics\PartitionResult;
 
class UsersByRole extends PartitionMetric
{
public function calculate(Request $request): PartitionResult
{
return $this->count($request, User::class, 'role')
->colors(['#22c55e', '#ef4444', '#3b82f6'])
->label(fn ($l) => strtoupper($l));
}
}

Query helpers: count(), sum(), average().

colors() takes a list (parallel to the groups) or a map keyed by group value. A group with no entry of its own in the map takes the next color of the theme palette, whatever its value is spelled like (a group named constructor is a group like any other, v2.4.0).

Progress Metric

Displays progress toward a target as a progress bar.

use App\Models\User;
use Illuminate\Http\Request;
use Martis\Metrics\ProgressMetric;
use Martis\Metrics\ProgressResult;
 
class MonthlySignups extends ProgressMetric
{
public function calculate(Request $request): ProgressResult
{
return $this->count(
$request,
User::class,
fn ($q) => $q->where('created_at', '>=', now()->startOfMonth()),
target: 100,
);
}
}

Use ->avoid() when the goal is to minimize rather than maximize.

Activity Feed Metric (Martis Extension)

Renders a chronological stream of recent events with a coloured Phosphor avatar tile, an actor / verb / target line, and a mono timestamp. Useful for "Recent activity" or "Latest deployments" cards on dashboards.

use Illuminate\Http\Request;
use Martis\Metrics\ActivityFeedMetric;
use Martis\Metrics\ActivityFeedResult;
 
class RecentDeploys extends ActivityFeedMetric
{
public function calculate(Request $request): ActivityFeedResult
{
return $this->result()
->add(
actor: 'Ana Pereira',
verb: 'deployed',
time: '2m ago',
target: 'api-core@v2.4.1',
icon: 'rocket-launch',
color: 'var(--martis-chart-2)',
)
->add(
actor: 'João Marques',
verb: 'rotated key',
time: '14m ago',
target: 'production-api',
icon: 'key',
color: 'var(--martis-chart-1)',
);
}
}

ActivityFeedResult::add() parameters:

ParameterRequiredDescription
actoryesBold name at the start of the line.
verbyesMuted action description after the actor.
timeyesRelative timestamp shown on a second mono line ("2m ago", "yesterday").
targetnoIdentifier rendered in mono (commit sha, route, key id).
iconnoPhosphor icon name for the leading avatar tile.
colornoCSS colour or token for the tile background (defaults to --martis-accent).

Bulk-set entries via $result->items([...]) if the data already comes back from a query in the right shape.

Generator: php artisan martis:activity-feed RecentDeploys.

Endpoint Table Metric (Martis Extension)

Renders a compact HTTP route table with coloured method chips (GET / POST / PUT / PATCH / DELETE), mono numeric columns (req/min, P95 latency, error %), and a thin share-of-traffic bar. Drops cleanly into a card-span-3 slot on the dashboard.

use Illuminate\Http\Request;
use Martis\Metrics\EndpointTableMetric;
use Martis\Metrics\EndpointTableResult;
 
class TopEndpoints extends EndpointTableMetric
{
public function calculate(Request $request): EndpointTableResult
{
return $this->result()
->errorWarnThreshold(0.2)
->add(method: 'GET', path: '/v1/resources', rpm: 482, latencyMs: 42, errorRate: 0.02, share: 28)
->add(method: 'POST', path: '/v1/events', rpm: 312, latencyMs: 88, errorRate: 0.12, share: 18)
->add(method: 'PATCH', path: '/v1/deployments/:id', rpm: 188, latencyMs: 126, errorRate: 0.18, share: 11);
}
}

EndpointTableResult::add() parameters:

ParameterRequiredDescription
methodyesHTTP verb. Drives the coloured chip in the first column.
pathyesRoute path (rendered in mono).
rpmnoRequests per minute.
latencyMsnoP95 latency in milliseconds.
errorRateno0..1. Highlights the cell amber when above errorWarnThreshold (default 0.2).
shareno0..100. Renders the thin share bar. Omit on every row to hide the column entirely.

Bulk-set via $result->rows([...]). Generator: php artisan martis:endpoint-table TopEndpoints.

Sparkline mode on Trend metrics

TrendResult accepts a sparkline flag. When set, the MetricCard renders an inline SVG sparkline + delta pill instead of the full Chart.js panel. Pair with the .martis-dash-kpis row layout to fit four trend metrics across the top of a dashboard:

class WeeklyRevenue extends TrendMetric
{
public function calculate(Request $request): TrendResult
{
return $this->countByDays($request, Order::class)
->sparkline() // ← compact mode
->showLatestValue()
->prefix('€');
}
}

The <Sparkline> React component used internally is also on @martis/runtime (v1.38.0+) for use inside custom cards or framed components: import { Sparkline } from '@martis/runtime'.

Number formatting

Value, trend and progress metrics format their numbers in the user's Martis locale (v2.1.0), with the metric's prefix and suffix around them: 1234.5 reads 1234,5 for pt_PT and 1,234.5 for en_US. The trend chart's axis and tooltip, and the partition chart's tooltip, use the same locale (Chart.js locale option).

Custom date column on query helpers

Every query helper (count, sum, average, max, min on ValueMetric; countByDays / countByWeeks / countByMonths / sumByDays / sumByWeeks / sumByMonths / averageByDays / averageByMonths on TrendMetric) accepts an optional ?string $dateColumn = null final argument that controls which timestamp column the active range filter applies against. Defaults to created_at.

Database support. TrendMetric buckets its periods (day / ISO week / month) in PHP after fetching the in-range rows, so it is database-agnostic — MySQL, PostgreSQL and SQLite all work identically, including ISO-week grouping. No driver-specific SQL date functions are used.

public function calculate(Request $request): TrendResult
{
return $this->sumByMonths($request, Invoice::class, 'total', 'paid_at');
// ^^^^^^^
// Range filter (?range=12) bounds Invoice.paid_at, not Invoice.created_at.
}

Use it whenever the metric should track a domain timestamp (paid_at, closed_at, delivered_at, archived_at) that differs from row creation.

Ranges

Metrics support time range selection. Override ranges() to customize:

public function ranges(): array
{
return [
30 => '30 Days',
60 => '60 Days',
365 => '1 Year',
'TODAY' => 'Today',
'MTD' => 'Month To Date',
'QTD' => 'Quarter To Date',
'YTD' => 'Year To Date',
];
}

The declared ranges are the only ranges (v2.4.0+). The ?range= query parameter is client input, and a trend opens one bucket per step of its window and reads every row in it, so a request cannot choose the window. The metric accepts a value only when it is one of the keys ranges() returns; any other value (?range=10000000, ?range=forever, a named range the metric does not declare) reads as the default range, which is 30 when declared and the first declared range otherwise. A metric that declares none (a partition or a progress metric, or a value metric whose ranges() returns [] to hide the selector) always reads 30. The same holds for the cache: the result is keyed on the declared range, so requests for made-up values neither compute a window of their own nor add an entry each.

A numeric range is also held to Metric::MAX_RANGE (3650 days, weeks or months, as the metric counts them), so a ranges() that declares a larger window opens 3650 steps at most.

A metric that reads the range itself in calculate() takes it from $this->requestedRange($request) (a string: a declared key) and Metric::rangeWindow($range) (the numeric window), not from $request->query('range'), which the whitelist does not cover. Before v2.4.0 ?range=10000000 on a trend card loaded the whole table, built ten million buckets and cached the result under a key of its own.

Card Width

Cards use a 12-column grid. Default width is 4 (one-third).

TotalUsers::make('Total Users')->width(4) // 1/3
UsersPerDay::make('Trend')->width(8) // 2/3
UsersByRole::make('Roles')->width(6) // 1/2
Overview::make('Overview')->width('full') // full width

Fraction strings are auto-converted: '1/3' → 4, '1/2' → 6, '2/3' → 8, 'full' → 12.

For typed callers (and stricter analyzers), width() also accepts the Martis\Enums\MetricWidthPreset enum:

use Martis\Enums\MetricWidthPreset;
 
TotalUsers::make('Total Users')->width(MetricWidthPreset::OneThird); // = 4
UsersPerDay::make('Trend')->width(MetricWidthPreset::TwoThirds); // = 8
Overview::make('Overview')->width(MetricWidthPreset::Full); // = 12

Cases: OneThird, Half, TwoThirds, Full. The integer / fraction / enum forms are interchangeable.

Responsive widths (widthMd() / widthLg())

Martis extension. The dashboard grid is responsive at the same breakpoints as the form grid (md = 768px, lg = 1024px), and the three width methods form a mobile-first cascade, like Tailwind's col-span-* md:col-span-* lg:col-span-*:

MethodApplies fromFallback
width()md (768px)4
widthMd()md (768px)inherits width()
widthLg()lg (1024px)inherits widthMd(), then width()

Below md every card spans the full row, whatever the declared widths, the same rule the form grid applies to field spans: a single column is always better than a third-width KPI on a 390px screen. So a card that declares only width(6) is half-width on tablets and desktops and full-width on phones, with no change to existing dashboards above md.

UsersPerDay::make('Trend')
->width(12) // tablet (>= 768px): full row
->widthLg(8); // desktop (>= 1024px): two-thirds
 
UsersByRole::make('Roles')
->width(12)
->widthLg(4); // shares the desktop row with the trend above

widthMd() is the explicit form of the md tier; use it when the card should change again at lg (width(6)->widthMd(12)->widthLg(4) reads as tablet 12, desktop 4). A plain custom Card (see Dashboards → Custom cards) accepts the same three methods.

How it is applied (v1.37.2+). The SPA never writes grid-column inline. Each card carries its resolved tiers as custom properties on the grid item (--martis-card-span, --martis-card-span-md, --martis-card-span-lg) and .martis-dashboard-grid in martis.css owns the placement per media query. A theme that wants width() honoured on phones too can override the mobile rule:

@media (max-width: 767px) {
.martis-dashboard-grid > * {
grid-column: span var(--martis-card-span);
}
}

Before v1.37.2 the two responsive values were serialised but never read, and the grid stayed 12 columns at every viewport.

Caching

Metric results are cached by default through the Martis metrics cache layer (5 minutes, see Cache). Override cacheFor() to give one metric its own lifetime instead:

public function cacheFor(): ?\DateTimeInterface
{
return now()->addMinutes(5);
}

Since v2.20.0 a metric can set its lifetime in seconds without leaving the layer, and a single metric can opt out of caching:

class TotalRevenue extends ValueMetric
{
protected ?int $cacheSeconds = 60; // or ->cacheForSeconds(60) where it is built
}
 
class CashBalance extends ValueMetric
{
protected bool $live = true; // or ->live(): never cached, computed on every request
}

martis.cache.metrics.default_ttl_seconds (env MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS) sets the seconds for every metric that sets none; null (the default) keeps the layer's ttl in minutes. Precedence: live(), then $cacheSeconds, then the default. These entries live in the metrics layer, so the kill-switch, ?nocache, martis:cache:clear metrics and the app cache scope apply to them, and they stay per user unless the metric sets $cachePerUser = false (the way to share one entry across users; the key is per user today and the default does not change). cacheFor() reports the moment $cacheSeconds expires. A value of default_ttl_seconds that is set but is not a whole number of seconds, 1 or more, throws and names the key. See Cache for a guide to which cards to leave live.

Custom Card classes have no compute path of their own (they travel with the dashboard shape, cached per user under the dashboards layer), so these options exist on metrics only; see Dashboards.

A metric that overrides cacheFor() is cached with Laravel's Cache::remember() directly, outside the Martis layer, so the metrics kill-switch, the ?nocache bypass and martis:cache:clear do not apply to it. Both paths key the result on the user the Martis guard signed in (by model and identifier; guests share one entry), the locale, the range and the filters, plus the scope an app registered with Martis::cacheScopeUsing() (v2.10.0), so a calculate() scoped to the user, their tenant or their permissions is never served to another user (v2.0.0+). A user whose identifier is not an int, a string or Stringable gets no cached entry, and protected bool $cachePerUser = false; shares one entry for a metric whose value is the same for everyone.

Authorization

Use canSee() to control metric visibility:

TotalUsers::make('Total Users')
->canSee(fn ($request) => $request->user()->isAdmin())

Restricting a card to the resource detail page

onlyOnDetail() removes the metric from dashboards and shows it only on the resource's detail surface. Useful for per-record health cards (e.g. "Last 30 days for THIS customer") that have no meaning on a global dashboard.

ChurnRiskScore::make('Churn risk')
->onlyOnDetail();

Inheriting the resource's active filters

Resource index filters do not flow into metric queries by default. Call withFilterScope() to wire the parent resource's filter state into the metric's query builder:

PaidRevenue::make('Paid revenue')
->withFilterScope(function ($query, $request) {
if ($plan = $request->input('filters.plan')) {
$query->where('plan', $plan);
}
return $query;
});

The closure receives ($query, $request) and must return the (possibly modified) $query. Pair this with the resource's filters() so the metric reacts to the same filter pills the user sees on the index.

Custom card colour

color(string) accepts any CSS colour and overrides the accent the card uses for the icon, value, and accent border:

ActiveAlerts::make('Active alerts')->color('#dc2626');
ServerLoad::make('Load avg')->color('var(--martis-chart-3)');

Use it when a card needs to stand out from the global accent without committing to one of the CardStyle semantic variants.

Card Icons (Martis Extension)

Add a Phosphor icon to the card header:

TotalUsers::make('Total Users')->icon('users')
Revenue::make('Revenue')->icon('currency-dollar')
TasksTrend::make('Tasks')->icon('chart-line-up')

The icon renders next to the card title and inherits the card style color.

Card Styles (Martis Extension)

Apply a colored accent (left border) to highlight card importance:

use Martis\Enums\CardStyle;
 
TotalUsers::make('Total Users')->style(CardStyle::Info) // blue
Revenue::make('Revenue')->style(CardStyle::Success) // green
OverdueInvoices::make('Overdue')->style(CardStyle::Danger) // red
PendingTasks::make('Pending')->style(CardStyle::Warning) // yellow

Available styles: Default, Success, Warning, Danger, Info.

Card Height (Martis Extension)

Set a minimum height to align cards in a row:

UsersByRole::make('By Role')->height(300) // 300px minimum height

Cache Configuration (Martis Extension)

Per-metric caching

Override cacheFor() on any metric class:

public function cacheFor(): ?\DateTimeInterface
{
return now()->addMinutes(5);
}

Global cache defaults

Defaults live in config/martis.php under the cache block. Each subsystem (metrics, navigation, dashboards, schema) has its own {enabled, ttl} pair so an operator can flip a single layer off without touching the others. Individual metrics still win via cacheFor(), which bypasses the layer (and its kill-switch) entirely. Every metric cache entry is per user, unless the metric sets $cachePerUser = false, and carries the scope of Martis::cacheScopeUsing() when the app registered one (v2.10.0).

'cache' => [
'enabled' => env('MARTIS_CACHE_ENABLED', true), // master kill-switch
 
'metrics' => [
'enabled' => env('MARTIS_CACHE_METRICS_ENABLED', true),
'ttl' => env('MARTIS_CACHE_METRICS_TTL', env('MARTIS_CACHE_METRICS', 5)),
],
'navigation' => [
'enabled' => env('MARTIS_CACHE_NAVIGATION_ENABLED', true),
'ttl' => env('MARTIS_CACHE_NAVIGATION_TTL', env('MARTIS_CACHE_NAVIGATION', 1)),
],
'dashboards' => [
'enabled' => env('MARTIS_CACHE_DASHBOARDS_ENABLED', true),
'ttl' => env('MARTIS_CACHE_DASHBOARDS_TTL', env('MARTIS_CACHE_DASHBOARDS', null)),
],
'schema' => [
'enabled' => env('MARTIS_CACHE_SCHEMA_ENABLED', true),
'ttl' => env('MARTIS_CACHE_SCHEMA_TTL', env('MARTIS_CACHE_SCHEMA', 1440)),
],
 
'admin_ui' => env('MARTIS_CACHE_ADMIN_UI', true), // exposes /api/cache/* + the admin page
],

ttl is in minutes; null disables caching for that subsystem. The single-name env vars (MARTIS_CACHE_METRICS, MARTIS_CACHE_NAVIGATION, …) are still read as a fallback so older .env files keep working. Runtime overrides set via php artisan martis:cache:disable {type} survive restarts and take precedence over both layers.

Auto-Refresh (Martis Extension)

Martis supports automatic polling.

ActiveUsers::make('Active Now')->refreshEvery(30) // refresh every 30 seconds

Cards with polling show a "LIVE" indicator.

Tooltip — help(?string)

Attach an explanatory tooltip next to the metric title. The bundled MetricCard renders a ? icon next to the title; hovering reveals the text. Pass null (or never call it) to hide the icon entirely.

ActiveUsers::make('Active Now')
->help('Distinct users with at least one HTTP request in the last 5 minutes. Excludes bot traffic.');

HTML is allowed in the tooltip body — line breaks (<br>), bold (<strong>), inline lists. The same channel field tooltips use (sanitised before it is shown; see Tooltip Standard). Useful for multi-paragraph explanations on dense metrics:

RegimeScore::make('Composite')
->help('A 0–10 reading of the current regime.<br><br><strong>5</strong> = neutral.<br><strong>>7</strong> = strong regime.<br><strong>&lt;3</strong> = regime is breaking.<br><br>Not a buy/sell signal.');

Use it for anything the value cannot explain on its own:

  • the unit of measurement (bytes per second, cents)
  • which rows are included or excluded (soft-deleted ones are counted)
  • caveats on the time range (uses the merchant's local timezone, not UTC)
  • regulatory caveats (not a buy/sell signal)

History: shipped server-side from v0.x but the React MetricCard never consumed the property until v1.8.18 — help() was a no-op visually before that release. Custom metric components that read meta.help directly were unaffected.

The text is serialized as help (top-level, not under meta) in the metric payload, so custom metric components can also read it via metric.help and render it however they prefer.

Artisan Commands

php artisan martis:value TotalUsers
php artisan martis:trend UsersPerDay
php artisan martis:partition UsersByRole
php artisan martis:progress MonthlyGoal

API

Compute a dashboard metric

GET /api/dashboards/{dashboard}/cards/{card}?range=30

Compute a resource metric

GET /api/resources/{resource}/cards/{card}?range=30

Both answer 403 with the lock payload when the dashboard, the resource or the card is soft-locked for the user (lockedFor(), requirePlan(), v2.4.0; see Soft-gates → What a lock stops on the server). A metric class has no lock of its own: lock the dashboard, the resource or the Card that holds it.


MetricResult — base class

Every metric type returns a result object that subclasses Martis\Metrics\MetricResult. The base class has a single contract method:

abstract public function toArray(): array;

Concrete subclasses (ValueResult, TrendResult, PartitionResult, ProgressResult, ActivityFeedResult, EndpointTableResult) implement this to serialise their specific shape into the API payload the React component consumes. Most consumers never touch the base class directly — you build results via the helpers on the metric class ($this->result($value), $this->trend($values), etc.) and let the metric handle serialisation.

Subclass MetricResult directly only if you're shipping a brand-new metric kind. Document the result shape in your own metric's docblock so consumers can read the payload without diving into source.