Zum Hauptinhalt springen
Schneespur
Dokumentation durchblättern

Module entwickeln

PDF & Berichte

Wie Module PDFs erzeugen und eigene Berichtsformate beisteuern — über die PdfRendererRegistry und die ReportFormatRegistry, mit lauffähigen Beispielen.

Berichte sind in einem Winterdienst-System kein Nebenschauplatz: Ein PDF-Einsatznachweis oder ein CSV-Export ist oft das, was am Ende beim Kunden oder in der Akte landet. Schneespur trennt dabei zwei Dinge sauber — das Rendern eines PDFs aus einem Template und das Format eines Berichts. Beide sind über Registries austauschbar, sodass ein Modul eine eigene Render-Engine oder ein zusätzliches Dateiformat beisteuern kann, ohne den Core anzufassen.

PDF-Rendering

Das PdfRendererInterface

Ein PDF-Renderer nimmt einen View-Namen samt Daten entgegen und gibt das fertige PDF als rohe Binärzeichenkette zurück. Mehr verlangt das Interface nicht:

interface PdfRendererInterface
{
    public function slug(): string;
    public function label(): string;

    /**
     * @param array{paper?: string, orientation?: string, isRemoteEnabled?: bool,
     *              footer?: array{left: string, right: string}} $options
     * @return string Raw PDF binary
     */
    public function render(string $view, array $data, array $options = []): string;

    /** @return string Raw PDF binary with footer */
    public function renderFooter(string $html, string $leftText, string $rightText): string;
}

Der Vorteil dieser schmalen Schnittstelle: Wer ein PDF braucht, muss nicht wissen, welche Engine es erzeugt. Der Aufrufer arbeitet immer gegen PdfRendererInterface, der konkrete Renderer ist austauschbar.

Der Core-Renderer: DomPDF

Standardmäßig registriert der Core den DomPdfRenderer, der auf dem Paket dompdf/dompdf aufsetzt. DomPDF braucht keine externen Binaries und läuft damit auf gewöhnlichem Webhosting — das ist der Grund, warum es die Voreinstellung ist. Ein Modul kann diesen Renderer ohne weitere Einrichtung nutzen.

PDF im eigenen Modul verwenden

Den aktiven Renderer lösen Sie über die PdfRendererRegistry auf und übergeben ihm einen Blade-View plus die Daten:

$renderer = app(PdfRendererRegistry::class)->resolve();

$pdf = $renderer->render('my-module::reports.invoice', [
    'customer' => $customer,
    'items' => $items,
    'total' => $total,
], [
    'paper' => 'A4',
    'orientation' => 'portrait',
    'footer' => [
        'left' => brand() . ' — Rechnung',
        'right' => 'Seite {PAGE_NUM} von {PAGE_COUNT}',
    ],
]);

return response($pdf)
    ->header('Content-Type', 'application/pdf')
    ->header('Content-Disposition', 'attachment; filename="invoice-' . $customer->id . '.pdf"');

Zwei Details lohnen den Blick: brand() liefert den markenneutralen Anzeigenamen der Installation, sodass derselbe Code unter Schneespur wie unter Wintertrace passt. Und die Platzhalter {PAGE_NUM} / {PAGE_COUNT} im Footer werden beim Rendern durch die echte Seitenzahl ersetzt.

Render-Optionen

Das options-Array steuert das Seitenlayout:

OptionTypStandardBedeutung
paperstring'A4'Papierformat (A4, Letter usw.)
orientationstring'portrait''portrait' oder 'landscape'
isRemoteEnabledboolfalseExterne Bilder laden zulassen
footerarray{left: string, right: string} für den Fußzeilentext

isRemoteEnabled steht bewusst auf false: Externe Bilder nachzuladen kostet Zeit und öffnet eine Tür nach außen. Schalten Sie es nur ein, wenn Ihr Template tatsächlich entfernte Grafiken braucht.

Einen eigenen PDF-Renderer als Modul bauen

Reicht DomPDF nicht, kann ein Modul einen alternativen Renderer beisteuern — etwa einen, der das Binary wkhtmltopdf nutzt. Sie implementieren dasselbe Interface:

namespace Schneespur\Module\WkPdf\Pdf;

use App\Services\Pdf\PdfRendererInterface;

class WkhtmltopdfRenderer implements PdfRendererInterface
{
    public function slug(): string { return 'wkhtmltopdf'; }
    public function label(): string { return 'wkhtmltopdf'; }

    public function render(string $view, array $data, array $options = []): string
    {
        $html = view($view, $data)->render();
        // Use wkhtmltopdf binary to convert HTML to PDF
        // ...
        return $pdfBinary;
    }

    public function renderFooter(string $html, string $leftText, string $rightText): string
    {
        // ...
    }
}

Weil der Renderer hinter dem Interface steht, ändert sich für die aufrufende Stelle nichts — sie ruft weiterhin render() auf. Die Registrierung läuft wie bei allen Registries über den ServiceProvider des Moduls; die genaue API steht unter Registries.

Berichtsformate

Das Rendern erzeugt ein PDF. Ein Berichtsformat geht eine Ebene höher: Es beschreibt, in welcher Datei-Form ein bestimmter Berichtstyp ausgegeben wird — als PDF, CSV, oder etwas Eigenem.

Das ReportFormatInterface

interface ReportFormatInterface
{
    public function slug(): string;
    public function label(): string;

    /** @return string[] Report types this format supports */
    public function supportedReportTypes(): array;

    /** @return string File content */
    public function generate(string $reportType, mixed $subject, array $params = []): string;

    public function mimeType(): string;
    public function fileExtension(): string;
}

supportedReportTypes() ist der Grund, warum nicht jedes Format jeden Bericht beherrschen muss: Ein Format meldet selbst, welche Berichtstypen (etwa job, customer, object) es bedienen kann. Die Oberfläche bietet dann nur die sinnvollen Kombinationen an.

Die Core-Formate

Der Core bringt zwei Formate mit:

SlugFormatUnterstützte Berichtstypen
pdfPDFjob, customer, object
csvCSVjob, customer, object

Ein eigenes Berichtsformat bauen

Ein Modul kann ein weiteres Format ergänzen — zum Beispiel Excel. Sie implementieren ReportFormatInterface und liefern den Dateiinhalt als Zeichenkette zurück:

namespace Schneespur\Module\Excel\Report;

use App\Services\Report\ReportFormatInterface;

class ExcelReportFormat implements ReportFormatInterface
{
    public function slug(): string { return 'xlsx'; }
    public function label(): string { return 'Excel (XLSX)'; }

    public function supportedReportTypes(): array
    {
        return ['job', 'customer', 'driver'];
    }

    public function generate(string $reportType, mixed $subject, array $params = []): string
    {
        // Use PhpSpreadsheet or similar to generate Excel content
        // Return raw file content as string
    }

    public function mimeType(): string
    {
        return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
    }

    public function fileExtension(): string
    {
        return 'xlsx';
    }
}

Registriert wird das Format über die ReportFormatRegistry:

app(ReportFormatRegistry::class)->register('xlsx', ExcelReportFormat::class);

Danach taucht „Excel (XLSX)” überall dort als Auswahl auf, wo ein unterstützter Berichtstyp exportiert wird — ohne dass eine Core-Datei geändert wurde.

Vorhandene Export-Infrastruktur

Ein Teil der Export-Logik steckt bereits im Core. CsvExportController und CustomerPdfController übernehmen die Auslieferung, und der PortalPdfController lässt Kunden im Portal ihre eigenen Berichte erzeugen. Darauf aufbauend hat ein Modul drei Wege:

  1. Neue Formate (Excel, XML usw.) über die ReportFormatRegistry ergänzen.
  2. Neue Berichtstypen (Fahrer-Übersicht, Fahrzeug-Protokoll usw.) über eigene Controller bereitstellen.
  3. Bestehende Berichte erweitern, indem es per Filter-Hook zusätzliche Daten einspeist — siehe Filter-Hooks.

Blade-Templates für PDFs

Ein praktischer Stolperstein zum Schluss: DomPDF lädt externe CSS-Dateien nicht zuverlässig. Blade-Views für die PDF-Ausgabe sollten ihre Stile deshalb inline im <style>-Block mitbringen statt über ein verlinktes Stylesheet:

{{-- resources/views/reports/invoice.blade.php --}}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <style>
        body { font-family: DejaVu Sans, sans-serif; font-size: 12px; }
        table { width: 100%; border-collapse: collapse; }
        th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; }
        th { background: #f5f5f5; }
        .total { font-weight: bold; text-align: right; }
    </style>
</head>
<body>
    <h1>Rechnung — {{ $customer->name }}</h1>
    <table>
        <thead>
            <tr><th>Position</th><th>Beschreibung</th><th>Betrag</th></tr>
        </thead>
        <tbody>
            @foreach($items as $item)
                <tr>
                    <td>{{ $loop->iteration }}</td>
                    <td>{{ $item->description }}</td>
                    <td>{{ number_format($item->amount / 100, 2, ',', '.') }} €</td>
                </tr>
            @endforeach
        </tbody>
    </table>
    <p class="total">Gesamt: {{ number_format($total / 100, 2, ',', '.') }} €</p>
</body>
</html>

Die Schriftart DejaVu Sans ist hier kein Zufall: Sie bringt die nötigen Glyphen für deutsche Umlaute und das Euro-Zeichen mit, die in vielen Standard-PDF-Schriften fehlen.