eliseekn/laravel-metrics
Generate metrics and trends from your Laravel models for dashboards. Group counts and sums by day, month, or year, set custom date/label columns and tables, use date ranges, and query via Eloquent or the query builder. Supports MySQL, PostgreSQL, and SQLite.
1. Install and check compatible branch. The package targets PHP ^8.2 and Laravel ^11/^12 on the default branch; Laravel 13 lives on the 4.x branch. If your app is on Laravel 13, require the branch explicitly rather than relying on the default constraint.
composer require eliseekn/laravel-metrics
# Laravel 13.x
composer require eliseekn/laravel-metrics:^4.0
2. There is nothing to publish or configure. The README documents no service provider assets, migrations, or config file — day/month labels are translated automatically from config('app.locale'). Your first "where to look" is therefore the README's three core sections: Types of periods, Types of aggregates, and Types of data. Those three axes are the whole mental model:
LaravelMetrics::query($builder) → aggregate → period → data type
count/sum byMonth trends/metrics
avg/min/max between
3. First use case — a dashboard card. Put it straight in a controller or, better, a small dashboard service:
use Eliseekn\LaravelMetrics\LaravelMetrics;
$revenue = LaravelMetrics::query(Order::query()->where('status', 'paid'))
->sum('amount')
->byMonth(12)
->forYear(now()->year)
->fillMissingData()
->trends();
4. Add the trait if your metrics are model-centric. HasMetrics gives you a shorter entry point and reads better inside model-scoped services:
use Eliseekn\LaravelMetrics\Traits\HasMetrics; // verify exact namespace in the package
class Order extends Model
{
use HasMetrics;
}
Order::metrics()->sum('amount')->byMonth(6)->trends();
5. Ship the demo first. Clone eliseekn/laravel-metrics-demo and diff your output against it — the package's own docs are prose-plus-snippets, so a working reference project is the fastest way to confirm return shapes before you wire up a chart library.
Every dashboard grows more than one query. Wrap each metric behind a method that returns plain arrays and cache the result — the package executes real aggregate SQL every call.
class DashboardMetrics
{
public function monthlyRevenue(int $months = 12): array
{
return Cache::remember("metrics.revenue.{$months}", now()->addMinutes(15), fn () =>
LaravelMetrics::query(Order::query()->where('status', 'paid'))
->sum('amount')
->byMonth($months)
->fillMissingData()
->trends()
);
}
}
Multi-tenancy, soft-delete filtering, and status filters belong in the builder you pass in. The package only aggregates whatever it receives, so this is the cleanest integration point:
LaravelMetrics::query(
Post::query()
->where('user_id', auth()->id())
->where('published', true)
)
->count()
->byDay(30)
->trends();
created_at is almost never the right axis for anything except "registrations". Use dateColumn() so trends reflect when the event happened:
LaravelMetrics::query(Order::query())
->sum('amount')
->byMonth(6)
->dateColumn('paid_at')
->trends();
This is the pattern you'll reach for most often on a real dashboard — one call producing several datasets for a stacked bar or line chart:
use Eliseekn\LaravelMetrics\Enums\Aggregate;
Order::metrics()
->countByMonth(column: 'status')
->groupData(['pending', 'delivered', 'cancelled'], Aggregate::SUM->value)
->fillMissingData()
->trends();
Keep the array order identical to the order in which the groupData values must appear in the chart legend — the README explicitly warns that changing order yields "false data".
between + groupBy* + fillMissingDataFor "last 30 days" style charts, between is more honest than byDay(30) because it accepts an explicit window and a grouping granularity:
LaravelMetrics::query(Product::query())
->countBetween([now()->subDays(30)->format('Y-m-d'), now()->format('Y-m-d')])
->groupByDay()
->fillMissingData()
->trends();
groupByWeek() / groupByMonth() / groupByYear() let the same query shape serve a 7-day and a 2-year chart with only a parameter change — good candidate for a ChartRange enum in your app.
metricsWithVariationsFor a card that shows 1,842 orders · +12% vs last month, metrics() alone won't do it:
LaravelMetrics::query(Order::query())
->count()
->byMonth()
->metricsWithVariations(previousCount: 1644, previousPeriod: 'month', inPercent: true);
You supply the previous count yourself — which is a hint that you should persist daily/weekly snapshots (a metric_snapshots table or cache keys) rather than recomputing historical windows on every page load.
Normalise everything into {labels, datasets} once, in a resource or a toChartJs() helper, so switching chart libraries never touches the metrics layer:
return response()->json([
'labels' => $revenue['labels'] ?? [],
'data' => $revenue['data'] ?? [],
]);
Use the SQLite in-memory driver in tests, freeze time with Carbon::setTestNow()/$this->travelTo() so byMonth() boundaries are deterministic, and assert on counts rather than on label strings if you assert locale-dependent output. Note that the package added a test suite in 3.2.0, but you still need your own tests around the queries you feed it — that's where the bugs will be.
Two different call signatures for the same idea. between($start, $end) takes two positional dates, while the combined helpers take an array: countBetween([$start, $end]), sumFrom($date). Mixing them up silently produces a wrong window rather than an exception. Double-check argument shape whenever you refactor between the fluent and combined forms.
fillMissingData() is not optional for charts. Without it, days/months with zero rows simply vanish from the series and your line chart visually lies. It auto-discovers labels, so call it on any trend you render — but it only affects trends(), not metrics().
table() is mandatory when you join and aggregate. If you join users and count, the builder needs to know which table the aggregate column and date column belong to. Otherwise you'll get ambiguous-column SQL errors:
LaravelMetrics::query(Order::query()->join('users', 'orders.id', 'users.order_id'))
->count()
->table('users')
->labelColumn('name')
->trends();
Timezone drift on byDay(). now() resolves in app.timezone while your DB likely stores UTC, so a day boundary can shift by hours and the first/last bucket looks short. Either keep the whole app on UTC, or store a denormalised local date column and point dateColumn() at it.
Locale leaks into your JSON. Day/month labels come from config('app.locale'), and the week period is explicitly not translated. If an API consumer or a cache expects stable English labels, set the locale explicitly around the call (App::setLocale('en')) or map the labels yourself before caching — otherwise a user switching language produces a cache-key collision.
Empty results have historically thrown. Changelog entries 2.4.1 and 2.7.3 both patch null/undefined-key exceptions on empty trend and metric data. Whatever version you pin, defensively default the response ($trends['data'] ?? []) in your controllers and add a test for the zero-row case.
Percentages divide by a total that can be zero. trends(inPercent: true) and metricsWithVariations(..., inPercent: true) will produce meaningless or erroring values on a fresh dataset where the first period has no rows. Guard in your presenter, and consider showing "—" rather than 0%.
Debug by dumping, not by guessing. This is a "verbose query builder" by design; when a number looks wrong, dump the underlying SQL/state before calling trends(). Verifying that the between bounds and the aggregate column are what you think they are solves most reported oddities.
Version pinning matters more than usual here. The package moved from fillEmptyData → fillMissingData, restructured in 2.x, and split into a 4.x branch for Laravel 13. With zero dependents and a single maintainer, pin a caret version, read CHANGELOG.md before upgrading, and keep your dashboard-output tests as the regression net.
There are no custom-driver extension points. Unlike configurable metric packages, this one exposes no driver registration, no config file, and no pluggable formatter. The realistic extension strategy is composition: wrap it in your own repository/service, own the caching, snapshotting, label-mapping, and chart-shaping layers, and treat LaravelMetrics as a pure "SQL aggregate → array" primitive. If you later outgrow it, replacing one thin class is trivial.
Watch duplicate rows on joined counts. count() defaults to id; a one-to-many join inflates the count. Push distinct into the builder you pass in, or count from a subquery — the package won't deduplicate on your behalf.
How can I help you explore Laravel packages today?