Actions and Metrics

Metrics

Compute counts, totals, breakdowns, trends and top lists over the rows of a resource, with ranges, time zones and a result cache.

A metric computes one result over the rows of a search: a count, a total, a breakdown, a series over time. It runs on the perimeter of the resource and on the search that the client sends, so a dashboard reads the same rows as the list next to it.

Defining a metric

Generate a metric with rest:metric. The class is written in app/Rest/Metrics:

php artisan rest:metric TotalRevenue --type=value
php artisan rest:metric OrdersByStatus --type=partition --group-by=status

Five built-in types compute the usual results in one query: Value, Partition, Progress, Trend and Table. Extend one and call its helpers in calculate():

use Illuminate\Database\Eloquent\Builder;
use Lomkit\Rest\Metrics\Value;
use Lomkit\Rest\Metrics\ValueResult;

class TotalRevenue extends Value
{
    public function calculate(Builder $query): ValueResult
    {
        return $this->sum($query, 'price', 'placed_at');
    }

    public function ranges(): array
    {
        return [30 => '30 days', 90 => '90 days'];
    }
}

Declare it in the metrics() method of the resource:

public function metrics(): array
{
    return [TotalRevenue::make()];
}
  • calculate() receives an Eloquent query on the model: the perimeter of the resource, then the search of the request, without order, selected columns or limit.
  • The helpers read the fields that the resource declares. A sum, an average, a min, a max, a median or a percentile needs a Number or Integer field, a date column a Date or DateTime field, and a group column a Text, Enum, Integer or Boolean field. Another field throws Lomkit\Rest\Exceptions\InvalidMetricException when the metric runs.
  • The metric is named after its class, without a Metric suffix: TotalRevenue gives Total Revenue and the key total-revenue.

Requesting a metric

Each metric gets the route POST /{resource}/metrics/{uriKey}, named {name}.metric.{uriKey}:

POST /api/orders/metrics/total-revenue
{}
{"data": {"type": "value", "value": 52.0, "previous": 37.25}}

The body takes three optional keys:

KeyValue
searchtext, trashed, filters, scopes and instructions of a search. sorts, selects, includes, aggregates, gates, limit and page are a 422, such as The search.limit field is prohibited.
rangea key of ranges(). The first range applies when the request sends none.
timezonea time zone name, such as Europe/Paris. config('app.timezone') applies when the request sends none.
{"range": 90, "search": {"filters": [{"field": "status", "value": "paid"}]}}
  • A range that the metric does not declare is a 422, and so is range on a metric without ranges.
  • An unknown zone, or an offset such as +01:00, is a 422.
  • An unknown metric answers 404 to a user allowed viewAny on the resource, and 403 to the others.
  • The search runs the resource's searching() event first. A metric that must skip it declares ->withoutSearching(), or public bool $withoutSearching = true;.
  • Floats keep their fraction in the response: 52.0, not 52.

Value

A Value gives one number, and the same number over the previous period when the metric declares ranges. A range key counts days.

HelperComputes
count($query, $dateColumn = 'created_at')the number of rows
sum($query, $column, $dateColumn = 'created_at')the total of a column
average(), min(), max(), median()with the parameters of sum()
percentile($query, $column, $percentile, $dateColumn = 'created_at')the value under which a share of the values falls

With ranges, value covers the last N days and previous the N days before. Without ranges, value covers every row, previous is null and the date column is not read.

  • A count is an integer. A sum, min or max is an integer for an Integer field and a float for a Number field. An average, a median or a percentile is a float.
  • Over no row, a count and a sum give 0, the others null.

Partition

A Partition gives one part per value of a column:

use Lomkit\Rest\Metrics\Partition;
use Lomkit\Rest\Metrics\PartitionResult;

class OrdersByStatus extends Partition
{
    public function calculate(Builder $query): PartitionResult
    {
        return $this->count($query, 'status');
    }
}
{"data": {"type": "partition", "parts": [{"key": "paid", "label": "Paid", "value": 5}, {"key": "pending", "label": "Pending", "value": 1}, {"key": null, "label": null, "value": 1}], "other": null}}
  • Helpers: count($query, $groupBy, $dateColumn = null), sum($query, $column, $groupBy, $dateColumn = null), average(), min(), max() and median() with the parameters of sum(), and percentile($query, $column, $percentile, $groupBy, $dateColumn = null).
  • Each part holds the stored value as key and a label: the enum case name for an Enum field, the key as text otherwise. The rows without a value form the part whose key is null.
  • Parts are sorted by value, largest first, then by key.
  • ->limit(5) when the resource declares the metric, or public ?int $limit = 5;, keeps the 5 largest parts and folds the others into other: {"label": "Other", "value": 3}. other is null when nothing is folded.
  • Without limit(), a partition keeps its rest.limits.sizes.groups largest parts, 100 by default and at least 1. limit() raises or lowers that number for one metric. See Limits.
  • ->labels(['paid' => 'Payée'], other: 'Autre') on the result renames parts and other.

Comparing with the previous period

With a named dateColumn and ranges(), a partition reads the last N days and gives each part and other a previous value over the N days before:

return $this->sum($query, 'amount', 'currency', dateColumn: 'paid_at');
{"data": {"type": "partition", "parts": [{"key": "EUR", "label": "EUR", "value": 18240.5, "previous": 16002.0}, {"key": "USD", "label": "USD", "value": 9100.0, "previous": 9875.25}], "other": null}}

A key without rows in one of the two periods gets null there.

Progress

A Progress gives a value and its target:

return $this->sum($query, 'price', target: 100000);
{"data": {"type": "progress", "value": 52000.0, "target": 100000.0}}

The helpers are count($query, $target, $dateColumn = 'created_at'), sum($query, $column, $target, $dateColumn = 'created_at'), average(), min() and max(). The target can come from the request: target: $this->request()->user()->monthly_goal.

Trend

A Trend gives one value per day, week or month. A trend declares ranges(), and a range key counts periods, the current one included:

use Lomkit\Rest\Metrics\Trend;
use Lomkit\Rest\Metrics\TrendResult;

class RevenuePerMonth extends Trend
{
    public function calculate(Builder $query): TrendResult
    {
        return $this->sumByMonths($query, 'price', 'placed_at');
    }

    public function ranges(): array
    {
        return [3 => '3 months', 6 => '6 months', 12 => '12 months'];
    }
}
{"range": 6}
{
  "data": {
    "type": "trend",
    "series": [
      {"label": "2026-06", "value": 0.0},
      {"label": "2026-07", "value": 0.0},
      {"label": "2026-08", "value": 0.0},
      {"label": "2026-09", "value": 0.0},
      {"label": "2026-10", "value": 20.0},
      {"label": "2026-11", "value": 43.5}
    ]
  }
}
  • Helpers: countByDays($query, $dateColumn = 'created_at', $splitBy = null), countByWeeks(), countByMonths(), then sumBy..., averageBy..., minBy..., maxBy... and medianBy... with ($query, $column, $dateColumn = 'created_at', $splitBy = null), and percentileBy... with ($query, $column, $percentile, $dateColumn = 'created_at', $splitBy = null).
  • Labels: a day 2026-11-07, a week the date of its first day, a month 2026-11. Every period of the range is in the series, oldest first. A period without rows is 0 for a count or a sum, null for the others.
  • Weeks start on Monday. ->weekStartsOn(Carbon::SUNDAY), or public int $weekStartsOn = Carbon::SUNDAY;, starts them on another day.
  • timezone counts the periods in that zone, changes of offset included.

Splitting a trend

splitBy adds one series per value of a column, here over the first range, 3 months:

return $this->countByMonths($query, 'placed_at', splitBy: 'status');
{
  "data": {
    "type": "trend",
    "series": [{"label": "2026-09", "value": 0}, {"label": "2026-10", "value": 1}, {"label": "2026-11", "value": 4}],
    "splits": [
      {"key": "paid", "label": "Paid", "series": [{"label": "2026-09", "value": 0}, {"label": "2026-10", "value": 0}, {"label": "2026-11", "value": 2}]},
      {"key": "pending", "label": "Pending", "series": [{"label": "2026-09", "value": 0}, {"label": "2026-10", "value": 1}, {"label": "2026-11", "value": 1}]},
      {"key": null, "label": null, "series": [{"label": "2026-09", "value": 0}, {"label": "2026-10", "value": 0}, {"label": "2026-11", "value": 1}]}
    ],
    "other": null
  }
}
  • Each split holds key, label and its series, labelled as the parts of a partition. The splits come in the order of their key, the null key last.
  • ->limit(5) keeps the 5 splits with the largest total over the range and folds the others into other: an object with label, Other, and a series that holds their values per period. other is null when nothing is folded.
  • Without limit(), a trend keeps its rest.limits.sizes.groups largest splits, 100 by default, as a partition keeps its parts.

Table

A Table gives the top rows of a grouped aggregate, one column per count, sum, average, min, max, median or percentile:

use Lomkit\Rest\Metrics\Table;
use Lomkit\Rest\Metrics\TableColumn;
use Lomkit\Rest\Metrics\TableResult;

class TopCustomers extends Table
{
    public int $limit = 5;

    public function calculate(Builder $query): TableResult
    {
        return $this->rows($query, 'customer', [
            'orders' => TableColumn::count(),
            'revenue' => TableColumn::sum('price'),
            'basket' => TableColumn::average('price')->label('Average basket'),
        ], orderBy: 'revenue', dateColumn: 'placed_at');
    }

    public function ranges(): array
    {
        return [30 => '30 days', 365 => '365 days'];
    }
}
{"data": {"type": "table", "columns": [{"name": "orders", "label": "Orders", "aggregate": "count"}, {"name": "revenue", "label": "Revenue", "aggregate": "sum"}, {"name": "basket", "label": "Average basket", "aggregate": "average"}], "rows": [{"key": "acme", "label": "acme", "values": {"orders": 2, "revenue": 150.0, "basket": 75.0}}]}}
  • rows($query, $groupBy, $columns, $orderBy = null, $direction = 'desc', $dateColumn = 'created_at'). A column name starts with a lowercase letter and holds letters, digits and underscores.
  • TableColumn::count(), sum($field), average($field), min($field), max($field), median($field) and percentile($field, $percentile). ->label() names a column, else its name in words.
  • The rows are ordered by $orderBy, the first column by default, largest first. 'asc' puts the smallest first.
  • $limit keeps from 1 to 100 rows, 10 by default. ->limit(5) sets it when the resource declares the metric.
  • ->labels([...]) on the result renames rows. For labels stored elsewhere, read them for $table->keys().

Median and percentile

median() and percentile() work on Value, Partition, Trend (medianByMonths(), percentileByMonths()) and Table (TableColumn::median(), TableColumn::percentile()):

return $this->percentile($query, 'resolution_minutes', 0.9, 'closed_at');
  • The percentile is a number from 0 to 1. Another value throws InvalidMetricException when the metric runs.
  • The result is interpolated between the two nearest rows, as PostgreSQL percentile_cont() does. Rows without a value are left out.
  • The database sorts the rows of each group: index the column on a large table.
  • SQLite, MySQL, MariaDB and PostgreSQL are supported.

Your own metric

A metric of your own extends Lomkit\Rest\Metrics\Metric, names its type in type() and returns a MetricResult:

use Lomkit\Rest\Metrics\Metric;
use Lomkit\Rest\Metrics\MetricResult;

class TicketBacklog extends Metric
{
    public function type(): string
    {
        return 'gauge';
    }

    public function calculate(Builder $query): MetricResult
    {
        return new Gauge($query->where('status', 'open')->count(), 500);
    }
}

final readonly class Gauge implements MetricResult
{
    public function __construct(private int $value, private int $max) {}

    public function jsonSerialize(): array
    {
        return ['value' => $this->value, 'max' => $this->max];
    }
}
{"data": {"type": "gauge", "value": 42, "max": 500}}
  • type() holds lowercase letters, digits and dashes. A result with its own type key throws InvalidMetricException.
  • $this->range() gives the selected range, $this->timezone() the zone of the request, and $this->periods($query, 'placed_at', PeriodUnit::Day, 7) the periods of a series.
  • schema() describes the result in the OpenAPI document. It returns a Scramble type, ?Type, or null by default: the document then gives type alone.

Caching a result

->cacheFor() keeps the result between requests:

public function metrics(): array
{
    return [
        TotalRevenue::make()->cacheFor(600),
        OrdersByStatus::make()->cacheFor(600)->cacheScope('global'),
    ];
}
  • cacheFor() takes seconds, a DateInterval or an instant such as now()->addMinutes(10). A metric class can declare public ?int $cacheFor = 600;.
  • Each user gets their own result. cacheScope('global'), or public CacheScope $cacheScope = CacheScope::Global;, shares one result between the users whose perimeterQuery() compiles to the same SQL and bindings: the users of one team share it, another team gets its own.
  • The key also holds the resource, the metric and its settings, the range, timezone and search of the request, and the values of the resource's authorizationCacheContext().
  • A kept result still runs validation, the abilities, the middleware and searching(). It skips the queries of the metric.
  • The results go to the store of authorizations.cache.store. Rest::forgetAuthorizationsFor($user) drops the results of a user, Rest::forgetAuthorizations() drops every result, and authorizations.cache.enabled set to false keeps none. See Decision cache.
A global result keys the SQL of perimeterQuery() only. A perimeter that also depends on a global scope of the model, or on the tenant of a database per tenant, adds that value to authorizationCacheContext(), or the tenants share one result.

Abilities and routes

  • A metric checks viewAny on the resource, the abilities of the resource's abilities() for Endpoint::Metric, then the metric's abilities(), ['viewAny'] by default. [] runs without these checks.
  • ->middleware() and ->withoutMiddleware(), or the $middleware and $withoutMiddleware properties, work as for actions.
  • Rest::resource() reads metrics() when it registers the routes: keep the list the same for every user. except('metric') removes the metric routes.
  • The details, with ?with=metrics, list each metric with its type and ranges.