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.