Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Filament Simple Stats Laravel Package

spatie/filament-simple-stats

Opinionated prebuilt stat widgets for Filament dashboards that combine Filament's Stat widgets with laravel-trend, so you can quickly show daily counts or sums for a model over the last 30 days with just a few lines of code.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and pin the right version first. The package tracks Filament closely, so the version you get matters:

composer require spatie/filament-simple-stats
  • Filament 5.2+ → 1.1.0 or later (current 1.2.1).
  • Filament 4 → pin 0.2.1 (the release that updated widgets to v4).
  • Laravel 13 → 1.2.0+ (Laravel 13 support and the inlined trend code landed there).

There is no config file, no migration, no service provider publishing step. Installation is the whole setup — which is the point of the package.

Where to look first. The public surface is a single class: spatie/filament-simple-stats/src/SimpleStat.php. Open it before writing a single widget line. Since 1.2.0, flowframe/laravel-trend was inlined into this package, so the aggregation/period helpers live in the same src/ tree — read that to discover which period methods (last30Days() is the only one the README documents) and granularities (dailyCount(), dailySum()) actually exist. Do not assume the full laravel-trend API is exposed 1:1.

First use case: replace a hand-rolled dashboard query. Create a stats overview widget and swap a manual User::whereDate(...)->count() for a SimpleStat:

php artisan make:filament-widget StatsOverview --stats-overview
use Spatie\FilamentSimpleStats\SimpleStat;

protected function getStats(): array
{
    return [
        SimpleStat::make(User::class)->last30Days()->dailyCount(),
        SimpleStat::make(Purchase::class)->last30Days()->dailySum('earnings'),
    ];
}

Register it on the panel (->widgets([StatsOverview::class])) or let Filament auto-discover it, then load /admin. You should see the count/sum, a per-day sparkline, and a green/red percentage trend against the previous 30 days. That end-to-end loop — install, widget, one stat, trend visible — should take under ten minutes. Everything after that is scoping and tuning.

Implementation Patterns

Scope with where() rather than hand-written builders. The package's chainable where() is the idiomatic integration point, and it composes cleanly with auth and tenancy:

SimpleStat::make(Purchase::class)
    ->where('user_id', auth()->id())
    ->last30Days()
    ->dailySum('earnings'),

For multi-tenant panels, always add the tenant constraint explicitly — do not rely on a global scope being present:

SimpleStat::make(Order::class)
    ->where('team_id', filament()->getTenant()?->id)
    ->last30Days()
    ->dailySum('total'),

Prefer the Eloquent Builder form when you have a repeated scope. 1.2.1 added support for passing a builder instead of a class string, which lets you reuse a local model scope and keep the dashboard declaration short:

SimpleStat::make(Purchase::query()->where('status', 'paid'))->last30Days()->dailySum('earnings'),

This is also the cleanest escape hatch for anything the where() chain doesn't express (whereHas, whereIn, JSON columns) — build it in the model scope, unit-test the scope, and keep getStats() dumb.

Mix SimpleStat with native Stat::make() in the same array. getStats() is just an array of Stat instances, so metrics that aren't "count/sum of an Eloquent model over a period" (API balances, queue depths, cache-hit ratios) should stay as vanilla Filament stats. Don't contort SimpleStat to fit them.

A widget per audience, not one giant dashboard. Because the array renders as one strip of cards, split into several StatsOverviewWidget classes — RevenueStats, SupportStats, UserGrowthStats — and register the subset per panel/role. This keeps the stat count per render low and gives you a natural place to hang authorization (canView()).

Turn trends off for counters where a period comparison is meaningless or noisy. Signups, total users, and anything with a zero or near-zero prior period read badly with a percentage:

SimpleStat::make(User::class)->last30Days()->dailyCount()->withoutTrend(),

Invert trend colors for "down is good" metrics. Error rates, refunds, support tickets, cloud spend:

SimpleStat::make(Error::class)->last30Days()->dailyCount()->invertTrendColors(),

Keep a mental rule: trend color semantics are per-metric, so decide the polarity at the call site and never leave a cost/error stat at defaults.

Test the widget, not the package. Freeze time and assert getStats() returns the expected instances with the expected values using RefreshDatabase; that's what actually breaks when you change a where() clause or a date boundary. The package's own suite is Pest-based and is a good template.

Gotchas and Tips

Each stat runs more than one query. A trend-enabled stat needs the aggregate for the current period, the aggregate for the previous period, and the per-day series for the sparkline. Eight trend stats on one dashboard is not eight queries — budget accordingly, and if a dashboard feels slow, withoutTrend() is the first lever. For genuinely expensive aggregates, wrap the values in Cache::remember() inside a custom widget subclass or set a conservative pollingInterval on the stats overview widget so it doesn't re-run on every Livewire poll.

Trends divide by the previous period. When the previous window is zero (new feature, new tenant, first month of data), the percentage is meaningless or degenerate. Use withoutTrend() for anything that can plausibly start from nothing, rather than debugging a weird "-100%" card later.

Date boundaries and today's data. endOfDay handling was added in 0.1.4 specifically so the current day's records are included — but the boundary is computed against your app's timezone assumptions, while created_at is typically stored in UTC. If your reporting day must match a business timezone, verify the window against a known row rather than trusting the label "last 30 days."

Chain order matters. SimpleStat builds on Filament's Stat, so standard Stat methods like ->description(), ->color(), and ->icon() are available — but the trend feature also writes to the description and color. Call trend modifiers (withoutTrend(), invertTrendColors()) after any cosmetic calls, and if a card shows an unexpected color or description, suspect a clash between the trend output and your own description()/color() call. When in doubt, drop the custom description.

Filament policies and tenancy are not applied for you. SimpleStat::make(User::class) counts every user, regardless of Filament's authorization layer or any panel tenancy. Any dashboard visible to non-superadmins needs explicit where() scoping — treat an unscoped SimpleStat on a shared dashboard as a data-leak bug, not a styling issue.

Custom timestamp columns need verification. The aggregation resolves a date column; if a model tracks the relevant event on something other than created_at (e.g. paid_at, published_at), confirm how to point the stat at it by reading SimpleStat — don't silently compare a paid_at-shaped metric against created_at, which produces plausible-looking but wrong numbers.

Version bumps are not always minor in effect. 1.2.0 inlined flowframe/laravel-trend, so older guidance referencing a separate laravel-trend install/API no longer applies, and the package's internal classes moved. If you copy a snippet from a blog post or an older README, check it against the installed version — and if composer require gives you an unexpected Filament 4/5 mismatch, that's the version table, not a bug.

Extension point: subclass SimpleStat for house style. If your team always wants the same where() plus the same trend polarity for a given domain concept, add tiny factory wrappers (RevenueStat::make() returning a preconfigured SimpleStat) instead of repeating five fluent calls across every widget. That's the lowest-friction way to keep an opinionated package from drifting per-developer.

Debugging a wrong number: build the same query independently (User::where(...)->whereBetween(...)->count()), then dump() the builder you pass into SimpleStat::make() and log the SQL to compare. The package is thin enough that a discrepancy is almost always a scope, timezone, or column-name mistake on your side rather than a package bug.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin