NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Metrics for Laravel

Metric classes

For metrics you reuse across a dashboard, extend a base type and implement calculate(), which returns the matching result object:

TypeExtendcalculate() returnsGood for
ValueRoundlyConsulting\Metrics\Types\Value\ValueValueResultA single number, optionally vs. the previous period
TrendRoundlyConsulting\Metrics\Types\Trend\TrendTrendResultA time series (line/area chart)
ProgressRoundlyConsulting\Metrics\Types\Progress\ProgressProgressResultCurrent value vs. a target (progress bar)
PartitionRoundlyConsulting\Metrics\Types\Partition\PartitionPartitionResultA grouped breakdown (pie/bar chart)

Value

use App\Models\User;
use RoundlyConsulting\Metrics\Types\Result;
use RoundlyConsulting\Metrics\Types\Value\Value;

final class RegisteredUsers extends Value
{
    protected function calculate(): Result
    {
        return $this->count(User::query());
    }
}

RegisteredUsers::make()->range('TODAY')->toArray();

Trend

use App\Models\User;
use RoundlyConsulting\Metrics\Types\Result;
use RoundlyConsulting\Metrics\Types\Trend\Trend;

final class RegisteredUsersTrend extends Trend
{
    protected function calculate(): Result
    {
        return $this->count(User::query(), 'created_at');
    }
}

RegisteredUsersTrend::make()->hourly()->range('TODAY')->toArray();

Progress

A Progress metric compares the current value to a target (default 100). Configure its defaults once in setup():

use App\Models\Subscription;
use RoundlyConsulting\Metrics\Types\Progress\Progress;
use RoundlyConsulting\Metrics\Types\Result;

final class MonthlyRevenueGoal extends Progress
{
    protected function setup(): void
    {
        $this->target(10000)->range('MTD');
    }

    protected function calculate(): Result
    {
        return $this->sum(Subscription::query(), 'amount');
    }
}

Use shouldBeAvoided() when the target is a ceiling to stay under — an error budget or a spend cap — rather than a goal to reach. It surfaces as avoid in the result so the UI can colour it accordingly:

use App\Models\Incident;
use RoundlyConsulting\Metrics\Types\Progress\Progress;
use RoundlyConsulting\Metrics\Types\Result;

final class ErrorBudget extends Progress
{
    protected function setup(): void
    {
        $this->target(50)->shouldBeAvoided()->range('MTD');   // stay under 50 this month
    }

    protected function calculate(): Result
    {
        return $this->count(Incident::query());
    }
}

// result => [..., 'target' => 50.0, 'avoid' => true, ...]

Partition

use App\Models\User;
use RoundlyConsulting\Metrics\Types\Partition\Partition;
use RoundlyConsulting\Metrics\Types\Result;

final class UsersByPlan extends Partition
{
    protected function calculate(): Result
    {
        // count of users grouped by the `plan` column
        return $this->count(User::query(), 'plan');
    }
}

Defaults in setup()

setup() runs in the constructor, after the config defaults. Configure presentation and behaviour there once instead of at every call site — fluent calls on the instance still win:

use App\Models\Order;
use RoundlyConsulting\Metrics\Enums\Period;
use RoundlyConsulting\Metrics\Types\Result;
use RoundlyConsulting\Metrics\Types\Value\Value;

final class Revenue extends Value
{
    protected function setup(): void
    {
        $this->withChangeAgainstPreviousPeriod();

        $this->name('Revenue')
            ->description('Paid orders in the selected period')
            ->prefix('$')
            ->precision(2)
            ->range(Period::MonthToDate);
    }

    protected function calculate(): Result
    {
        return $this->sum(Order::query()->where('status', 'paid'), 'total');
    }
}

// setup() runs in the constructor, so call-site fluent calls still win:
Revenue::make()->range(Period::LastMonth)->toArray();

Shared fluent API

Every metric — class-based or inline — extends the abstract RoundlyConsulting\Metrics\Metric class and exposes:

MethodEffect
name(string $name)Display name; defaults to the humanized class name.
description(string $description)Free-text description in the envelope.
prefix(string $prefix) / suffix(string $suffix)Display prefix and suffix, e.g. a currency sign or a unit.
precision(int $precision = 0, RoundingMode $mode = RoundingMode::HalfAwayFromZero)Rounding precision and mode for every value.
range(Period|string $range, ?string $customRangeStart = null, ?string $customRangeEnd = null)Date range — see Date ranges.
timezone(?string $timezone)Resolve this metric’s ranges and label its trend buckets in an explicit timezone — see Date ranges.
ranges(): arrayThe selectable range keys and labels.
formatUsing(Closure $formatter)Add a formatted value to the result — see Number formatting.
cache(), cacheFor(), cacheKey(), dontCache()Per-metric caching — see Caching.
key(): ?stringThe registry key it was resolved under, or null for ad-hoc metrics.
make(mixed ...$arguments): staticStatic constructor.
result(): ResultThe typed result object — calculated, or restored from the result cache.
toArray(): arrayResolve and return the envelope.
toResponse($request): JsonResponseThe envelope as a JSON response (Responsable).

precision() takes PHP’s native RoundingMode enum, e.g. ->precision(2, RoundingMode::HalfEven). By default a metric’s name is the humanized class name (RegisteredUsers → “Registered Users”).

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.