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

Laravel Stats Laravel Package

spatie/laravel-stats

Lightweight Laravel package that summarizes database changes over time. Define a stats class, call increase() or decrease() as events happen, then query totals grouped by day, week, or month, returning increments, decrements, and net differences for each period.

View on GitHub
Deep Wiki
Context7

Getting Started

composer require spatie/laravel-stats

php artisan vendor:publish --provider="Spatie\Stats\StatsServiceProvider" --tag="stats-migrations"
php artisan migrate

That creates a single stats table where every increase / decrease / set call becomes one row (name, type, value, created_at). Reads are computed from those rows, so there is nothing else to configure.

Create one class per statistic — this is your whole "model" layer, and there is no artisan generator, so just add it under app/Stats:

namespace App\Stats;

use Spatie\Stats\BaseStats;

class SignupStats extends BaseStats {}

Then instrument the domain event, not the controller. A listener is the natural seam:

class RecordSignup
{
    public function handle(UserRegistered $event): void
    {
        SignupStats::increase();
    }
}

Read it back immediately to confirm the pipeline works:

use App\Stats\SignupStats;

$stats = SignupStats::query()
    ->start(now()->subDays(30))
    ->end(now()->subSecond())
    ->groupByDay()
    ->get();

You get an array of Spatie\Stats\DataPoint objects. ->toArray() on each (or the collection you map) gives start, end, value, increments, decrements, difference — enough to feed Chart.js/ApexCharts or an Inertia prop without any extra shaping.

First real use case: a "growth" card on an admin dashboard — one groupByDay() query for the last 30 days, value as the running total line and difference as the daily net-change bars.

Implementation Patterns

Write at the event boundary, read at the presentation boundary. Keep increase()/decrease() inside listeners, observers, or model events; keep query()->start()->end()->groupBy*()->get() inside a small reporting class or controller. Don't scatter stats writes through controllers — you'll miss the paths that create records via jobs, imports, or the console.

Backfill with the timestamp argument. Every write method takes a datetime, which is how you make history correct for pre-existing rows:

SignupStats::increase(1, $user->created_at);

For a large existing table, prefer one absolute baseline over N events:

SignupStats::set(User::where('created_at', '<', now()->startOfMonth())->count(), now()->startOfMonth());

set() is for externally-owned numbers. When the source of truth lives in Stripe/another API, run a scheduled command that pulls the count and calls set($count). Never mix that with per-event increase() for the same stat — pick one strategy per statistic.

Separate reads and writes for non-BaseStats sources. As of 2.x, BaseStats gives you ::writer() and ::query(), and the generic entry points are StatsWriter::for(...) / StatsQuery::for(...). Grouped attributes let you multiplex many logical stats into one table:

StatsWriter::for(Order::class, ['region' => 'eu'])->increase(1);
StatsWriter::for(Order::class, ['region' => 'eu'])->decrease(1, $order->cancelled_at);

StatsQuery::for(Order::class, ['region' => 'eu'])
    ->start(now()->subMonths(3))
    ->end(now()->subSecond())
    ->groupByWeek()
    ->get();

Multi-tenant via relationships. A hasMany relationship scope keeps tenants isolated without extra columns in your queries:

StatsWriter::for($tenant->orderStats(), ['type' => 'recurring'])->increase(1);

$stats = StatsQuery::for($tenant->orderStats())
    ->start(now()->subMonth())
    ->groupByDay()
    ->get();

This requires the related model to use the HasStats trait and, if you add filter columns, the corresponding columns on that table.

Map DataPoints to chart series once, in one place. Wrap it so every widget is consistent:

return collect($stats)
    ->map(fn ($point) => [
        'label'      => $point->toArray()['start'],
        'total'      => $point->value,
        'net'        => $point->difference,
        'in'         => $point->increments,
        'out'        => $point->decrements,
    ])
    ->values();

Wrap writes in afterCommit if you use transactions and queues. A stats row written inside a transaction that later rolls back, or written before the job that owns it fails, leaves you with permanently skewed history — the rows are append-only.

Gotchas and Tips

  • value is cumulative, not per-period. For a period ending Jan 8, value is the total as of Jan 8 (including everything before your start()), while increments/decrements/difference are scoped to that period. Use value for a trend line, difference for bars. Using value as a per-bucket bar height produces a misleading ramp.

  • end(now()->subSecond()) in the docs is deliberate. The current second is a moving boundary; excluding it avoids a half-populated trailing bucket. Always bound end() explicitly instead of omitting it.

  • The default storage key is derived from the class name. Renaming or moving SubscriptionStats orphans every historical row. Override getName() with a stable string from day one:

    public function getName(): string { return 'subscriptions'; }
    
  • set() does not count as an increment. Absolute-value events move value but aren't increments/decrements, so a stat driven by set() will show a healthy total with empty or zero change columns. If the dashboard leans on increments, use change events.

  • Mixing strategies double-counts. A scheduled set($apiCount) plus increase() on webhooks yields nonsense. One stat = one write strategy.

  • No retention or pruning command. Every event is a row, forever. Deleting old rows silently changes historical value (the cumulative baseline disappears), so if the table grows, archive rather than delete, or materialize a monthly snapshot with set().

  • Index your query columns. StatsQuery filters on name/type plus the date column. A composite index on those is the difference between a snappy dashboard and a multi-second page on a table with millions of rows.

  • Cache the aggregation, not the raw events. Grouping a year by day recomputes on every render. Cache::remember("stats.signups.30d", 300, fn () => ...) is the pragmatic fix.

  • Check the writer method name in your installed version. The README's relationship example calls ->increment(1) while the other examples use ->increase(1) — the 2.x API is increase/decrease/set, so verify against vendor/spatie/laravel-stats/src/StatsWriter.php if you hit a BadMethodCallException.

  • Upgrading from 1.x is a real port. StatsQuery::for(OrderStats::class) became OrderStats::query(), StatsEvent::TYPE_SET/TYPE_CHANGE moved to DataPoint::*, and createEvent() was removed. Read upgrading.md before bumping.

  • Database support is version-dependent: PostgreSQL landed in 2.0.3, SQLite in 2.2.0, and table-prefix awareness in 2.0.2. On older pins with a DB_PREFIX, queries can miss the table — check the changelog against your DB before debugging your own code.

  • Test with frozen time. Carbon::setTestNow() around the write, then assert the toArray() shape of a groupByDay() query. Asserting exact start/end strings catches timezone and DST boundary drift that only shows up in month/quarter groupings.

  • There is no UI. This package stops at DataPoint arrays; dashboards, charts, and access control are entirely yours. Plan for a thin reporting class per widget rather than query code sprinkled in Blade.

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.
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names