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. |