sprain/swiss-qr-bill
Generate Swiss QR bills (QR-Rechnungen) in PHP with sprain/swiss-qr-bill. Build compliant QR-bill PDFs including payment parts, QR codes, references, and ISO 20022 data—works standalone or in Laravel. Simple API, Swiss QR standard support.
Install
composer require sprain/swiss-qr-bill
# Only needed for the printable payment part (the 210×105 mm slip):
composer require tecnickcom/tcpdf # or setasign/fpdf
The library itself is plain PHP (no Laravel service provider), so there is nothing to register. It pulls in symfony/validator for validation and endroid/qr-code for image generation.
Where to look first
README.md — the examples cover ~90% of real usage.src/QrBill.php — the aggregate you build up element by element.src/DataGroup/Element/ — CombinedAddress, StructuredAddress, CreditorInformation, PaymentAmountInformation, PaymentReference, AdditionalInformation.src/PaymentPart/Output/ — TcPdfOutput, FpdfOutput; these turn the object into the printable slip.src/Reference/ — helpers/validators for QR (mod-10 recursive) and RF/SCOR (mod-97) references.First use case: turn an invoice into a QR bill
use Sprain\SwissQrBill as QrBill;
$bill = QrBill\QrBill::create(); // header data group is created for you
$bill->setCreditor(
QrBill\DataGroup\Element\CombinedAddress::create(
'Acme AG', 'Bahnhofstrasse 1', '8001 Zürich'
)
);
$bill->setCreditorInformation(
QrBill\DataGroup\Element\CreditorInformation::create('CH4431999123000889012')
);
$bill->setDebtor(
QrBill\DataGroup\Element\CombinedAddress::create(
'Max Muster', 'Musterstrasse 12', '8000 Zürich'
)
);
$bill->setPaymentAmountInformation(
QrBill\DataGroup\Element\PaymentAmountInformation::create('CHF', 1250.00)
);
$bill->setPaymentReference(
QrBill\DataGroup\Element\PaymentReference::create(
QrBill\DataGroup\Element\PaymentReference::TYPE_QR,
'210000000003139471430009017'
)
);
$bill->setAdditionalInformation(
QrBill\DataGroup\Element\AdditionalInformation::create('Rechnung 2024-0042')
);
if (! $bill->isValid()) {
foreach ($bill->getViolations() as $v) {
logger()->warning($v->getPropertyPath().': '.$v->getMessage());
}
throw new RuntimeException('QR bill is not valid');
}
// QR code as PNG (or embed via getQrCode()->getImagePath() which returns a data URI)
$bill->getQrCode()->writeFile(storage_path('app/qr-bill.png'));
// Payment part as PDF
$output = new QrBill\PaymentPart\Output\TcPdfOutput\TcPdfOutput($bill, 'de');
$output->getPaymentPart()->Output(storage_path('app/payment-part.pdf'), 'F');
Three things to internalise early
QrBill object is a builder, not a value object — you assemble it with set*() calls and only then validate.de/fr/it/en) is chosen per render, so one invoice can produce a German and a French slip from the same object.Config-driven creditor + a builder service. Never scatter IBANs and company names through controllers. Put creditor data in config/swissqr.php, and wrap assembly in a single class:
// config/swissqr.php
return [
'creditor' => [
'name' => 'Acme AG',
'address' => ['Bahnhofstrasse 1', '8001 Zürich'],
'iban' => env('SWISSQR_IBAN'),
],
'default_language' => 'de',
];
final class QrBillFactory
{
public function __construct(private array $config) {}
public function forInvoice(Invoice $invoice): QrBill
{
$c = $this->config['creditor'];
$bill = QrBill\QrBill::create();
$bill->setCreditor(CombinedAddress::create($c['name'], ...$c['address']));
// ... map invoice/customer fields
return $bill;
}
}
Address mapping. Use CombinedAddress::create($name, $line1, $line2) when your customer model stores free-form address lines; use StructuredAddress::create($name, $street, $buildingNumber, $postalCode, $city, $country) when you actually have parsed fields. The debtor may be omitted entirely (open-amount / bearer bills), and PaymentAmountInformation::create('CHF', null) handles donation-style open amounts.
Pick a reference strategy once and encode it in the domain.
| Creditor account | Reference type | What the payer sees |
|---|---|---|
| QR-IBAN (IID 30000–31999) | TYPE_QR with valid mod-10 reference |
27-digit QR reference |
| Normal IBAN | TYPE_SCOR with valid RF reference |
RF… reference |
| Normal IBAN | TYPE_NON |
"No reference" + additional information |
Generate the check digit when the invoice is created (not at render time), store the full reference on the invoice row, and pass it verbatim. This keeps re-renders deterministic and testable.
Validation as a gate, not an afterthought. Call isValid() in a form request, command, or job before touching PDFs, and convert getViolations() into a 422/exception. Invalid objects will still render, which is the most common way a broken bill reaches a customer.
Compose the invoice PDF with FPDI. The slip must be exactly 210×105 mm and sit flush at the bottom of an A4 page (leaving the top 192 mm for invoice content). A typical merge:
$pdf = new \setasign\Fpdi\Tcpdf\Fpdi();
$pdf->setPrintHeader(false);
$pdf->AddPage();
$pdf->setSourceFile($invoicePath);
$pdf->useTemplate($pdf->importPage(1), 0, 0, 210, 297);
$pdf->setSourceFile($paymentPartPath);
$pdf->useTemplate($pdf->importPage(1), 0, 192, 210, 105);
$pdf->Output($finalPath, 'F');
Queue it. TCPDF is memory-hungry and PDF generation is slow; put it in a GenerateInvoicePdf job, write to S3/local disk, and attach the file to a Mailable. Make the job idempotent on invoice ID so a retry doesn't duplicate the bill.
Locale routing. Map Laravel locales to the output languages: de* → de, fr* → fr, it* → it, everything else en. Store the chosen language on the invoice so re-downloads match the original.
Testing. Build a fixture Invoice, assert isValid(), and snapshot either the generated PDF hash or the decoded QR payload. Cover the three reference combinations, an open amount, a debtor-less bill, and a name with umlauts — those catch 90% of regressions.
isValid() is not automatic. Building an object with a missing debtor, invalid IBAN, or wrong reference type succeeds; only validation reports it. Always gate rendering on it and log getPropertyPath().TYPE_SCOR/TYPE_NON fails validation, and a standard IBAN with TYPE_QR fails too. Don't assume any IBAN works with any reference.src/Reference/ for helpers) and store the finished string. If you compute them at render time you'll eventually ship an invoice whose reference no longer matches your database."1'250.00" and not a locale-formatted string. Max is 999999999.99; currencies are only CHF and EUR. Use integer cents in your domain and divide by 100 at the boundary to avoid float drift.StringModifier in src/String/ helps with trimming/line breaks) and never concatenate a full postal address into the name field.endroid/qr-code is a common source of Composer conflicts; ext-gd (or Imagick) is required for PNG output, and ext-mbstring/intl for TCPDF. Pin the library and re-run your PDF regression tests on upgrades — validator behaviour and output rendering do change between minor versions.memory_limit on queue workers, and don't generate PDFs synchronously inside a web request if you can avoid it.getPaymentPart()); anything the library doesn't expose (watermarks, extra pages, headers) you do on that object. For other PDF engines, implement OutputInterface — that's the intended extension point.How can I help you explore Laravel packages today?