Data sources

A source provides the records and describes their fields. It is report-local, knows about filters and sorting, and decides whether those are applied in SQL or in memory.

The ReportSource contract

Extend ReportSource. Only records() is required; every other method has a sensible default or is an optional hook.

records(): iterable Required. Returns the records as an array, collection, generator or Eloquent collection.
fields(): array A list of ReportField objects. It is the allow-list for filters, sorting and columns.
parameters(): array Declares runtime parameters as ReportParameter objects. Metadata for UI and validation; the engine does not forward them automatically.
key(): string Identity of the source. Defaults to the class name.
locale(): ?string · withLocale(?string $locale) Reads and sets the active locale. The engine sets it before the run.
filters(): ?FilterBag · withFilters(FilterBag|array $filters) Reads and sets the active filters. An array is converted to a FilterBag automatically.
sorts(): ?SortBag · withSorts(SortBag|array $sorts) Reads and sets the active sorting.
filterFields(): array Derives all filterable fields from fields() as FilterField objects.
applyFilters(array $records): array Protected helper for in-memory filtering. Uses the same operators as the query variant.
applySorts(array $records): array Protected helper for in-memory sorting against the field allow-list.
sortSpecs(): array Protected helper that returns the sorting as ORDER BY specifications for a database query.

The engine calls withLocale(), withFilters() and withSorts() before records(). A source may evaluate them, but does not have to: without evaluation, filtering and sorting have no effect.

Ready-made source types

The package ships five adapters. DatabaseSource is the shared base for Eloquent and the query builder and is not used directly.

new ArraySource($records = [], $fields = [], $parameters = []) In-memory array. Filters and sorting are applied in PHP; ideal for tests and computed data.
new CollectionSource($items = [], $fields = [], $parameters = []) In-memory collection or iterable. Behaves like ArraySource but also accepts lazy collections.
new EloquentSource(Model|Builder|class-string $source, $fields = [], $parameters = []) Eloquent model, builder or model class. Filters and sorting are pushed into the query; the rows stay models.
new QuerySource(Builder $query, $fields = [], $parameters = []) Laravel query builder, e.g. DB::table(). Filters become WHERE and sorting becomes ORDER BY.
new RawSqlSource(string $sql, array $bindings = [], $fields = [], $parameters = [], ?string $connection = null) Raw SQL with bindings. Filters and sorting are applied in memory after the fetch because arbitrary SQL cannot be rewritten safely.
use App\Models\Customer;
use Guggach\Reports\Sources\EloquentSource;
use Guggach\Reports\Sources\QuerySource;
use Guggach\Reports\Sources\RawSqlSource;
use Guggach\Reports\Sources\ReportField;

// The field DTOs are a normal PHP variable. They are optional for every
// source: without them there is no filter/sort allow-list.
$fields = [
    new ReportField('id', 'ID', type: 'id'),
    new ReportField('name', 'Name'),
    new ReportField('revenue', 'Revenue', type: 'number'),
    new ReportField('active', 'Active', type: 'boolean'),
];

// Eloquent: model, builder or model class name
new EloquentSource(
    Customer::query()->where('active', true),
    fields: $fields,
);

// Query builder: DB::table()
new QuerySource(
    DB::table('customers')->where('active', true),
    fields: $fields,
);

// Raw SQL: SQL and bindings stay developer-owned
new RawSqlSource(
    'select id, name, revenue from customers where active = ?',
    [1],
    fields: $fields,
    connection: 'reporting',
);
<?php

declare(strict_types=1);

namespace App\Reports\PriceList;

use Guggach\Reports\Sources\ReportField;
use Guggach\Reports\Sources\ReportSource;

/**
 * Data source of the price list. Report-local on purpose (spec 7.1): a source
 * is never shared between reports. Filters are applied in memory here; a
 * database-backed variant would push them down instead.
 */
final class Source extends ReportSource
{
    public function records(): iterable
    {
        return $this->applyFilters([
            ['sku' => 'A-100', 'name' => 'Kaffeemaschine', 'category' => 'Küche', 'supplier' => 'Nordwind AG', 'price' => 129.00],
            ['sku' => 'B-200', 'name' => 'Wasserkocher', 'category' => 'Küche', 'supplier' => 'Nordwind AG', 'price' => 39.90],
            ['sku' => 'C-300', 'name' => 'Toaster', 'category' => 'Büro', 'supplier' => 'Berger GmbH', 'price' => 59.50],
            ['sku' => 'D-400', 'name' => 'Mixer', 'category' => 'Büro', 'supplier' => 'Conti AG', 'price' => 89.00],
        ]);
    }

    public function fields(): array
    {
        return [
            new ReportField('sku', 'SKU', type: 'string'),
            new ReportField('name', 'Bezeichnung', type: 'string'),
            new ReportField('category', 'Kategorie', type: 'enum', options: ['Küche', 'Büro']),
            new ReportField('supplier', 'Lieferant', type: 'relation', options: [
                'Nordwind AG', 'Berger GmbH', 'Conti AG', 'Delta Handels',
                'Egger & Co', 'Fuchs AG', 'Graf GmbH', 'Huber AG',
            ]),
            new ReportField('price', 'Preis', type: 'number'),
        ];
    }
}

A custom in-memory source next to the three database adapters.

fields is optional — without DTOs there is no filtering and no sorting

The fields parameter defaults to []. Without a field list, fields() is empty: filterFields() returns nothing, the filter form stays empty, submitted filters are dropped and applySorts()/sortSpecs() produce no sorting. The report still renders all records because the Blade views read the record fields directly. The field list is an allow-list for filtering, sorting and UI metadata — it does not select database columns. $fields in the example is an ordinary PHP variable holding ReportField[]; you can also pass the DTOs directly to the constructor.

Describing fields

ReportField is an immutable DTO. It describes how a record field is named, displayed, filtered and sorted.

string $key Field name in the record, e.g. price.
string $label Display name in the filter form and column headings.
string $type = 'string' Field type. It controls the default widget and the available operators.
bool $sortable = true Allows this field in sorting.
bool $filterable = true Includes the field in the filter form and in filterFields().
bool $aggregatable = false Metadata for UI and exports. The engine does not derive aggregates from it.
bool $hidden = false Metadata to hide a field in the UI.
?string $format = null Optional formatting metadata such as currency or date.
array $options = [] Values for enum and relation fields. Either a list or a value/label map.

Field types and their defaults

The type determines the widget and the operators. FilterBuilder can override both per field.

string Text control with like, = and !=.
number · int · integer · decimal · float Number control with =, !=, <, <=, >, >=, between and in.
boolean · bool Boolean control with the = operator.
date · datetime Date control with =, before, after and between.
enum Select for up to six options, multiselect above that. Operator: in.
id · fk · relation Searchable picker (dual list) with the in operator.
json Only is_null and is_not_null.

Parameters

ReportParameter declares inputs that do not go through the filter allow-list — for example a fixed reporting date or a tenant.

string $name Parameter name.
string $type = 'string' Type for UI and validation.
bool $required = false Marks the parameter as required.
mixed $default = null Default value when the caller provides none.

Filtering, sorting and push-down

The source decides where filters and sorting are applied. That is the main performance difference between the adapters.

Engine → withFilters() → withSorts() → records() The engine sets locale, filters and sorting before it calls records().
DatabaseSource (Eloquent, Query) Filters become WHERE, sorting becomes ORDER BY. The database returns the finished set.
ArraySource, CollectionSource, RawSqlSource Records are fetched first and then filtered and sorted in PHP. Push-down is preferable for large data sets.

Grouping needs sorted data

Grouping is run-based: the engine compares consecutive records. Sort the source by the same keys as the groups — otherwise the same group appears multiple times.

The filter form

filterControls() returns a list of FilterControl descriptors. Every UI — Blade, Vue or React — renders the same form from them.

FilterBuilder::for(ReportSource|iterable $fields) Starts the builder from a source or directly from ReportField objects.
field(string $key) Selects a field for the following customisations.
control(string $control) Sets the widget: text, number, boolean, select, multiselect, date or picker.
label(string $label) Overrides the display label of the field.
operators(array $operators) Restricts or extends the operators. The first operator becomes the default.
options(array $options) Sets the choices for select, multiselect or picker.
multiple(bool $multiple = true) Allows multiple values.
wildcards(bool $wildcards = true) Enables wildcards in text filters.
required(bool $required = true) Marks the field as required.
picker(?string $valueField = 'id', ?string $labelField = 'label', ?string $search = null) Turns the field into a searchable dual-list picker for IDs or foreign keys.
text() · number() · boolean() · select() · multiselect() · date() Shortcuts for control().
only(array $keys) · except(array $keys) Keeps only certain controls or removes them from the form.
build(): array Returns the finished list of FilterControl objects.

Operators

Operators are validated and passed to the matching applier. Not every operator makes sense for every type.

= Equality.
!= Inequality.
< · <= · > · >= Greater/less comparisons for numbers and dates.
between Range with two values.
in Value list, used by multiselect and picker.
like Substring search in text fields.
before · after Date comparisons.
is_null · is_not_null Checks for empty values.
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();
}
<?php

declare(strict_types=1);

namespace App\Reports\PriceList;

use Guggach\Reports\Sources\ReportField;
use Guggach\Reports\Sources\ReportSource;

/**
 * Data source of the price list. Report-local on purpose (spec 7.1): a source
 * is never shared between reports. Filters are applied in memory here; a
 * database-backed variant would push them down instead.
 */
final class Source extends ReportSource
{
    public function records(): iterable
    {
        return $this->applyFilters([
            ['sku' => 'A-100', 'name' => 'Kaffeemaschine', 'category' => 'Küche', 'supplier' => 'Nordwind AG', 'price' => 129.00],
            ['sku' => 'B-200', 'name' => 'Wasserkocher', 'category' => 'Küche', 'supplier' => 'Nordwind AG', 'price' => 39.90],
            ['sku' => 'C-300', 'name' => 'Toaster', 'category' => 'Büro', 'supplier' => 'Berger GmbH', 'price' => 59.50],
            ['sku' => 'D-400', 'name' => 'Mixer', 'category' => 'Büro', 'supplier' => 'Conti AG', 'price' => 89.00],
        ]);
    }

    public function fields(): array
    {
        return [
            new ReportField('sku', 'SKU', type: 'string'),
            new ReportField('name', 'Bezeichnung', type: 'string'),
            new ReportField('category', 'Kategorie', type: 'enum', options: ['Küche', 'Büro']),
            new ReportField('supplier', 'Lieferant', type: 'relation', options: [
                'Nordwind AG', 'Berger GmbH', 'Conti AG', 'Delta Handels',
                'Egger & Co', 'Fuchs AG', 'Graf GmbH', 'Huber AG',
            ]),
            new ReportField('price', 'Preis', type: 'number'),
        ];
    }
}

The price list filter form and the matching source with field types and options.

Next · Groups & aggregates Nest groups, define headers and footers, and compute sums, averages and counts.