定制 syriable/laravel-metrics 二次开发

按需修改功能、优化性能、对接业务系统,提供一站式技术支持

邮箱:yvsm@zunyunkeji.com | QQ:316430983 | 微信:yvsm316

syriable/laravel-metrics

Composer 安装命令:

composer require syriable/laravel-metrics

包简介

A modern, extensible, API-first analytics/metrics engine for Laravel — values, trends, partitions, comparisons, formulas and multi-dataset metrics computed database-side.

README 文档

README

A standalone, backend-only metrics engine for Laravel. It computes values, trends, partitions, comparisons, multi-dataset metrics and formulas — all aggregated database-side — and returns normalized, serializer-friendly structures ready for any consumer: REST, GraphQL, Filament, Vue, React, Flutter, CLI.

No charts. No Blade. No Livewire. No widgets. Just an engine.

This package separates metrics computation from presentation. Define metrics as pure, reusable components that work everywhere — in API endpoints, queued reports, tests, and CLI commands — without coupling to HTTP requests, UI frameworks, or presentation concerns. The engine's architecture is guided by explicit design decisions documented in ADRs; see docs/architecture.md for the full picture.

Requirements

  • PHP 8.4+
  • Laravel 11 / 12 / 13
  • MySQL, MariaDB, PostgreSQL, SQLite or SQL Server

Installation

composer require syriable/laravel-metrics
php artisan vendor:publish --tag="laravel-metrics-config"   # optional

Quick start

use Syriable\Metrics\Facades\Metrics;

// One number, compared against the previous 30 days — in a single query.
$result = Metrics::query(Order::class)
    ->sum('total')
    ->range('30d')
    ->compareWithPrevious()
    ->value();

$result->value();                    // 48250.75
$result->comparison()->percentage;   // 12.4
$result->comparison()->direction;    // Direction::Up
$result->toArray();                  // normalized API payload
// A gap-filled daily series.
$trend = Metrics::query(Order::class)
    ->count()
    ->range('mtd')
    ->perDay()
    ->trend();

// A group-by breakdown with engine-computed percentages.
$partition = Metrics::query(Order::class)
    ->count()
    ->groupBy('status')
    ->top(5)              // fold the tail into "others"
    ->partition();

Concepts

Concept What it is
Value one number per dataset, optionally compared against a reference period
Trend a time series per dataset, gap-filled, bucketed by minute→year
Partition one number per group per dataset (groupBy)
Dataset one aggregation inside a metric; a metric can hold many
Formula a computed dataset evaluated over the others, server-side
Range a named resolver ("mtd", "30d") producing an immutable Period
Comparison a strategy picking the reference window + uniform math (Δ, %, direction)
Metric a reusable, named definition — a class extending Metric, scaffolded with make:metric and run by key

Ranges

Built-in keys: today, yesterday, wtd, last_week, mtd, last_month, qtd, last_quarter, ytd, last_year, all — plus rolling patterns that need no registration: 90m, 24h, 30d, 4w, 12mo, 2q, 5y.

->range('qtd')                                    // named
->range('12mo')                                   // rolling
->between('2026-01-01', '2026-06-30')             // explicit period
->allTime()                                       // unbounded

Calendar-aligned ranges compare like-for-like: mtd on July 10 compares July 1–10 against June 1–10, not against the 10 days ending June 30.

Trends

Metrics::query(Order::class)
    ->average('total')
    ->range('ytd')
    ->per(Interval::Week)      // Minute | Hour | Day | Week | Month | Quarter | Year
    ->timezone('Asia/Kolkata') // minute-precision bucket shifting
    ->trend();

Every point carries a canonical machine key (2026-W28, 2026-07-10, 2026-Q3), a human label, the bucket start, and the value. Missing buckets are zero-filled with the aggregate's empty value (0 for count/sum, null for avg/min/max). Series joining happens on machine keys — labels are pure presentation.

Comparisons

->compareWithPrevious()          // immediately preceding period
->compareWithPreviousWeek()      // same window, shifted back
->compareWithPreviousMonth()     //   (no month-overflow surprises)
->compareWithPreviousQuarter()
->compareWithPreviousYear()
->compareWith($customStrategy)   // your own ComparisonStrategy

The engine computes previous, difference, percentage and direction server-side. A value + comparison is one SQL query (conditional aggregation), not two.

Datasets & formulas

$result = Metrics::query(Order::class)
    ->range('30d')
    ->dataset('revenue', fn ($d) => $d->sum('total'))
    ->dataset('refunds', fn ($d) => $d->sum('refund_total'))
    ->dataset('expenses', fn ($d) => $d->sum('amount')->from(Expense::class))
    ->formula('profit', '[revenue] - [refunds] - [expenses]')
    ->formula('margin', 'profit / revenue * 100')
    ->value();

$result->value('margin'); // 37.5

Formulas are parsed by a small, safe arithmetic evaluator (no eval, no SQL). They work across all three metric shapes — per value, per trend point, per partition group — and comparisons flow through them. Division by zero and null operands yield null ("no data"), never an exception.

Named metrics (the API story)

class OrdersRevenue extends Metric
{
    public function query(): MetricBuilder
    {
        return Metrics::query(Order::class)
            ->sum('total')->range('30d')->compareWithPrevious()->cache(300);
    }
}

// e.g. in a controller:
Route::get('/api/metrics/{key}', function (string $key, Request $request) {
    return Metrics::run($key, $request->only(['range', 'interval', 'timezone', 'compare']));
});

No Metrics::register() call needed — any Metric subclass living under the configured namespace/path (App\Metrics / app/Metrics by default, whether hand-written or generated with make:metric) is discovered and registered automatically at boot. Register manually only for metrics that live elsewhere — a package, a different directory:

Metrics::register(SomeOtherPackage\Metrics\Signups::class);

The package deliberately ships no routes — one line of your routing exposes every registered metric, under your auth, your throttling, your versioning.

Generating metrics

php artisan make:metric Revenue

Scaffolds a new class in the configured metrics namespace (App\Metrics / app/Metrics by default) from a publishable stub — the same experience as make:model or make:notification:

INFO  Metric [app/Metrics/Revenue.php] created successfully.

Nested names respect PSR-4, just like every first-party generator:

php artisan make:metric Sales/Revenue   # App\Metrics\Sales\Revenue

--force overwrites an existing class; without it, make:metric refuses to clobber your work. Everything else is configurable in config/metrics.php:

'generator' => [
    'namespace' => 'App\\Metrics',   // the namespace generated classes declare
    'path' => app_path('Metrics'),  // where they're written
    'stub' => null,                 // an absolute path to fully override the stub
    'base_class' => Metric::class,  // the class generated metrics extend
],

Publish the stub to customize it in place — no config change required:

php artisan vendor:publish --tag="laravel-metrics-stubs"

Every metric generated this way is registered automatically — see Automatic discovery. Set metrics.discover to false if you'd rather register everything by hand.

Metric shapes are an open vocabulary, not a hardcoded switch: make:metric discovers its --{option} flags from a blueprint registry, so a future --trend/--value/--partition scaffold is registering a MetricBlueprint, never editing the command.

Automatic discovery

Every concrete Metric subclass under the configured namespace/path is found and registered at boot — the same file-path-to-class discovery Laravel uses for console commands in app/Console/Commands. No manifest, no cache, no Metrics::register() call:

// app/Metrics/Revenue.php — written by hand or by `make:metric` —
// is registered automatically. Just run it:
Metrics::run('revenue');

Turn it off in config/metrics.php if you'd rather register everything explicitly (or have a very large metrics directory and want to avoid the boot-time scan):

'discover' => false,

Caching

->cache(600)      // seconds; any Laravel TTL value works
->fresh()         // bypass for one execution

Keys are hashed from the compiled SQL + bindings of every dataset plus the resolved period/interval/timezone/formulas — changing anything about a metric's definition is automatically a cache miss. Only plain arrays are cached, never objects or closures. Store and global TTL are configurable in config/metrics.php.

Output

Every result serializes to the same normalized shape:

{
    "key": "orders.revenue",
    "type": "trend",
    "range": {"key": "7d", "start": "2026-07-03T12:00:00+00:00", "end": "2026-07-10T12:00:00+00:00", "timezone": "UTC"},
    "interval": "day",
    "datasets": [
        {
            "name": "default",
            "points": [{"key": "2026-07-09", "label": "Jul 9, 2026", "start": "2026-07-09T00:00:00+00:00", "value": 30}],
            "total": 30,
            "formula": false,
            "comparison": {"strategy": "previous_period", "previous": 40, "difference": -10, "percentage": -25, "direction": "down"}
        }
    ],
    "meta": {},
    "generated_at": "2026-07-10T12:00:00+00:00",
    "from_cache": false
}

Extending everything

use Syriable\Metrics\Aggregates\CallbackAggregate;
use Syriable\Metrics\Ranges\CallbackRange;
use Syriable\Metrics\Comparisons\CallbackComparison;

// A new aggregation — no core changes:
Metrics::registerAggregate(new CallbackAggregate(
    'stddev', fn (string $inner) => "stddev({$inner})",
));

// A fiscal-year range:
Metrics::registerRange(new CallbackRange('fiscal_ytd', 'Fiscal YTD',
    fn (CarbonImmutable $now) => new Period($now->setMonth(4)->startOfMonth(), $now),
));

// A custom reference window:
Metrics::registerComparison(new CallbackComparison('vs_launch',
    fn (Period $current) => Period::between('2026-01-01', '2026-01-31'),
));

// A new database driver — one class:
Metrics::registerDialect(new FirebirdDialect);

// Your own payload shape / expression language:
Metrics::useSerializer(new JsonApiSerializer);
Metrics::useFormulaEvaluator(new SymfonyExpressionEvaluator);

Performance notes

  • Aggregation is always database-side; result rows are never hydrated into models (queries run through the base query builder).
  • Value + comparison = 1 query. Trend = 1 grouped query per dataset. Partition = 1 grouped query per dataset.
  • Range filtering stays on the raw indexed column (whereBetween); only the bucket key is computed per row.
  • Timelines are generated lazily and capped (metrics.max_buckets) so a minute-over-a-year request fails fast instead of exhausting memory.
  • ->withNow($instant) pins the clock — deterministic tests and backfills without freezing global time.

Testing

composer test

Documentation

License

MIT — see LICENSE.md.

统计信息

  • 总下载量: 1
  • 月度下载量: 0
  • 日度下载量: 0
  • 收藏数: 0
  • 点击次数: 0
  • 依赖项目数: 0
  • 推荐数: 0

GitHub 信息

  • Stars: 0
  • Watchers: 0
  • Forks: 0
  • 开发语言: PHP

其他信息

  • 授权协议: MIT
  • 更新时间: 2026-07-14

承接程序开发

PHP开发

VUE

Vue开发

前端开发

小程序开发

公众号开发

系统定制

数据库设计

云部署

网站建设

安全加固