Skip to content

withSequentialPeriodMetrics() for period-over-period analytics#59766

Open
NurullahDemirel wants to merge 3 commits intolaravel:13.xfrom
NurullahDemirel:feature/eloquent-sequential-period
Open

withSequentialPeriodMetrics() for period-over-period analytics#59766
NurullahDemirel wants to merge 3 commits intolaravel:13.xfrom
NurullahDemirel:feature/eloquent-sequential-period

Conversation

@NurullahDemirel
Copy link
Copy Markdown
Contributor

withSequentialPeriodMetrics()

A new Query / Eloquent Builder method that computes per-period metrics
and their period-over-period comparisons (e.g. month-over-month percent
change) in a single query.


Small, readable API

Screens that used to take 40-50 lines of "a few subqueries plus PHP-side
post-processing"
typically collapse down to 5-10 lines:

use Illuminate\Database\Query\Aggregate;

// Dashboard: revenue and order growth over the last 12 months
return Order::query()
    ->where('created_at', '>=', now()->subYear())
    ->withSequentialPeriodMetrics(
        periodFormat: 'Y-m',
        aggregates: [
            Aggregate::sum('cost')
            Aggregate::count('*'),
        ],
        selectComparisonsOnly: false,
    )
    ->get();

That payload can be sent straight to the dashboard component and rendered as a
chart — no extra computation layer needed.


TL;DR

It buckets rows by a date column into periods (day / week / month / year),
computes the aggregates you ask for (sum, avg, count, min, max)
per bucket, and on top of that returns the absolute difference (_change)
and percent change (_change_percent) versus the previous period — all in
the same query.

One call → monthly revenue, order count, and MoM percent change for each.
The result feeds straight into a line / bar chart.


The problem it solves

A seemingly trivial BI question — "how much revenue did we make each month,
and how does it compare to the previous month?"
— usually ends up being
solved over and over again like this:

  1. Write an aggregate query with GROUP BY DATE_FORMAT(...).
  2. Sort the rows in PHP.
  3. Track the previous row by hand and apply
    (current - previous) / previous * 100.
  4. Patch up the edge cases: null, divide-by-zero, missing periods, sort drift.
  5. When you have multiple metrics (revenue + orders + cost), repeat the steps.
  6. When you also need to support several drivers (MySQL / PostgreSQL / SQLite
    / SQL Server), reimplement the date formatting expression for each.

withSequentialPeriodMetrics() collapses all of that into one builder call.
The driver-specific SQL is generated automatically and a window function
(LAG) returns both the aggregates and the comparison columns in a single
round-trip.


When to use it

It's a good fit if you need any of these:

  • KPI cards in admin / BI dashboards: "this month vs last month",
    "this week vs last week", etc.
  • Time-series charts (line / bar) where the X axis is a period, the Y axis
    is an aggregate, and you also want the percent change in the tooltip.
  • Reports with derived growth metrics — MoM, WoW, YoY.
  • Multiple metrics on the same period axis (revenue, order count, average
    basket, cost…) computed at once.

When it is not the right tool:

  • Rolling windows / moving averages (e.g. "7-day rolling average"). That's a
    different window-function pattern; this method is focused on sequential
    period
    comparisons.
  • Multi-dimensional grouping inside the same period (e.g. "revenue per month
    per category"
    ). The method works on a single period axis; this could be
    extended later via a groupBy parameter.

At a glance

orders table:

id created_at revenue
1 2024-01-10 100
2 2024-01-20 200
3 2024-02-05 250
4 2024-02-18 50
5 2024-03-01 400
Order::query()->withSequentialPeriodMetrics(
    periodFormat: 'Y-m',
    aggregates: ['revenue', 'sum', 'total_revenue'],
    selectComparisonsOnly: false,
)->get();

Result:

period total_revenue total_revenue_previous_period total_revenue_change_percent
2024-01 300 NULL NULL
2024-02 300 300 0.00
2024-03 400 300 33.33

Plugging it into a chart

The returned rows can be passed directly to Chart.js / ApexCharts / ECharts /
Recharts. For instance, the example above can be drawn as
"bars = revenue, line = MoM % change" on the same chart:

xychart-beta
    title "Monthly Revenue and Month-over-Month % Change"
    x-axis ["2024-01", "2024-02", "2024-03"]
    y-axis "Revenue" 0 --> 500
    bar   [300, 300, 400]
    line  [0, 0, 33]
Loading

The bar series maps to total_revenue, the line series maps to
total_revenue_change_percent. In a real charting library you would
typically render the percent change on a secondary Y axis.

Wiring it up on the frontend stays trivial:

// rows coming from the API:
// [
//   { period: "2024-01", total_revenue: 300, total_revenue_change_percent: null },
//   { period: "2024-02", total_revenue: 300, total_revenue_change_percent: 0 },
//   { period: "2024-03", total_revenue: 400, total_revenue_change_percent: 33.33 },
// ]

const labels  = rows.map(r => r.period)
const revenue = rows.map(r => r.total_revenue)
const change  = rows.map(r => r.total_revenue_change_percent)

Highlights

  • Driver-agnostic: MySQL 8+, MariaDB 10.2+, PostgreSQL 11+, SQLite 3.25+,
    SQL Server 2012+. The correct date-formatting function (DATE_FORMAT,
    STRFTIME, TO_CHAR, FORMAT) is selected automatically.
  • Multiple aggregates, one query: revenue, cost, order_count… all
    computed together, each one getting its own LAG(...) previous-period
    column and _change / _change_percent columns.
  • Per-metric comparison type: pick percent for one metric, absolute
    difference for another, and skip comparisons entirely for a third.
  • Fluent Aggregate API: IDE-friendly, readable definitions like
    Aggregate::sum('revenue')->as('total_revenue')->precision(2).
  • Raw expressions & subqueries as the aggregate column —
    DB::raw('revenue * quantity') or a correlated subquery via a Closure.
  • Precision control: a method-level default plus a per-aggregate override.
  • Locale-style formatting: render values as "13,868,830.91" (US/EN),
    "13.868.830,91" (TR/DE), "13 868 830,91" (FR)… via PHP's
    number_format() — SQL stays purely numeric, formatting only happens at
    presentation time.
  • Safe by default: the date column is verified to actually be a date /
    datetime type via the schema; misuse is rejected with a clear error message.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant