Browse the documentation
Building modules
PDF & reports
Generate PDFs and reports from a module: hooking into the reporting pipeline and shaping the output.
Reports are not peripheral in a winter service system: a PDF job record or a CSV export is often what ends up with the customer or in the file. The application separates two concerns cleanly — rendering a PDF from a template and the format of a report. Both are swappable through registries, so a module can contribute a custom render engine or an additional file format without touching any core file.
PDF rendering
The PdfRendererInterface
A PDF renderer receives a view name and data and returns the finished PDF as a raw binary string. That is all the interface requires:
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;
}
The advantage of this narrow interface: code that needs a PDF does not need to know which engine produces it. The caller always works against PdfRendererInterface; the concrete renderer is interchangeable.
The core renderer: DomPDF
By default the core registers DomPdfRenderer, which uses the dompdf/dompdf package. DomPDF requires no external binaries and runs on ordinary web hosting — that is why it is the default. A module can use this renderer with no additional setup.
Using PDF in your module
Resolve the active renderer through the PdfRendererRegistry and pass it a Blade view along with the data:
$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"');
Two details worth noting: brand() returns the brand-neutral display name for the installation, so the same code works correctly under both Schneespur and Wintertrace. And the {PAGE_NUM} / {PAGE_COUNT} placeholders in the footer are replaced by the real page numbers during rendering.
Render options
The options array controls the page layout:
| Option | Type | Default | Description |
|---|---|---|---|
paper | string | 'A4' | Paper size (A4, Letter, etc.) |
orientation | string | 'portrait' | 'portrait' or 'landscape' |
isRemoteEnabled | bool | false | Allow loading external images |
footer | array | — | {left: string, right: string} for footer text |
isRemoteEnabled is intentionally set to false: loading external images adds latency and opens an outbound connection. Enable it only if your template genuinely needs remote graphics.
Building a custom PDF renderer module
If DomPDF is not sufficient, a module can contribute an alternative renderer — for example one that uses the wkhtmltopdf binary. You implement the same 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
{
// ...
}
}
Because the renderer sits behind the interface, calling code does not change — it continues to call render(). Registration follows the same pattern as all registries, through the module’s ServiceProvider; the full API is under Registries.
Report formats
Rendering produces a PDF. A report format sits one level above: it describes in which file form a particular report type is output — as PDF, CSV, or something custom.
The 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() is the reason a format does not need to handle every report type: a format declares which report types (such as job, customer, object) it supports. The UI then only presents the combinations that make sense.
Core formats
The core ships with two formats:
| Slug | Format | Supported report types |
|---|---|---|
pdf | job, customer, object | |
csv | CSV | job, customer, object |
Building a custom report format
A module can add a further format — Excel, for instance. Implement ReportFormatInterface and return the file content as a string:
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';
}
}
Register the format through the ReportFormatRegistry:
app(ReportFormatRegistry::class)->register('xlsx', ExcelReportFormat::class);
After registration, “Excel (XLSX)” appears as an option wherever a supported report type can be exported — without any core file being changed.
Existing export infrastructure
Part of the export logic already lives in the core. CsvExportController and CustomerPdfController handle delivery, and PortalPdfController lets customers generate their own reports in the portal. Building on this, a module has three paths:
- Add new formats (Excel, XML, etc.) via the
ReportFormatRegistry. - Add new report types (driver summary, vehicle log, etc.) through custom controllers.
- Extend existing reports by injecting additional data via filter hooks — see Filter & hooks.
Blade templates for PDFs
One practical gotcha: DomPDF does not load external CSS reliably. Blade views for PDF output should include their styles inline in a <style> block rather than via a linked 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>
The DejaVu Sans font family is not chosen at random: it includes the glyphs needed for characters — such as umlauts and the Euro sign — that are missing from many standard PDF fonts.