In Entwicklung

Druckfertige Reports, direkt aus Laravel Blade.

Seiten- und Bandmodell, Gruppen mit Summen, Filterformulare, Live-Vorschau und PDF-Export — in eigenständigen Report-Modulen, paginiert von Chromium.

  • Flow- & Strict-Paginierung
  • Views direkt beim Report
  • HTML & PDF aus einer Quelle
Preisliste

Alles, was ein gedruckter Report braucht

Das Paket deckt den Teil ab, den Laravel auslässt: ein Seiten- und Bandmodell mit verlässlicher, wiederholbarer Paginierung.

Band- & Seitenmodell

Seitenkopf und -fuss, Tabellenköpfe, Detailbänder, verschachtelte Gruppen und Aggregate.

Flow & Strict

Im Flow-Modus paginiert Chromium; im Strict-Modus werden Bänder mit fixer Höhe arithmetisch platziert.

Eigenständige Module

Report-Definition, Datenquelle und Blade-Views liegen gemeinsam in einem Ordner unter app/Reports.

Filter & Vorschau

Typisierte Filter-Controls, ein zweiphasiges Formular und ein Vorschau-Frame mit Zoom und Druck.

Geteilte Layouts

Corporate Identity als wiederverwendbares Layout — Briefkopf, Farben und Typografie über alle Reports.

HTML & PDF

Dasselbe HTML speist die Browser-Vorschau und den PDF-Export über einen austauschbaren Contract.

Was genau ist ein Band?

Die beschriftete Preisliste zeigt, was pro Seite, pro Gruppe und pro Datensatz gerendert wird.

Bänder erklärt

Live ansehen, Code lesen

Jedes Beispiel ist ein echter Report, der in diesem Playground läuft. Der gezeigte Code wird direkt aus den Dateien gelesen, die die Vorschau erzeugen.

Alle Beispiele

Preisliste · Gruppierte Liste mit Zwischensummen

Ein Report-Start-Band mit dem Titel, ein Tabellenkopf, ein Detailband, eine Gruppe pro Kategorie mit eigenem Kopf und Fuss, eine summierte Zwischensumme, ein Report-Ende-Band und ein Filterformular.

Gruppen Aggregate Filter Flow
Preisliste
<?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');
        });
    }
}

Lieferschein · Strict-Platzierung

Bänder mit fixer Höhe, arithmetisch über die Seiten verteilt, mit wiederholtem Tabellenkopf und Seitenzahlen.

Strict Paginierung
Lieferschein
<?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);
    }
}

Etikettenbogen · N-up Detailband

Ein Detailband über die volle Breite, in vier Zellen pro Zeile aufgeteilt: 30 Etiketten über zwei Seiten, mit der Zeile als Paginationseinheit.

Strict N-up
Etikettenbogen
<?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);
    }
}

Rechnung · Free-form-Formular mit Einzahlungsschein

Eine vollständige Rechnung pro Datensatz, aufgebaut aus Page-Blocks mit deklarierten Höhen. Der Swiss-QR-Einzahlungsschein bleibt auf Seite 1, wenn er Platz hat, und rutscht auf Seite 2, wenn die Rechnungspositionen den Platz brauchen.

Strict Free-form Paginierung
Rechnung
<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>

Autokaufvertrag · Free-form-Flow mit Allgemeinen Bedingungen

Ein Autokaufvertrag im Flow-Modus. Der Browser paginiert den Fliesstext; ein CSS-Seitenumbruch startet die Allgemeinen Bedingungen auf Seite 2.

Flow Free-form
Autokaufvertrag
<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>

Datenquellen · SQLite-basierte Source-Adapter

Ein Report, fünf Source-Adapter: QuerySource, EloquentSource, RawSqlSource, CollectionSource und ArraySource lesen dieselbe SQLite-Tabelle. Der Adapter lässt sich im Filterformular wählen.

Flow Filter Aggregate
Datenquellen
<?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';
    }
}

Mehrsprachige Preisliste · Sprache und Format im Frame-Header wählen

Die Sprache lässt sich direkt im Header der Vorschau umschalten. Report-Texte kommen aus dem Modul-lang-Verzeichnis, Datum und Zahlen folgen der Format-Locale; die Währung bleibt CHF.

Flow Sprache Aggregate
Mehrsprachige Preisliste
<?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',
    ],
];

Gruppierter Umsatz mit Enrichment · Gruppendaten aus einem Eloquent-Model

Die Detailzeilen kommen aus demo_orders. Jeder Gruppenkopf lädt Kategoriename und Ziel über enrichWith() aus dem Eloquent-Model demo_categories; der Name wird pro Sprache über einen Accessor aufgelöst.

Flow Gruppen Sprache Aggregate Enrichment
Gruppierter Umsatz mit 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();
    }
}

In drei Schritten zum Report

Von einer frischen Laravel-App zur paginierten Vorschau.

1

Paket installieren

Das Paket wird als Composer-Library verteilt und registriert seinen Service Provider automatisch.

composer require guggach/laravel-reports
2

Report anlegen

Ein Report ist ein eigenständiges Modul unter app/Reports/<Name>: die Report-Definition, die Datenquelle und die Blade-Views. Kein Service Container, keine Registrierung — das Modul wird über seinen Ordner entdeckt.

Modul-Struktur

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

Dateien

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

Vorschau öffnen

Reports werden über ihren Key angesprochen. Die Vorschau-, Dokument- und PDF-Route registriert das Paket für dich.