Work in progress

Print-ready reports, straight from Laravel Blade.

Page and band model, groups with aggregates, filter forms, a live preview and PDF export — in self-contained report modules, paginated by Chromium.

  • Flow & strict pagination
  • Views live next to the report
  • HTML & PDF from one source
Price list

Everything a printed report needs

The package covers the part Laravel leaves out: a page and band model with reliable, repeatable pagination.

Band & page model

Page headers and footers, grid headers, detail bands, nested groups and aggregates.

Flow & strict

Chromium paginates in flow mode; strict mode places fixed-height bands arithmetically.

Self-contained modules

Report definition, data source and Blade views live together in one folder under app/Reports.

Filters & preview

Typed filter controls, a two-phase form and a preview frame with zoom and print.

Shared layouts

Corporate identity as a reusable layout — letterhead, colours and typography across reports.

HTML & PDF

The same HTML drives the browser preview and the PDF export through a swappable contract.

What exactly is a band?

The annotated price list shows what renders once per page, once per group and once per record.

Bands explained

See it live, read the code

Each example is a real report running in this playground. The code shown is read from the very files that produce the preview.

All examples

Price list · Grouped list with subtotals

A report start band with the title, a grid header, a detail band, one group per category with its own header and footer, a summed subtotal, a report end band and a filter form.

Groups Aggregates Filters Flow
Price list
<?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');
        });
    }
}

Packing list · Strict placement

Fixed-height bands placed arithmetically across pages, with a repeated grid header and page numbers.

Strict Pagination
Packing list
<?php

declare(strict_types=1);

namespace App\Reports\PackingList;

use Guggach\Reports\Definition\PageSetup;
use Guggach\Reports\Definition\ReportBuilder;
use Guggach\Reports\Definition\ReportMode;
use Guggach\Reports\Report as BaseReport;
use Guggach\Reports\Sources\ReportSource;

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

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

    public function layout(): string
    {
        return 'default';
    }

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

    public function define(ReportBuilder $r): void
    {
        $r->mode(ReportMode::Strict);
        $r->pageSetup(PageSetup::a4()->portrait()->marginsMm(20, 15, 20, 15));

        // Module-relative view names resolve to app/Reports/PackingList/views/*.
        $r->pageHeader(view: 'header')->height(20);
        $r->gridHeader(view: 'grid-head')->height(8);
        $r->detail(view: 'row', height: 8);
        $r->pageFooter(view: 'footer')->height(10);
    }
}

Label sheet · N-up detail band

One full-width detail band split into four cells per row: 30 labels across two pages, with the row as the unit of pagination.

Strict N-up
Label sheet
<?php

declare(strict_types=1);

namespace App\Reports\LabelSheet;

use Guggach\Reports\Definition\PageSetup;
use Guggach\Reports\Definition\ReportBuilder;
use Guggach\Reports\Definition\ReportMode;
use Guggach\Reports\Report as BaseReport;
use Guggach\Reports\Sources\ReportSource;

/**
 * N-up demo: one full-width detail band split into four label cells per row
 * (docs/spec.md 6.7). Strict mode makes the row the pagination unit.
 */
final class Report extends BaseReport
{
    public function key(): string
    {
        return 'label_sheet';
    }

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

    public function layout(): string
    {
        return 'default';
    }

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

    public function define(ReportBuilder $r): void
    {
        $r->mode(ReportMode::Strict);
        $r->pageSetup(PageSetup::a4()->portrait()->marginsMm(16, 12, 16, 12));

        $r->pageHeader(view: 'header')->height(14);
        $r->pageFooter(view: 'footer')->height(8);

        // One row holds four labels; height is the height of the whole row.
        // A row occupies 32 mm + 2 mm row gap = 34 mm; 7 rows (238 mm) fit
        // into the 243 mm body of an A4 page => 28 labels.
        $r->detail(view: 'label', height: 32)
            ->columns(4, gap: 3, rowGap: 2);
    }
}

Invoice · Free-form form with payment slip

One complete invoice per record, built from page blocks with declared heights. The Swiss QR payment slip stays on page 1 when it fits and moves to page 2 when the invoice positions need the space.

Strict Free-form Pagination
Invoice
<x-reports::page-block :page="$page" :height="0">
<style>
    .invoice-brand {
        display: flex;
        justify-content: space-between;
        align-items: flex-end;
        border-bottom: 3px solid #0f766e;
        padding-bottom: 4mm;
        height: 100%;
        box-sizing: border-box;
    }
    .invoice-brand__name { font-size: 24px; font-weight: 800; letter-spacing: .02em; color: #0f766e; }
    .invoice-brand__meta { text-align: right; font-size: 9px; line-height: 1.5; color: #475569; }

    .invoice-address { font-size: 10px; line-height: 1.55; color: #1f2937; padding-top: 6mm; box-sizing: border-box; }
    .invoice-address__label { margin-bottom: 3mm; font-size: 9px; color: #64748b; text-transform: uppercase; letter-spacing: .08em; }

    .invoice-title { display: flex; justify-content: space-between; align-items: flex-end; border-bottom: 1px solid #cbd5e1; padding-bottom: 3mm; box-sizing: border-box; }
    .invoice-title h1 { margin: 0; font-size: 22px; line-height: 1; color: #0f172a; }
    .invoice-title__meta { text-align: right; font-size: 10px; line-height: 1.5; color: #475569; }

    .invoice-table-head,
    .invoice-table-row,
    .invoice-total {
        display: grid;
        grid-template-columns: minmax(0, 1fr) 34mm 24mm 28mm 26mm 16mm;
        align-items: center;
        column-gap: 2mm;
        box-sizing: border-box;
    }
    .invoice-table-head {
        border-bottom: 2px solid #0f766e;
        font-size: 8px;
        font-weight: 700;
        line-height: 1.25;
        color: #0f172a;
    }
    .invoice-table-row {
        border-bottom: 1px solid #e2e8f0;
        font-size: 8px;
        line-height: 1.3;
        color: #334155;
        overflow: hidden;
    }
    .invoice-table-row__description { font-weight: 600; color: #0f172a; }
    .invoice-table-row__period { color: #64748b; font-size: 7px; }
    .invoice-table-row .right,
    .invoice-table-head .right { text-align: right; }

    .invoice-total {
        display: flex;
        justify-content: space-between;
        align-items: center;
        border-top: 2px solid #0f172a;
        border-bottom: 1px solid #cbd5e1;
        font-size: 11px;
        font-weight: 700;
    }
    .invoice-total__value { text-align: right; }
    .invoice-note { padding-top: 3mm; font-size: 8px; line-height: 1.5; color: #64748b; }

    .payment-slip {
        box-sizing: border-box;
        height: 100%;
        overflow: hidden;
        background: #fff;
    }
    .payment-slip img { display: block; width: 100%; height: 100%; object-fit: fill; }
</style>
</x-reports::page-block>

<x-reports::page-block :page="$page" :height="24">
    <div class="invoice-brand">
        <div>
            <div class="invoice-brand__name">Acme AG</div>
            <div class="invoice-brand__meta" style="text-align:left">Apartments · Immobilien · IfN</div>
        </div>
        <div class="invoice-brand__meta">
            Beispielstrasse 1<br>
            8000 Zürich<br>
            info@acme.example
        </div>
    </div>
</x-reports::page-block>

<x-reports::page-block :page="$page" :height="36">
    <div class="invoice-address">
        <div class="invoice-address__label">Rechnung für</div>
        <strong>{{ $ctx->customer }}</strong><br>
        @foreach ($ctx->address as $line)
            {{ $line }}<br>
        @endforeach
        <span style="color:#64748b">MWST Nr.: {{ $ctx->vat_number }}</span>
    </div>
</x-reports::page-block>

<x-reports::page-block :page="$page" :height="16">
    <div class="invoice-title">
        <h1>Rechnung</h1>
        <div class="invoice-title__meta">
            {{ $ctx->invoice_number }}<br>
            {{ $ctx->invoice_date }}
        </div>
    </div>
</x-reports::page-block>

<x-reports::page-block :page="$page" :height="8">
    <div class="invoice-table-head">
        <span>Produkt / Beschreibung</span>
        <span>von – bis</span>
        <span>Menge</span>
        <span>Preis</span>
        <span>Betrag</span>
        <span class="right">MWST</span>
    </div>
</x-reports::page-block>

@foreach ($ctx->positions as $position)
    <x-reports::page-block :page="$page" :height="10">
        <div class="invoice-table-row">
            <div>
                <div class="invoice-table-row__description">{{ $position['description'] }}</div>
                <div class="invoice-table-row__period">{{ $position['period'] }}</div>
            </div>
            <span>{{ $position['period'] }}</span>
            <span>{{ $position['quantity'] }}</span>
            <span>{{ number_format($position['unit_price'], 2, '.', "'") }} / Einheit</span>
            <span>{{ number_format($position['total'], 2, '.', "'") }}</span>
            <span class="right">{{ $position['vat'] }}%</span>
        </div>
    </x-reports::page-block>
@endforeach

<x-reports::page-block :page="$page" :height="14">
    <div class="invoice-total">
        <span class="invoice-total__label">Rechnungstotal</span>
        <span class="invoice-total__value">CHF {{ number_format($ctx->invoice_total, 2, '.', "'") }}</span>
    </div>
</x-reports::page-block>

<x-reports::page-block :page="$page" :height="10">
    <div class="invoice-note">
        MWST Betrag CHF {{ number_format($ctx->invoice_vat_total, 2, '.', "'") }} · {{ $ctx->due }}
    </div>
</x-reports::page-block>

<x-reports::page-block :page="$page" :height="105" anchor="bottom">
    <div class="payment-slip">
        <img src="{{ asset('images/docs/swissqr-payment-slip.png') }}" alt="Swiss QR Einzahlungsschein">
    </div>
</x-reports::page-block>

Car purchase contract · Free-form flow with general terms

A car purchase contract in Flow mode. The browser paginates the prose, and a CSS page break starts the general terms on page 2.

Flow Free-form
Car purchase contract
<style>
    .contract {
        font-size: 10px;
        line-height: 1.55;
        color: #1f2937;
    }
    .contract-brand {
        display: flex;
        justify-content: space-between;
        align-items: flex-end;
        border-bottom: 3px solid #0f766e;
        padding-bottom: 4mm;
        margin-bottom: 8mm;
    }
    .contract-brand__name { font-size: 24px; font-weight: 800; color: #0f766e; letter-spacing: .02em; }
    .contract-brand__meta { text-align: right; font-size: 9px; line-height: 1.5; color: #475569; }

    .contract-title { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 7mm; }
    .contract-title h1 { margin: 0; font-size: 22px; line-height: 1.1; }
    .contract-title__meta { text-align: right; font-size: 9px; line-height: 1.5; color: #475569; }

    .contract-party-grid,
    .contract-vehicle-grid {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 6mm 12mm;
    }
    .contract-party-grid { margin-bottom: 7mm; }
    .contract-party { border-top: 1px solid #cbd5e1; padding-top: 3mm; }
    .contract-label { margin-bottom: 1mm; font-size: 8px; text-transform: uppercase; letter-spacing: .08em; color: #64748b; }

    .contract-section-title {
        margin: 7mm 0 2mm;
        font-size: 11px;
        font-weight: 700;
        color: #0f172a;
        border-bottom: 1px solid #e2e8f0;
        padding-bottom: 1mm;
    }
    .contract-table { width: 100%; border-collapse: collapse; font-size: 9px; }
    .contract-table th,
    .contract-table td { padding: 2mm 0; border-bottom: 1px solid #e2e8f0; text-align: left; vertical-align: top; }
    .contract-table th { width: 38mm; color: #64748b; font-weight: 400; }
    .contract-price { font-size: 13px; font-weight: 700; }

    .contract-signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 20mm; margin-top: 14mm; }
    .contract-signature { border-top: 1px solid #64748b; padding-top: 2mm; font-size: 8px; color: #64748b; }

    .contract-terms-head {
        display: flex;
        justify-content: space-between;
        align-items: flex-end;
        border-bottom: 2px solid #0f766e;
        padding-bottom: 3mm;
        margin-bottom: 7mm;
    }
    .contract-terms-head h2 { margin: 0; font-size: 18px; }
    .contract-terms-head__meta { text-align: right; font-size: 9px; color: #64748b; }
    .contract-clause { break-inside: avoid; page-break-inside: avoid; margin-bottom: 5mm; }
    .contract-clause h3 { margin: 0 0 1.5mm; font-size: 11px; }
    .contract-clause p { margin: 0 0 2mm; orphans: 3; widows: 3; }

    @media screen {
        /* Flow has one continuous sheet on screen; mark the forced print break. */
        .contract-page-break { border-top: 2px dashed #94a3b8; padding-top: 8mm; }
    }
</style>

<article class="contract">
    <header class="contract-brand">
        <div>
            <div class="contract-brand__name">Acme Garage AG</div>
            <div class="contract-brand__meta" style="text-align:left">Automobile · Service · Finanzierung</div>
        </div>
        <div class="contract-brand__meta">
            Beispielstrasse 1<br>
            8000 Zürich<br>
            garage@acme.example
        </div>
    </header>

    <div class="contract-title">
        <h1>Autokaufvertrag</h1>
        <div class="contract-title__meta">
            Vertrag Nr. {{ $ctx->contract_number }}<br>
            {{ $ctx->contract_date }}
        </div>
    </div>

    <div class="contract-party-grid">
        <div class="contract-party">
            <div class="contract-label">Verkäuferin</div>
            <strong>{{ $ctx->seller['name'] }}</strong><br>
            {{ $ctx->seller['street'] }}<br>
            {{ $ctx->seller['city'] }}<br>
            {{ $ctx->seller['commercial_register'] }}
        </div>
        <div class="contract-party">
            <div class="contract-label">Käufer</div>
            <strong>{{ $ctx->buyer['name'] }}</strong><br>
            {{ $ctx->buyer['street'] }}<br>
            {{ $ctx->buyer['city'] }}
        </div>
    </div>

    <h2 class="contract-section-title">1. Fahrzeug</h2>
    <table class="contract-table">
        <tr><th>Marke / Modell</th><td>{{ $ctx->vehicle['make'] }} {{ $ctx->vehicle['model'] }}</td></tr>
        <tr><th>Jahrgang</th><td>{{ $ctx->vehicle['year'] }}</td></tr>
        <tr><th>Fahrgestellnummer</th><td>{{ $ctx->vehicle['vin'] }}</td></tr>
        <tr><th>Kilometerstand</th><td>{{ $ctx->vehicle['mileage'] }}</td></tr>
        <tr><th>Farbe</th><td>{{ $ctx->vehicle['colour'] }}</td></tr>
        <tr><th>Kontrollschild</th><td>{{ $ctx->vehicle['plate'] }}</td></tr>
    </table>

    <h2 class="contract-section-title">2. Kaufpreis und Zahlung</h2>
    <table class="contract-table">
        <tr><th>Kaufpreis inkl. MWST</th><td class="contract-price">CHF {{ number_format($ctx->price, 2, '.', "'") }}</td></tr>
        <tr><th>Anzahlung bei Vertragsabschluss</th><td>CHF {{ number_format($ctx->deposit, 2, '.', "'") }}</td></tr>
        <tr><th>Restbetrag bei Übergabe</th><td>CHF {{ number_format($ctx->price - $ctx->deposit, 2, '.', "'") }}</td></tr>
        <tr><th>Übergabe</th><td>{{ $ctx->delivery }}</td></tr>
    </table>

    <h2 class="contract-section-title">3. Besondere Vereinbarungen</h2>
    <p>Das Fahrzeug wird im aktuellen Zustand verkauft. Die Verkäuferin bestätigt, dass keine offenen Leasing- oder Pfandrechte bestehen. Service und Hauptuntersuchung werden vor der Übergabe durchgeführt. Der Käufer ist mit der Übermittlung der Fahrzeugdaten an die zuständige Zulassungsstelle einverstanden.</p>

    <div class="contract-signatures">
        <div class="contract-signature">Ort, Datum / Verkäuferin</div>
        <div class="contract-signature">Ort, Datum / Käufer</div>
    </div>
</article>

<x-reports::page-break />

<section class="contract-page-break">
    <div class="contract-terms-head">
        <h2>Allgemeine Bedingungen</h2>
        <div class="contract-terms-head__meta">
            Autokaufvertrag Nr. {{ $ctx->contract_number }}<br>
            {{ $ctx->seller['name'] }} · {{ $ctx->buyer['name'] }}
        </div>
    </div>

    @include('contract_example::terms')
</section>

Data sources · SQLite-backed source adapters

One report, five source adapters: QuerySource, EloquentSource, RawSqlSource, CollectionSource and ArraySource all read the same SQLite table. Pick the adapter in the filter form.

Flow Filters Aggregates
Data sources
<?php

declare(strict_types=1);

namespace App\Reports\DatabaseSources;

use App\Models\DemoOrder;
use Guggach\Reports\Aggregates\Count;
use Guggach\Reports\Aggregates\Sum;
use Guggach\Reports\Definition\ReportBuilder;
use Guggach\Reports\Filters\FilterBuilder;
use Guggach\Reports\Filters\FilterControl;
use Guggach\Reports\Report as BaseReport;
use Guggach\Reports\Sources\ArraySource;
use Guggach\Reports\Sources\CollectionSource;
use Guggach\Reports\Sources\EloquentSource;
use Guggach\Reports\Sources\QuerySource;
use Guggach\Reports\Sources\RawSqlSource;
use Guggach\Reports\Sources\ReportField;
use Guggach\Reports\Sources\ReportSource;
use Illuminate\Support\Facades\DB;

/**
 * SQLite-backed demo: the same report can be rendered through every source
 * adapter. Pick the source in the generated filter form; the report start band
 * shows which adapter is active.
 */
final class Report extends BaseReport
{
    /** @var array<string, string> */
    private const SOURCE_LABELS = [
        'query' => 'QuerySource · DB::table()',
        'eloquent' => 'EloquentSource · Model',
        'raw_sql' => 'RawSqlSource · SQL + Bindings',
        'collection' => 'CollectionSource · Collection',
        'array' => 'ArraySource · Array',
    ];

    public function key(): string
    {
        return 'database_sources';
    }

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

    public function layout(): string
    {
        return 'default';
    }

    public function source(): ReportSource
    {
        $fields = $this->dataFields();

        $source = match ($this->selectedSource()) {
            'eloquent' => new EloquentSource(DemoOrder::class, $fields),
            'raw_sql' => new RawSqlSource('select * from demo_orders', [], $fields),
            'collection' => new CollectionSource(DemoOrder::all(), $fields),
            'array' => new ArraySource(DemoOrder::all()->toArray(), $fields),
            default => new QuerySource(DB::table('demo_orders'), $fields),
        };

        return $source
            ->withFilters($this->filters())
            ->withSorts(['amount' => 'desc']);
    }

    /**
     * @return list<FilterControl>
     */
    public function filterControls(): array
    {
        $sourceControl = new FilterControl(
            key: 'source',
            label: 'Quelle',
            control: 'select',
            type: 'enum',
            operators: ['='],
            defaultOperator: '=',
            options: self::SOURCE_LABELS,
            required: true,
        );

        return [
            $sourceControl,
            ...FilterBuilder::for($this->dataFields())->build(),
        ];
    }

    public function define(ReportBuilder $r): void
    {
        $r->reportStart(view: 'report-start');
        $r->gridHeader(view: 'grid-head');
        $r->detail(view: 'row');
        $r->reportEnd(view: 'report-end');

        $r->aggregate('total', Sum::class, field: 'amount', scope: 'report');
        $r->aggregate('orders', Count::class, field: 'order_number', scope: 'report');
    }

    /**
     * @return array<string, string>
     */
    public static function sourceLabels(): array
    {
        return self::SOURCE_LABELS;
    }

    /**
     * @return list<ReportField>
     */
    private function dataFields(): array
    {
        return [
            new ReportField('order_number', 'Auftrag'),
            new ReportField('customer', 'Kunde'),
            new ReportField('category', 'Kategorie', type: 'enum', options: [
                'Miete' => 'Miete',
                'Service' => 'Service',
                'Verkauf' => 'Verkauf',
            ]),
            new ReportField('amount', 'Betrag', type: 'number', aggregatable: true),
            new ReportField('ordered_at', 'Datum', type: 'date'),
            new ReportField('status', 'Status', type: 'enum', options: [
                'offen' => 'offen',
                'bezahlt' => 'bezahlt',
            ]),
        ];
    }

    private function selectedSource(): string
    {
        $raw = $this->filters()['source'] ?? 'query';

        if (is_array($raw)) {
            $raw = $raw['value'] ?? $raw[0] ?? 'query';
        }

        return is_string($raw) && array_key_exists($raw, self::SOURCE_LABELS) ? $raw : 'query';
    }
}

Multilingual price list · Pick language and format in the frame header

Switch the language directly in the preview header. Report texts come from the module lang directory; dates and numbers follow the format locale, while the currency stays CHF.

Flow Language Aggregates
Multilingual price list
<?php

declare(strict_types=1);

return [
    'name' => 'Mehrsprachige Preisliste',
    'title' => 'Preisliste',
    'intro' => 'Sprache und Formatierung folgen der Auswahl im Header. Die Währung bleibt unabhängig davon CHF.',
    'language_label' => 'Sprache',
    'format_label' => 'Format',
    'columns' => [
        'product' => 'Produkt',
        'category' => 'Kategorie',
        'valid_from' => 'Gültig ab',
        'status' => 'Status',
        'price' => 'Preis',
    ],
    'summary' => [
        'positions' => 'Positionen',
        'total' => 'Gesamttotal',
    ],
    'field' => [
        'product' => 'Produkt',
        'category' => 'Kategorie',
        'price' => 'Preis',
        'valid_from' => 'Gültig ab',
        'status' => 'Status',
    ],
];

Grouped revenue with enrichment · Group data from an Eloquent model

The detail rows come from demo_orders. Every group header loads its translated category name and target through enrichWith() from the demo_categories Eloquent model.

Flow Groups Language Aggregates Enrichment
Grouped revenue with enrichment
<?php

declare(strict_types=1);

namespace App\Reports\GroupEnrichment;

use App\Models\DemoCategory;
use Guggach\Reports\Sources\EloquentSource;
use Guggach\Reports\Sources\ReportField;
use Guggach\Reports\Sources\ReportSource;

/**
 * Enrichment source: one Eloquent query per group. The engine passes the group
 * key as a filter (category = …), so the model's translated name accessor
 * runs in the active report locale.
 */
final class CategoryInfo extends ReportSource
{
    public function records(): iterable
    {
        return (new EloquentSource(
            DemoCategory::class,
            [
                // "enum" makes the group key an exact match (operator "in").
                new ReportField('category', 'Kategorie', type: 'enum'),
            ],
        ))
            ->withFilters($this->filters() ?? [])
            ->withLocale($this->locale())
            ->records();
    }
}

Three steps to a report

From a fresh Laravel app to a paginated preview.

1

Install the package

The package is distributed as a Composer library and registers its service provider automatically.

composer require guggach/laravel-reports
2

Create a report

A report is a self-contained module under app/Reports/<Name>: the report definition, its data source and the Blade views. No service container, no registration — the module is discovered by its folder.

Module structure

app/Reports/PriceList/
├── Report.php              # bands, groups, filters
├── Source.php              # data + field definitions
└── views/
    ├── report-start.blade.php
    ├── grid-head.blade.php
    ├── row.blade.php
    ├── category-head.blade.php
    ├── category-foot.blade.php
    └── report-end.blade.php

Files

<?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');
        });
    }
}
<?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'),
        ];
    }
}
<h1 style="margin:0 0 12px">{{ $ctx->report->name() }}</h1>
3

Open the preview

Reports are addressed by their key. The package registers the preview, the document and the PDF route for you.