Groups & aggregates

Groups split the sorted records into sections with their own header and footer. Aggregates condense the values of a group or the whole report into sums, averages and counts.

Defining groups

A group is one or more key fields plus a callback for header, footer and aggregates. The order of the definitions determines the nesting depth.

group(string|array $key, ?Closure $callback = null) Creates a group and returns a GroupBuilder. The callback runs immediately.
string|array $key A field name or a list of fields. Multiple fields are combined into a composite group key.
$group->group($key, $callback) Nests a child group. The engine flattens groups in pre-order; the position defines the level.
Level 1 … n The outermost group is level 1. Every level can have its own header, footer and aggregates.
$r->group('category', function ($category): void {
    $category->header(view: 'category-head');
    $category->footer(view: 'category-foot');
    $category->aggSum('price', as: 'category_total');

    // Second level: a group inside a group.
    $category->group('supplier', function ($supplier): void {
        $supplier->header(view: 'supplier-head');
        $supplier->aggSum('price', as: 'supplier_total');
    });
});

// Composite key: one group per category + currency combination.
$r->group(['category', 'currency'], function ($group): void {
    $group->header(view: 'group-head');
});
<?php

declare(strict_types=1);

namespace App\Reports\PriceList;

use App\Reports\Layout\CompanyLayout;
use Guggach\Reports\Definition\ReportBuilder;
use Guggach\Reports\Filters\FilterBuilder;
use Guggach\Reports\Report as BaseReport;
use Guggach\Reports\Sources\ReportSource;

final class Report extends BaseReport
{
    public function key(): string
    {
        return 'price_list';
    }

    public function name(): string
    {
        return 'Preisliste';
    }

    public function layout(): string
    {
        return CompanyLayout::class;
    }

    public function source(): ReportSource
    {
        return new Source;
    }

    /**
     * Demonstrate the fluent control description: operator listbox for the
     * number, multiselect for the enum and a searchable dual-list for the
     * relation (id/FK) field.
     */
    public function filterControls(): array
    {
        return FilterBuilder::for($this->source())
            ->field('price')->operators(['=', '>=', '<=', 'between', 'in'])
            ->field('category')->multiselect()
            ->field('supplier')->picker(valueField: 'id', labelField: 'label')
            ->build();
    }

    public function define(ReportBuilder $r): void
    {
        // Module-relative view names resolve to app/Reports/PriceList/views/*.
        // Report start/end run once per report; the grid header introduces the
        // table and the detail band renders one row per record.
        $r->reportStart(view: 'report-start');
        $r->gridHeader(view: 'grid-head');
        $r->detail(view: 'row');
        $r->reportEnd(view: 'report-end');

        // Grouped by category (data is sorted by category) with a subtotal.
        $r->group('category', function ($g): void {
            $g->header(view: 'category-head');
            $g->footer(view: 'category-foot');
            $g->aggSum('price', as: 'category_total');
        });
    }
}

A flat group from the price list and an example with two levels and a composite key.

Group header and footer

A group header runs when a new key begins; the footer runs when the group ends. Both are full-width bands and receive the RenderContext.

header($view = null, ?Closure $closure = null) Group header. Runs once per group, before the first record of the group.
footer($view = null, ?Closure $closure = null) Group footer. Runs once per group after the last record has been processed. The group aggregates are available here.
height() · visibility · pages … Header and footer are regular bands and support the same options as page, report and detail bands.
<tr>
    <td colspan="3" style="padding-top:10px; font-weight:700; border-bottom:1px solid #0f766e; color:#0f766e">
        {{ $ctx->currentGroup()['key'] }}
    </td>
</tr>
<tr style="font-weight:600; background:#f3f4f6">
    <td colspan="2">Summe {{ $ctx->currentGroup()['key'] }}</td>
    <td style="text-align:right">{{ number_format((float) $ctx->aggregates['category_total'], 2, '.', "'") }}</td>
</tr>

Header and footer of the price list category group. The footer reads the group sum.

Aggregates

An aggregate collects values while records stream through and returns a value at the end. Group aggregates apply per group, report aggregates across the whole report.

Aggregates per group

Sums have shortcuts; every other type goes through aggregate() with a spec array.

aggSum(string $field, ?string $as = null) Sums a field. Without a name the aggregate is called <field>_sum, e.g. price_sum.
sum(string $field, ?string $as = null) Alias for aggSum().
aggregate(array $aggregate) Generic group aggregate with type, field and as. Example: ['type' => 'avg', 'field' => 'price', 'as' => 'avg_price'].

Supported aggregate types

The type decides how values are collected. Non-numeric values are ignored depending on the type.

sum Sum of all numeric values. Starts at 0.
avg Average of the numeric values. Zero when nothing was counted.
count Counts all non-null values. Without a field it counts every record.
countDistinct · count_distinct Counts distinct scalar values.
min · max Smallest or largest value. Null values are skipped.

Aggregates per report

Report aggregates are declared in the builder with a name and an aggregate class. They are available in the report end and while the detail rows render.

aggregate(string $name, string $class, ?string $field = null, string $scope = 'report') Declares a report aggregate. The name is the key in $ctx->aggregates.
Sum::class · Avg::class · Count::class · CountDistinct::class · Min::class · Max::class The classes live under Guggach\Reports\Aggregates. Without a field, Count counts the records.
string $scope = 'report' Aggregate scope: report for the whole report or group:1 … group:n for one group level. Invalid scopes and missing levels throw during build().
use Guggach\Reports\Aggregates\Avg;
use Guggach\Reports\Aggregates\Count;
use Guggach\Reports\Aggregates\Max;
use Guggach\Reports\Aggregates\Sum;

public function define(ReportBuilder $r): void
{
    // ...

    // Report-wide: available in the report end band (and cumulatively
    // while the detail rows are rendered).
    $r->aggregate('grand_total', Sum::class, field: 'price');
    $r->aggregate('average_price', Avg::class, field: 'price');
    $r->aggregate('position_count', Count::class);
    $r->aggregate('highest_price', Max::class, field: 'price');

    $r->group('category', function ($g): void {
        // Group-wide: available in the group footer.
        $g->aggSum('price', as: 'category_total');
        $g->aggregate(['type' => 'count', 'field' => 'sku', 'as' => 'category_count']);
    });
}
{{-- Group footer: group + report aggregates --}}
<tr>
    <td colspan="2">Summe {{ $ctx->currentGroup()['key'] }}</td>
    <td style="text-align:right">
        {{ $ctx->currency($ctx->aggregates['category_total']) }}
    </td>
</tr>

{{-- Report end: report aggregates --}}
<p>{{ $ctx->total }} Positionen,
   Gesamt {{ $ctx->currency($ctx->aggregates['grand_total']) }}</p>

Report aggregates in the builder and how to read them in the Blade views.

Aggregate scope: report or group

The scope decides which resolver collects the values. Report aggregates run across the whole data set; group aggregates reset when the group opens and are complete in the group footer.

scope: 'report' Report-wide. Available in the report end and while detail rows render; the report start and group header only see the running value so far.
scope: 'group:1' … 'group:n' Belongs to group level n. Complete in the group footer, still empty in the group header. Equivalent to $g->aggSum() for plain sums.
$r->aggregate(... group:1) ≡ $g->aggSum(...) Both paths attach the aggregate to the same GroupDefinition. The builder derives the level from the scope.
build() validation Unknown scopes, a group:N without a matching level and duplicate names in the same level throw an InvalidArgumentException — no silent fallback to a report aggregate.
$r->group('category', function ($g): void {
    $g->header(view: 'category-head');
    $g->footer(view: 'category-foot');
});

// Group-scoped aggregate declared on the report builder.
// Equivalent to $g->aggSum('amount', as: 'category_total').
$r->aggregate(
    'category_total',
    Sum::class,
    field: 'amount',
    scope: 'group:1',
);

// Report-scoped aggregate.
$r->aggregate('grand_total', Sum::class, field: 'amount', scope: 'report');

The same group aggregate declared through the GroupBuilder and through the report builder with scope: group:1.

Which aggregates are available where

Not every band sees every value. The table shows what $ctx->aggregates contains per band.

Group header Report aggregates up to the previous record; the aggregates of this group start at zero.
Detail band Report aggregates including the current record plus the aggregates of the deepest open group.
Group footer Report aggregates plus the complete aggregates of this group.
Report start No aggregates yet — the first record has not been processed.
Report end All report aggregates across the complete data set.

Sorting and group breaks

The engine groups by runs: it compares the key of the current record with the previous one. A group break is therefore a change in the sorted order.

Sort the source by the group keys

Deliver the records already sorted by the group keys — for example with orderBy() in the query or applySorts() in the source. With unsorted data the same group appears repeatedly with a new header and footer.

Group enrichment

enrichWith() loads additional data exactly once per group — master data, addresses or targets that would otherwise be duplicated in every detail row through a join. The engine passes the group keys as filters and runs the source in the run locale.

enrichWith(ReportSource|string $source) Registers an enrichment source per group level. Accepts an instance or a class/container name.
once per group The source runs when the group opens, not per detail row. If the same group starts again (unsorted data), it runs again.
$this->filters() The group keys arrive as a FilterBag, e.g. ['category' => 'Miete']. The source decides whether to push the filters or read them itself.
$ctx->groupData() · currentGroup()['data'] The first record returned by the source is available in the group header and footer; null when nothing matches.
EloquentSource + spatie/laravel-translatable Because the source runs inside the LocaleScope, the model returns its attributes in the run language. A separate group locale is not needed.
ReportField(type: 'enum'|'id') Declare the field as enum or id (operator in) for an exact group key. type: string would use like.
use App\Reports\GroupEnrichment\CategoryInfo;

$r->group('category', function ($g): void {
    $g->header(view: 'category-head');
    $g->footer(view: 'category-foot');
    $g->enrichWith(CategoryInfo::class);
});
use App\Models\DemoCategory;
use Guggach\Reports\Sources\EloquentSource;
use Guggach\Reports\Sources\ReportField;
use Guggach\Reports\Sources\ReportSource;

final class CategoryInfo extends ReportSource
{
    public function records(): iterable
    {
        return (new EloquentSource(
            DemoCategory::class,
            [new ReportField('category', 'Kategorie', type: 'enum')],
        ))
            ->withFilters($this->filters() ?? [])
            ->withLocale($this->locale())
            ->records();
    }
}
@php($info = $ctx->groupData())

<div class="group-head">
    <strong>{{ $info?->name ?? $ctx->currentGroup()['key'] }}</strong>
    <span>Ziel {{ $ctx->currency($info->target) }}</span>
</div>

The grouped revenue report loads category name and target from an Eloquent model; the view reads them through $ctx->groupData().

Groups stay in the run locale

There is deliberately no per-group language. Translated group data comes from the enrichment source: it runs inside the LocaleScope, so Eloquent with Spatie Translatable returns the name in the selected report language. The unfinished localeUsing() API on the GroupBuilder was removed.

Not implemented yet: top-down

drivesWith() and link() are prepared as an API, but top-down group sources follow in v2. The builder rejects drivesWith() during build() with a clear exception instead of silently ignoring the definition.

drivesWith(string $source) Let a source drive the iteration (top-down) instead of the detail source. Not implemented in v1 and rejected during build().
link(array $map) Mapping between group keys and fields of the group source. Currently only stored for the later top-down variant.