Metrics
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 thesearchof 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
NumberorIntegerfield, a date column aDateorDateTimefield, and a group column aText,Enum,IntegerorBooleanfield. Another field throwsLomkit\Rest\Exceptions\InvalidMetricExceptionwhen the metric runs. - The metric is named after its class, without a
Metricsuffix:TotalRevenuegivesTotal Revenueand the keytotal-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:
| Key | Value |
|---|---|
search | text, 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. |
range | a key of ranges(). The first range applies when the request sends none. |
timezone | a 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
rangethat the metric does not declare is a422, and so israngeon a metric without ranges. - An unknown zone, or an offset such as
+01:00, is a422. - An unknown metric answers
404to a user allowedviewAnyon the resource, and403to the others. - The search runs the resource's
searching()event first. A metric that must skip it declares->withoutSearching(), orpublic bool $withoutSearching = true;. - Floats keep their fraction in the response:
52.0, not52.
Value
A Value gives one number, and the same number over the previous period when the metric declares ranges. A range key counts days.
| Helper | Computes |
|---|---|
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
Integerfield and a float for aNumberfield. An average, a median or a percentile is a float. - Over no row, a count and a sum give
0, the othersnull.
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()andmedian()with the parameters ofsum(), andpercentile($query, $column, $percentile, $groupBy, $dateColumn = null). - Each part holds the stored value as
keyand alabel: the enum case name for anEnumfield, the key as text otherwise. The rows without a value form the part whose key isnull. - Parts are sorted by value, largest first, then by key.
->limit(5)when the resource declares the metric, orpublic ?int $limit = 5;, keeps the 5 largest parts and folds the others intoother:{"label": "Other", "value": 3}.otherisnullwhen nothing is folded.- Without
limit(), a partition keeps itsrest.limits.sizes.groupslargest parts,100by default and at least1.limit()raises or lowers that number for one metric. See Limits. ->labels(['paid' => 'Payée'], other: 'Autre')on the result renames parts andother.
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(), thensumBy...,averageBy...,minBy...,maxBy...andmedianBy...with($query, $column, $dateColumn = 'created_at', $splitBy = null), andpercentileBy...with($query, $column, $percentile, $dateColumn = 'created_at', $splitBy = null). - Labels: a day
2026-11-07, a week the date of its first day, a month2026-11. Every period of the range is in the series, oldest first. A period without rows is0for a count or a sum,nullfor the others. - Weeks start on Monday.
->weekStartsOn(Carbon::SUNDAY), orpublic int $weekStartsOn = Carbon::SUNDAY;, starts them on another day. timezonecounts 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,labeland itsseries, labelled as the parts of a partition. The splits come in the order of their key, thenullkey last. ->limit(5)keeps the 5 splits with the largest total over the range and folds the others intoother: an object withlabel,Other, and aseriesthat holds their values per period.otherisnullwhen nothing is folded.- Without
limit(), a trend keeps itsrest.limits.sizes.groupslargest splits,100by 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)andpercentile($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. $limitkeeps 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
InvalidMetricExceptionwhen 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 owntypekey throwsInvalidMetricException.$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, ornullby default: the document then givestypealone.
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, aDateIntervalor an instant such asnow()->addMinutes(10). A metric class can declarepublic ?int $cacheFor = 600;.- Each user gets their own result.
cacheScope('global'), orpublic CacheScope $cacheScope = CacheScope::Global;, shares one result between the users whoseperimeterQuery()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,timezoneandsearchof the request, and the values of the resource'sauthorizationCacheContext(). - 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, andauthorizations.cache.enabledset tofalsekeeps none. See Decision cache.
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
viewAnyon the resource, the abilities of the resource'sabilities()forEndpoint::Metric, then the metric'sabilities(),['viewAny']by default.[]runs without these checks. ->middleware()and->withoutMiddleware(), or the$middlewareand$withoutMiddlewareproperties, work as for actions.Rest::resource()readsmetrics()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 itstypeandranges.