square1/nova-metrics
Square1 Nova Metrics adds customizable metric cards to Laravel Nova, helping you display key application statistics and trends directly in your Nova dashboard with simple, configurable value and trend metrics.
Day 0 — read the source before you composer require. This is a 27-star, zero-dependent package that reaches into closed-source Nova internals, so the compatibility check is the work. Spend half a day on the repo:
composer.json — note the declared Nova major, Laravel, and PHP constraints. Nova 4 → 5 was a Vue 2 → Vue 3 jump with a different asset pipeline (Nova.booting); card packages routinely break across that line. If it declares Nova 5, find the CI matrix that proves it. If it doesn't, assume Nova 4 is the ceiling until you've tested otherwise.Filter::apply($request, $query) server-side per metric request, or does it accept opaque filter state from the client and replay it? Server-side is auditable and inherits your existing canSee() rules. Client-supplied state is a tampering surface and a cross-tenant leak waiting to happen.Install pinned — never with a caret:
composer require square1/nova-metrics:x.y.z
Record the Nova version you validated against in the PR description, not just in composer.lock. The lockfile tells the next person what you installed; the PR tells them what you tested.
Where to look first, in order: the trait or base class that does the filter threading → the service provider / card registration path → any published config → the tests (if there are any; if there aren't, that's your first contribution). Read the entry point end to end before wiring a card; you need to know whether the metric sees your TicketResource::filters() list or a copy of it.
First use case — one Value card, one resource, one simple filter. Pick a resource where users already apply a status or owner filter on the index page. Wire exactly one metric card that respects it. Then run the only check that matters:
// tests/Feature/Nova/FilteredMetricParityTest.php
$filters = ['status' => 'open'];
$indexCount = (new TicketResource::indexQuery(
NovaRequest::create('/', 'GET', ['filter' => $filters]), Ticket::query()
))->count();
$metricValue = (new OpenTicketsMetric)->calculate(
NovaRequest::create('/', 'GET', ['filters' => $filters])
)->value;
$this->assertSame($indexCount, $metricValue);
Green for no-filter, single-filter, and multi-filter cases earns you the right to add a second card. Red means stop — a metric that renders but silently ignores a filter is worse than a broken dashboard, because it looks trustworthy and gets screenshotted into a business review.
Wrap it in your own base class from day one. Put an internal App\Nova\Metrics\FilterAwareMetric in place that extends or uses the package's trait. Every concrete metric extends that, never the package class directly. This is a single seam: when upstream goes stale, or breaks on your next Nova upgrade, the swap to first-party code or a vendored fork is one file, not a sweep through every dashboard.
namespace App\Nova\Metrics;
use Square1\NovaMetrics\...; // the only place the package is named in your app
abstract class FilterAwareMetric extends ...
{
// hook for cross-cutting concerns: labelling, cache keys, guard rails
}
Single-source your filter definitions. The metric and the resource index must resolve from the same filter classes, not two hand-copied implementations of the same where clause. If TicketResource::filters() returns [StatusFilter::class, OwnerFilter::class], the card should resolve from that same list — ideally by reading it, not by duplicating it in a config array. Divergence here is the "the numbers don't match" bug you installed the package to fix, reintroduced in a new place.
Cache keyed on the filter payload and the user. If you wrap the calculation in Cache::remember, the key must include the resolved filter state and the authenticated user's ID. Otherwise a tenant- or owner-scoped metric serves a cached number across users. This is the highest-severity mistake available in this integration, and it will not show up in a single-user smoke test.
$key = sprintf(
'metric:open-tickets:%s:%s',
auth()->id(),
md5(json_encode($resolvedFilters))
);
Build a parity harness as a data provider, not a single test. The point of the package is agreement between two query paths, so test the matrix: no filters, each filter alone, filter combinations, a filter with defaults, and a user with restricted scope.
public static function filterMatrix(): array
{
return [
'none' => [[]],
'status' => [['status' => 'open']],
'owner' => [['owner' => 7]],
'status + owner' => [['status' => 'open', 'owner' => 7]],
];
}
Prefer Value and Partition over Trend for filtered cards. A Trend metric issues one query per bucket. With a cheap column predicate that's 30 queries and fine; with a whereHas filter it's 30 correlated subqueries and a dashboard that feels broken. Measure before you ship a trend card behind an expensive filter — and if the filter is expensive, precompute on a schedule instead.
Register cards where they already belong. Add the metric to the existing NovaServiceProvider::cards() or a dashboard's cards() array — don't introduce a parallel registration pathway. Keep each card's uriKey() stable from the first deploy; changing it later orphans any saved dashboard layouts user has customised.
Label filtered cards in the UI. A number that changes with filter state must say so in its name, its help text, or both — Open Tickets (filtered) rather than Open Tickets. The package solves query correctness; it does not solve the human problem of two people arguing over two different numbers that are both technically right.
canSee() and user-dependent options() on filters behave differently in a metric request context than on an index request. Test with two users of different tenant/owner scope and assert they get different numbers. If they don't, you have a data exposure, not a cosmetic bug.options() runs on every metric request. Filters whose options() hits the database with a heavy query now run once per card, on every dashboard load. Watch the query count on a cold dashboard with three or four of these cards; it adds up faster than you'd guess.DateFilter ranges are computed against the request; a metric resolved in a different context can be off by a day at the boundary. Test an explicit range edge — the last second of a period, and a user in a non-UTC timezone.DB::listen(fn ($q) => logger()->debug($q->sql, $q->bindings));
EXPLAIN on the filtered metric query. A filter that translates to whereHas may be fine on a small table and disastrous at production volume — and a dashboard loads it before anyone has applied anything.How can I help you explore Laravel packages today?