Templates and design
This page explains how Quittio prints an invoice or a document (quote, reminder …) as PDF or HTML, and how you change its look. Read it when you want your own logo, colours, texts or layout. The first sections need no template knowledge; the view model reference at the end is for writing own templates. The design only affects the PDF and HTML, never the e-invoice XML (What an e-invoice is).
Design levels
You can change the design on four levels. Start with level 1 and go further only when it is not enough.
| Level | Use it when you want to | What you do |
|---|---|---|
| 1 – Configuration | Set logo, colours, font, texts, footer, sender line, letter layout, paper or QR code | Set pdf.* in config/quittio.php, or pass a Presentation per invoice or per call (Logo, colours and texts) |
| 2 – Partials | Change one part of the default template, e.g. the payment block | php artisan vendor:publish --tag=quittio-views and change single partials (Changing single partials) |
| 3 – Own template | Use a layout of your own | php artisan make:quittio-template my-design, then ->template('invoices.my-design.layout') or pdf.template (Own templates) |
| 4 – Own renderer | Create the PDF with another engine, e.g. Browsershot or Gotenberg | Quittio::extend('renderer', …) (PDF renderers); PDF/A and the embedded XML stay with the package |
PDF/A is the PDF variant for long-term archiving. A ZUGFeRD/Factur-X invoice is a PDF/A-3 file with the XML embedded (Formats).
Templates only display data. Quittio prepares totals, VAT, the texts of the payment terms, QR codes and labels in the view model before the template runs. The same data also produces the XML. After rendering, the mandatory field guard checks that the template printed every value an invoice must show.
Default templates
Quittio ships one default design in two template engines. Both print the same document.
| Engine | Template | Used by |
|---|---|---|
| Blade | quittio::templates.default.layout with partials in quittio::templates.default.partials |
Laravel (pdf.template) |
| Plain PHP | packages/core/resources/templates/default.php |
Quittio::create() without Laravel (template default); Laravel falls back to it for names that are no Blade view |
The PDF is created by mPDF, a PHP library that converts the HTML of the template into a PDF (PDF renderers). To see the result with your configuration, render the sample invoices with php artisan quittio:preview or open the preview route (HTML output and preview).
Page layout
The default templates print A4 pages (Paper size) with a left margin of 20 mm and a right margin of 19 mm, in the font Geist (Fonts). The issuer is the party that sends the document: the seller, or the buyer on a self-billed invoice (German “Gutschrift”, see German terms).
- First page: the logo (at most 14 mm high) or the issuer’s name in the primary colour at the top left, 15 mm from the top edge. Below it is the address field at the position of the letter layout: the sender line at its top and the recipient directly below. The invoice details (number, dates, references …) are on the right at the same height. Then follow the title, the line table, the totals with the gross total (or the amount due) in the primary colour, and the payment block.
- Following pages: a header with the logo or name on the left, and the title with number, the recipient with customer number and the invoice date on the right. The line table continues below the header with its column heads.
- Every page: the footer with up to four columns (address; phone and e-mail; register and legal information; VAT identifier and tax number) and the page number.
Which elements use which colour is listed in Design.
Carry-over across pages
When the line table continues on the next page, the default templates print a running total at the break:
- The last row of the page reads “Continued on page 2”, “Carried forward” and the sum of the line amounts so far. The German output is “Fortsetzung auf Seite 2” (continued on page 2) and “Übertrag” (carried forward).
- The first row of the next page reads “Brought forward from page 1” (German “Übertrag von Seite 1”) with the same amount.
The mPDF renderer does this. It lays the document out once. Only if the line rows land on more than one page, it lays the document out again with space reserved for the carry rows, finds the page breaks, splits the table there and inserts the rows. Single-page documents are rendered once. Documents without prices (delivery notes) get no carry-over.
Own templates can use the same mechanism (Carry-over in own templates).
Logo, colours and texts (level 1)
Most designs need only configuration. Set the defaults for all invoices in config/quittio.php (Configuration):
// config/quittio.php
'pdf' => [
'logo' => storage_path('app/logo.png'), // PNG, JPEG or SVG
'colors' => ['primary' => '#0f766e', 'text' => '#15171c', 'muted' => '#5f6470', 'secondary' => null],
'font' => 'geist', // or 'inter', or a registered font
'texts' => ['salutation' => null, 'intro' => 'Thank you for your order.', 'closing' => 'Kind regards'],
'sender_line' => null, // null: generated from the seller
'footer_columns' => [], // []: generated from the seller
],
To change the design of one invoice, call ->presentation() on the builder. The design is stored with the invoice data, so the invoice looks the same when it is rendered again. To change it for one render call only, e.g. for a second brand, call ->presentation() on Quittio::make():
use Quittio\Core\Data\Presentation\Colors;
use Quittio\Core\Data\Presentation\Presentation;
use Quittio\Laravel\Facades\Quittio;
$invoice = Quittio::invoice()
// ...
->presentation(['introText' => 'Thank you for your order.', 'letterLayout' => 'din5008a']) // this invoice
->build();
$result = Quittio::make($invoice)
->presentation(new Presentation(logo: storage_path('app/brand-b.png'), colors: new Colors(primary: '#7c3aed'))) // this call
->render();
The invoice’s own presentation wins over the presentation of the call, which wins over the configuration. The rules are in Presentation.
| You want to change | Setting | Details |
|---|---|---|
| Logo | pdf.logo, Presentation::$logo |
Logos |
| Colours | pdf.colors, Presentation::$colors |
Presentation, Design |
| Font | pdf.font, pdf.fonts |
Fonts |
| Salutation, intro and closing text | pdf.texts.* |
Texts and Markdown |
| Footer columns and sender line | pdf.footer_columns, pdf.sender_line |
Presentation, Design |
| Position of the address field, fold marks | pdf.layout, pdf.fold_marks |
Letter layouts |
| Paper size | pdf.paper |
Paper size |
| Payment QR code | pdf.payment_qr.* |
Payment QR codes |
| Single printed texts, e.g. “Amount due” | Presentation::$labels, language files |
Translations and locales |
| Title of the document | Presentation::$title |
Presentation |
| Draft marker | option watermark |
Watermark |
Presentation
Quittio\Core\Data\Presentation\Presentation holds everything that only affects the PDF and HTML. It is never written to the XML.
| Property | Default | Effect | Laravel configuration |
|---|---|---|---|
template |
null |
Template of this invoice (Selecting a template) | pdf.template |
logo |
null |
Logo (Logos) | pdf.logo |
colors |
null |
Colors(primary: '#2741d9', text: '#15171c', muted: '#5f6470', secondary: null) |
pdf.colors.primary, text, muted, secondary |
font |
null (geist) |
Font family (Fonts) | pdf.font |
senderLine |
null (generated) |
Sender line above the address | pdf.sender_line |
letterLayout |
null (country default) |
Letter layout | pdf.layout |
salutation |
null |
Plain text before the intro | pdf.texts.salutation |
introText, closingText |
null |
Markdown texts before the lines and at the end | pdf.texts.intro, pdf.texts.closing |
footerColumns |
[] (generated) |
list<list<string>>, one list of lines per column |
pdf.footer_columns |
lineGroups |
[] |
list<LineGroup> (id, title, showSubtotal = true) |
– (InvoiceBuilder::group()) |
showGrossPrices |
false |
Print the price including VAT below the net unit price | – |
showItemNumbers |
false |
Print the seller’s item number (BT-155) after the item name | pdf.show_item_numbers |
paymentQr |
auto |
Payment QR code: auto, girocode, swiss-qr-bill, none |
pdf.payment_qr.type |
watermark |
null |
Watermark text | – (option watermark) |
paper |
A4 |
Paper size | pdf.paper |
labels |
[] |
Label overrides, e.g. ['amount_due' => 'Total to pay'] |
– |
title |
null |
Title instead of the type code label; also used in the file name and the PDF title | – |
explicit |
[] |
Properties whose value wins even when it equals the default | – |
BT-155 and the other BT numbers name the fields of the European standard EN 16931 (Business terms).
Hints for single lines are LinePresentation objects (groupId, highlight, showGrossPrice). The line builder sets them:
LineBuilder::group($groupId)puts the line into a line group.highlight()shades the line.showGrossPrice(?bool $show = true)prints the price including VAT of this line, or withfalsenot, whatevershowGrossPricessays.nullfollows the presentation.
use Quittio\Core\Builder\Line;
use Quittio\Core\Data\Presentation\Colors;
use Quittio\Core\Data\Presentation\Presentation;
use Quittio\Laravel\Facades\Quittio;
$invoice = Quittio::invoice()
// ...
->presentation(['logo' => storage_path('app/logo.png'), 'introText' => 'Thank you for your order.'])
->group('hardware', 'Hardware') // line group with subtotal
->line(Line::make('Router', netPrice: '89.00', quantity: 2)->group('hardware')) // group(): Line::make()
->build();
Quittio::make($invoice)
->presentation(new Presentation(colors: new Colors(primary: '#0f766e'), labels: ['amount_due' => 'Total to pay']))
->render();
How presentations are merged. The design of a run is built property by property from three layers: the invoice presentation over the presentation of the call (->presentation(...)) over the configured design.
- A value that equals the default of an empty
Presentation(false,[],'auto','A4',null) counts as not set and keeps the value of the layer below. - Objects such as
colorsreplace the value below as a whole. - Labels are merged key by key.
- To reset a configured value for one invoice, name the property in
explicit. Example:new Presentation(showGrossPrices: false, paper: 'A4', footerColumns: [], explicit: ['showGrossPrices', 'paper', 'footerColumns'])switches gross prices off, prints on A4 althoughpdf.paperis Letter, and prints no footer columns.
Checks. Values are checked when rendering; invalid values throw a ConfigurationException:
- colours must be hex values (
#2741d9or#fff); - fonts must be plain family names (letters, digits, space,
_and-, at most 64 characters); - letter layouts and payment QR code types must be known.
An unknown pdf.payment_qr.type already fails when the Laravel configuration is mapped (first use of the facade).
Render options. Some design settings are render options: per call with ->option(), without Laravel also in the options key of Quittio::create(). They are fold_marks (bool, default true), markdown (bool), paper (string), watermark (string), girocode_amount (due, discounted) and logo_cache (directory).
Logos
Set the logo with pdf.logo or Presentation::$logo. The default templates print it at the top left of the first page and in the header of the following pages.
- Logos are local files, data URIs or
http/httpsURLs of PNG, JPEG or SVG images, at most 10 MB and 25 megapixels. Other stream wrappers (ftp://,php://,phar://…), a leading//, backslashes and surrounding whitespace fail with aConfigurationException, as does a local file that does not exist. - A data URI is decoded strictly, checked and re-encoded, so only validated bytes reach the renderer.
- Remote logos are downloaded by Quittio on every render (no cache), with a 10 s timeout and at most 3 redirects, each checked again. Quittio downloads only from hosts whose every IPv4 and IPv6 address is public: no loopback, private, link-local, shared, reserved, documentation or multicast range, also not inside NAT64 addresses. The connection is pinned to the checked address, and TLS is verified for the host name. A refused address fails with a
ConfigurationException; an unreachable logo is left out. The downloaded copy is embedded as data URI (also in HTML output). - Raster logos larger than 600 × 200 px are scaled down with GD (transparency is kept). Scaled copies of local files are cached in the logo cache:
pdf.cache_path/logos, without Laraveltemp_dir/logos, defaultsys_get_temp_dir()/quittio-mpdf/logos. - For PDF/A (ZUGFeRD/Factur-X, XRechnung in PDF, or
pdf.pdfa = true), every logo is re-encoded as RGB PNG on white, because PDF/A forbids the alpha channel mPDF writes. SVG is rasterised with the PHP extensionimagick(SVG coder only, 300 dpi but at most twice the logo size). Withoutimagick, an SVG logo fails with aConfigurationExceptionin PDF/A documents. - SVG logos must be self-contained: no
hreforurl()to files, URLs or nested SVG (embedded PNG, JPEG, GIF or WebP data URIs are allowed) and no entity declarations. - mPDF never fetches anything itself (PDF renderers).
- In HTML output, a local logo is referenced by its file path, which browsers cannot load. Use a URL or data URI for logos in HTML that is shown in a browser.
- Local logo paths and URLs are trusted configuration. Do not take
Presentation::$logoor issuer branding from end users unless your application validates them (e.g. an uploaded file stored by the application).
Fonts
Two fonts are bundled (SIL Open Font License):
| Family | Files | Script |
|---|---|---|
geist (default, pdf.font = 'geist') |
Regular, Bold, Italic, Bold Italic; geistmedium with Medium and SemiBold |
Latin and Cyrillic |
inter |
Regular, Bold, Italic, Bold Italic | Latin, Cyrillic and Greek |
Set pdf.font = 'inter' for Greek names or texts: mPDF prints no characters the font does not have. The font name is used as CSS font-family in the templates and as default font of mPDF.
Register more fonts in the pdf section of config/quittio.php:
'font' => 'corporate',
'fonts' => ['corporate' => ['R' => 'Corporate-Regular.ttf', 'B' => 'Corporate-Bold.ttf', 'I' => 'Corporate-Italic.ttf', 'BI' => 'Corporate-BoldItalic.ttf']],
'font_directories' => [resource_path('fonts')],
- Use lower case family names, as mPDF looks fonts up in lower case.
- Fonts must be embeddable TrueType fonts (PDF/A embeds all fonts).
php artisan vendor:publish --tag=quittio-fontscopies Geist and Inter toresources/fonts/quittio/geistandresources/fonts/quittio/inter.- The Laravel renderer searches
pdf.font_directories, thenresources/fonts/quittioand its subdirectories automatically. Fonts placed there only need an entry inpdf.fonts. - Font files are taken from the first directory that has them. The configured directories come before the bundled fonts and those of mPDF, so a published and edited font replaces the bundled one.
- Without Laravel, pass the fonts to the renderer:
new MpdfRenderer(tempDir: …, fonts: [...], fontDirectories: [...], defaultFont: 'corporate').
Letter layouts
A letter layout fixes the address field on the first page, so that the recipient appears in the window of a window envelope. It is a Quittio\Core\View\LetterLayout with these values in mm (defaults in parentheses): name, addressTop (45), addressLeft (20), addressWidth (85), addressHeight (45), senderLineHeight (5), foldMarks ([]), punchMark (null) and addressZoneTop (17.7). LetterLayout::free() is the layout without norm.
The layout is Presentation::$letterLayout (pdf.layout), otherwise the country pack default, otherwise free. Country packs provide layouts with letterLayouts(); an unknown name fails with a ConfigurationException listing the known layouts.
The German pack provides layouts after DIN 5008, the German standard for business letters:
| Layout | Address field from the top | Fold marks | Punch mark |
|---|---|---|---|
din5008a (form A) |
27 mm | 87 and 192 mm | 148.5 mm |
din5008b (form B, default) |
45 mm | 105 and 210 mm | 148.5 mm |
free |
45 mm (layout default) | none | none |
The default templates print the sender line at the top of the address field and the recipient directly below it, both inside the window of a window envelope. DIN 5008 divides the address field into zones; own templates can use them (design.addressZoneTop, design.noticeZoneHeight, see Design). Fold and punch marks are printed at the left edge unless pdf.fold_marks (option fold_marks) is false. See Country packs.
Paper size
The paper is the first of:
- the invoice presentation’s
paper, the presentation of the call and the configured presentation, each only when it is notA4or namespaperinexplicit; - the option
paper(pdf.paper); - the country pack default (Germany: A4).
pdf.paper = null uses the country default; a configured size wins, also A4. The value is a page format of mPDF such as A4, A5 or Letter.
Watermark
A watermark marks drafts and previews: ->option('watermark', 'DRAFT') or Presentation::$watermark.
- Watermarked PDFs are rendered without PDF/A, because PDF/A does not allow the transparency of watermarks.
- They never contain e-invoice XML. A watermark together with any format that produces XML (also next to
pdfinformats([...])) throws aConfigurationException, so a draft can never pass as an e-invoice. - The default templates print it with mPDF’s
<watermarktext>(alpha 0.08) and, for browsers, as rotated CSS overlay (design.watermarkCss). - The labels
draftandpreviewcan be used as text: “DRAFT” and “PREVIEW”, in German “ENTWURF” (draft) and “VORSCHAU” (preview).
Payment QR codes
The default templates print a QR code next to the bank details that banking apps can scan to fill in the transfer. The type is set with Presentation::$paymentQr (pdf.payment_qr.type):
paymentQr |
Generator |
|---|---|
auto (default) |
A registered generator (setPaymentQrGenerator(), Laravel pdf.payment_qr.generator). Otherwise what the country pack names in CountryDefaults::$paymentQr: none prints no code; for other values the country pack’s generator (paymentQr()), otherwise the EPC GiroCode for girocode (the default, also without country pack) and no code for other types |
girocode |
EPC GiroCode, the European QR code for SEPA credit transfers |
swiss-qr-bill |
The generator registered with setPaymentQrGenerator() (Laravel: pdf.payment_qr.generator). Quittio ships no generator for the Swiss QR bill; without one, the render run fails with a ConfigurationException |
none |
No code |
No code is printed on refunds (credit notes, negative invoices) and on self-billed invoices.
The EPC GiroCode (EpcGiroCodeGenerator, standard EPC069-12 version 002) is created when:
- the currency is EUR,
- the payment means is a (SEPA) credit transfer (code 30 or 58) with a valid IBAN,
- and the amount due is between 0.01 and 999,999,999.99.
Its payload contains the BIC (optional), the IBAN, the amount, the beneficiary (payee name, otherwise account name, otherwise seller name, at most 70 characters) and the remittance information (BT-83, otherwise the invoice number, at most 140 characters); in total at most 331 bytes.
pdf.payment_qr.girocode_amount (option girocode_amount) is due (the amount due) or discounted (the amount after the first cash discount). With discounted, qrCode.note says until when the code is valid (“QR code with the amount after cash discount (…), valid until …”). Payment reminders get a code over the total of the reminder (Payment reminders).
payment.qrCode in the view model holds type (e.g. girocode), payload, dataUri (PNG data URI rendered with GD, SVG without GD; PNG because mPDF’s SVG output violates PDF/A), amount and note. Quittio\Core\Pdf\Qr\QrImage::svg(), png() and dataUri() render any payload. Own generators implement PaymentQrGenerator (key(), supports(InvoiceData), payload(InvoiceData, array $options)) and are returned by a country pack’s paymentQr().
Texts and Markdown
Line descriptions and notes are also part of the e-invoice XML. The templates therefore print them as plain text (lines[].description.html, notes[].html: escaped, line breaks kept).
With pdf.markdown = true (option markdown) they are rendered as Markdown: paragraphs, lists, bold and italic. HTML is shown as text, and links keep only their text. Formatting changes the displayed text compared with the XML. For example, 20*30*40 cm is shown as “203040 cm” with “30” in italics and without the asterisks. Keep the option off for texts that must match the XML.
Intro and closing texts (texts.intro, texts.closing) are PDF-only and always Markdown. The salutation is plain text.
Translations and locales
The locale (language) of a run is the first of:
->locale()of the call,- the invoice’s locale (
InvoiceBuilder::locale()), defaults.locale(localeinQuittio::create()),- the country pack default (Germany:
de).
Labels and code names (units, VAT categories, payment means, type codes …) exist in German, English and French; other locales fall back to English. The XML texts generated for the run (e.g. payment terms) use the same locale.
| Source | Where |
|---|---|
| Shipped labels | packages/core/resources/lang/{de,en,fr}/labels.php (namespace quittio) |
| Code names | codes.php, payment terms texts terms.php |
| Laravel overrides | lang/vendor/quittio/{locale}/labels.php (php artisan vendor:publish --tag=quittio-lang) |
| Country pack overrides | quittio-{code}::labels.{key} in the pack’s translationPath() |
| Per invoice or call | Presentation::$labels |
Without Laravel, the translator is Quittio\Core\I18n\ArrayTranslator (files {path}/{locale}/{group}.php, later paths override earlier ones, fallback en). Add a directory with Translations::translator()->addPath($path, 'quittio'), or replace the translator with Translations::use(new MyTranslator) (Quittio\Core\I18n\Translator: get(), has(), locale(), addPath()). The Laravel integration bridges to Laravel’s translator.
Values are formatted by IntlFormatter from their exact decimal value:
- amounts with two decimals and the currency symbol (
€,$,£,CHF, otherwise the code; English puts$and£in front); - prices with at least two decimals;
- quantities and rates without trailing zeros;
- separators from
ext-intl(fallback: German and English conventions); - dates as
dd.MM.yyyy(de),dd/MM/yyyy(fr, it, es, pt, nl, en),MM/dd/yyyy(en_US) and the medium date format ofext-intlfor other locales.
Country names come from ext-intl.
HTML output and preview
->format('html') returns the rendered template without PDF ($result->html()); documents use ->htmlOnly(). The HTML is the same markup mPDF receives; the watermark is shown with CSS.
The preview route /_quittio/preview lists the sample scenarios (simple, many-lines, long-texts, mixed-vat, reverse-charge, intra-eu, export, cancellation, sub-lines, foreign-currency, b2c-gross, allowances, attachments, peppol). It shows them as HTML or, with ?format=pdf, as PDF, with a “PREVIEW” watermark, the configured design and validation in mode warn (errors are reported, not thrown).
- The route exists in the local environment, or with
quittio.preview.enabled = true; outsidelocalthe gateviewQuittioPreviewmust allow the request (Preview route). Its path isquittio.preview.path, its middlewarequittio.preview.middleware. It is not registered while routes are cached. ?template=accepts the views that rendering accepts (Selecting a template):default, the configuredpdf.template, the views underquittio::templates.and the views ofpdf.templates, never partials; the view must exist.?locale=accepts a locale code.- Other values return 404.
More in Preview route. On the command line:
php artisan quittio:preview --scenario=many-lines --format=pdf --locale=en --output=/tmp/preview.pdf
The command writes to storage/app/quittio/preview/{scenario}.{format} by default; --template accepts the same templates as the route.
Changing single partials (level 2)
The Blade layout is split into partials, one per part of the page. To change one part, publish the views and edit the copy of that partial:
php artisan vendor:publish --tag=quittio-views # copies the views to resources/views/vendor/quittio/templates
Laravel then uses your copy of a partial instead of the shipped one. You can delete the copies you did not change; for those, Laravel uses the shipped partials, which follow package updates. The layout takes the partial prefix from the variable $partials (default quittio::templates.default.partials).
| Partial | Content |
|---|---|
styles |
CSS: page margins (first and following pages), font, colours, address field position, watermark for browsers |
header |
<htmlpageheader> of the following pages: logo or issuer name, title with number, recipient with customer number, invoice date; and an empty header for the first page |
footer |
<htmlpagefooter> with the footer columns and “Page {PAGENO} of {nbpg}” |
marks |
Fold and punch marks at the left edge of the first page (option fold_marks, on by default) |
sender-line |
Sender line at the top of the address field |
recipient |
Address lines of the recipient |
letterhead |
Logo or name of the issuer at the top left; the issuer’s address when the footer does not start with it |
meta |
Number, date, valid until, reference document, delivery date, service period, customer number, references, VAT identifier and tax number of the issuer (only when the footer does not show them), VAT identifier of the recipient (“Your VAT ID”; on a self-billed invoice also the seller’s tax number, “Your tax number”), tax representative, deliver-to address |
title |
Title (<h1>) |
references |
Preceding invoices (“Reference: invoice … of …”) |
intro |
Salutation and intro text |
lines |
Line table: groups with subtotals, optional and alternative markers, item numbers, descriptions, line periods, attributes, line allowances and charges, line notes, serial numbers, sub lines, price base quantity, gross unit price; a VAT column only with several VAT rates; the rows for the carry-over |
allowances |
Sum of lines, document allowances and charges (inside totals) |
totals |
Net total, VAT rows, gross total, paid amount, rounding, third party payments, amount due, VAT in accounting currency. When the amount due is the gross total, it is printed once, as gross total |
documents |
Packages, open items, fees, interest, totals and balance of documents |
tax-notes |
Exemption reasons and regulatory notes (legalNotes) |
payment |
Payment terms and direct debit notice; next to them the bank details (payee, IBAN; BIC and reference; the account holder only when it is not the issuer) or the payout account, and the payment QR code |
notes |
Other notes |
attachments |
Attached documents (identifier and description) |
closing |
Closing text, acceptance field of quotes |
Partials read the view model. Keep printing every value a partial prints today unless you print it elsewhere: the mandatory field guard reports missing mandatory values.
Own templates (level 3)
For a layout of your own, create a copy of the default template under your own name:
php artisan make:quittio-template my-design # resources/views/invoices/my-design (--force overwrites)
The name may contain lower case letters, digits, - and _. The command copies the default layout and partials and points the layout at invoices.my-design.partials. Use the template per call with ->template('invoices.my-design.layout') (allowed by the default pdf.templates entry invoices.*) or for every invoice with pdf.template.
A template must follow some rules of mPDF (mPDF rules for templates) and must print every mandatory value (Mandatory field guard).
Selecting a template
The template of a run is the first of:
->template('…')of the call;Presentation::$templateof the invoice’s own presentation;- the
templateof the presentation passed to->presentation()of the call; - the
templateof the configured presentation; - the configured template. In Laravel this is the name
default, which the Blade engine maps topdf.template(defaultquittio::templates.default.layout). Without Laravel it is thetemplatekey ofQuittio::create()(defaultdefault).
The same order applies to documents (quotes, reminders …).
Template names may come from invoice data (Presentation::$template, issuer branding of the Managed layer), so they are restricted:
- Blade engine: it renders
default, the configuredpdf.template, the shipped views underquittio::templates.(not their partials) and the views listed inpdf.templates(default['invoices.*']; an entry ending in*allows every view below that prefix, e.g.mytheme::*). A view whose name contains.partials.is never rendered as a template. Another existing view fails with anInvalidArgumentException. Names that are no Blade view are passed to the plain PHP engine. - Plain PHP engine: it accepts names only, no paths: segments of letters, digits,
_and-separated by/(segments after the first may also contain.), no.., no absolute paths or stream wrappers. It resolves{name}.phpinside its template directories (template_pathsofQuittio::create(), then the shipped templates). An unknown or invalid name fails with anInvalidArgumentException.
What a template receives
A Blade template receives $invoice, the view model. A plain PHP template receives $invoice and $e, an escape function that returns HTML-safe text.
Print values escaped: with {{ }} in Blade, with $e(…) in plain PHP. Only these prepared HTML values may be printed unescaped ({!! !!}): lines[].description.html, notes[].html, texts.intro, texts.closing and design.watermarkCss.
Carry-over in own templates
Own templates get the carry-over with these markers (other renderers and browsers ignore them):
<table class="lines">
<thead>…</thead>
<tbody>
<!--quittio:rows-->
<tr data-carry-over="{{ $line->carryOver }}">…</tr> {{-- every line row; sub lines and group rows without --}}
<!--/quittio:rows-->
</tbody>
</table>
<!--quittio:carry-out <tr class="carry-out"><td>Continued on page {page}</td><td>{amount}</td></tr> -->
<!--quittio:carry-in <tr class="carry-in"><td>Brought forward from page {page}</td><td>{amount}</td></tr> -->
<!--quittio:rows-->and<!--/quittio:rows-->enclose the rows of the line table.- Every line row carries its running total in
data-carry-over(lines[].carryOveroflineGroups). - The
carry-outcomment holds the row printed at the end of a page, thecarry-incomment the row at the start of the next page. {page}is the next page in the carry-out row and the previous page in the carry-in row.{amount}is thecarryOverof the last line row before the break.- The table needs a
<thead>and fixed column widths, because each page gets a table of its own.
Page numbers, headers and footers
The default templates put the footer columns and “Page {PAGENO} of {nbpg}” into an <htmlpagefooter> that every page uses (@page { footer: html_quittioFooter; }). mPDF replaces {PAGENO} and {nbpg} with the page number and the number of pages. Headers work the same way with <htmlpageheader>: the first page uses an empty header (@page :first { header: html_quittioFirstHeader; }), the following pages the header with logo, title and recipient.
Up to four footer columns get a fixed width (43.8 mm each, the last 39.6 mm); more columns share the width. The page margins come from the view model: design.pageMarginTop for the first page and design.continuationMarginTop for the following pages (Design).
mPDF rules for templates
mPDF supports only part of CSS. Follow these rules:
- Layout with tables, not flexbox or grid.
- Inside table cells, mPDF ignores
marginandpaddingof blocks (div,p). Create spacing there with the padding of a table cell, e.g. a nested table with one row per block. - Position elements on the page with
position: absolute(measured from the page edge);position: fixedis measured from the page margins. The address field must be a direct child of<body>. - Repeat table headers on every page via
<thead>. - Headers and footers via
<htmlpageheader>/<htmlpagefooter>, page numbers with{PAGENO}and{nbpg}. - Use
page-break-inside: avoidfor the totals block. - Images as PNG or JPEG; fonts must be embeddable.
Other template engines
Other engines (Twig …) implement Quittio\Core\Templates\TemplateEngine::render(string $template, InvoiceViewModel $invoice, array $data = []): string. Pass them to Quittio::create(templates: …) or $quittio->setTemplateEngine(…).
PDF renderers
A renderer turns the HTML of the template into the PDF (design level 4).
| Renderer | Use |
|---|---|
MpdfRenderer (default) |
HTML to PDF/A-3b with mPDF in pure PHP; embeds the XML and writes the XMP metadata (the document description PDF/A requires) in one pass |
CallbackRenderer(Closure $callback) |
Own rendering (Browsershot, Gotenberg …): fn (string $html, RenderRequest $request): string returns the PDF; PDF/A, metadata and the XML are added by MpdfPostProcessor |
PassthroughRenderer(string $pdf) |
A finished PDF of the application; only PDF/A and the XML are added |
use Quittio\Core\Pdf\Renderers\CallbackRenderer;
use Quittio\Core\Pdf\RenderRequest;
use Quittio\Laravel\Facades\Quittio;
use Spatie\Browsershot\Browsershot;
Quittio::extend('renderer', 'browsershot', fn ($app, array $config) => new CallbackRenderer(
fn (string $html, RenderRequest $request) => Browsershot::html($html)->format($request->paper)->pdf(),
));
// config/quittio.php: 'pdf' => ['renderer' => 'browsershot', 'renderers' => ['browsershot' => [...]]]
The factory receives the container and pdf.renderers.{name}. Without Laravel, pass the renderer to Quittio::create(renderer: …) or $quittio->setRenderer(…).
A renderer implements PdfRenderer with render(RenderRequest $request): string and supportsEmbedding(): bool:
supportsEmbedding()returnstruewhen the renderer creates the PDF/A and embeds the XML itself (MpdfRenderer, andCallbackRenderer/PassthroughRendererthrough their post processor).- For
false, Quittio passes the PDF toMpdfPostProcessorwhenever the request asks for PDF/A or an embedded XML, so a plain HTML-to-PDF renderer is enough. RenderRequestholdshtml,title,author,subject,paper,pdfa,embedded(EmbeddedXml:content,filename,relationship,conformanceLevel,description,documentType,version) andfont.- The post processor imports the pages with FPDI (a PHP library that reads existing PDFs). The result is PDF/A only if the source PDF allows it (embedded fonts, no encryption, no JavaScript), so check it with veraPDF, a validator for PDF/A.
A PDF is PDF/A-3b when it embeds XML, otherwise when pdf.pdfa is true (default) and it has no watermark. Its metadata: title “{title} {number}”, author the seller, creator “Quittio”.
mPDF settings of MpdfRenderer:
- margins 20 (left), 15 (right), 20 (top), 25 (bottom), header and footer 8 mm; the default templates set their own page margins;
pdf.renderers.mpdf.simple_tables(defaultfalse;truedrops the borders of table cells and is not faster);pdf.renderers.mpdf.pdfa_auto(defaultfalse; lets mPDF fix PDF/A issues itself);pdf.cache_pathas temporary directory (without Laraveltemp_dir, defaultsys_get_temp_dir()/quittio-mpdf).
mPDF cannot load anything over HTTP (a denying HTTP client is installed), has no remote base path and does not follow redirects. Remote resources of a template are rendered as missing.
View model
Templates receive $invoice (Quittio\Core\View\InvoiceViewModel) and nothing else. It holds every value of the invoice, already calculated and formatted for printing.
- Nodes are read-only
ViewNodeobjects. Use object syntax ($invoice->seller->name) or array syntax, loop over lists, and usecount(),isEmpty()andtoArray()(plain values, formatted). - Writing throws a
LogicException; a missing key reads asnull. - Values that need formatting are
FormattedValueobjects.{{ $invoice->totals->amountDue }}prints the formatted text (->formatted).->rawholds the original value:Brick\Money\Moneyfor amounts,Quittio\Core\Support\Datefor dates,Periodfor periods,Brick\Math\BigDecimalfor quantities, prices and rates. - Templates contain no calculations.
$invoice->label($key, $replace) returns a label with :placeholders replaced (unknown keys return the key); $invoice->title() returns the title.
The tables name the EN 16931 field of a value in parentheses, e.g. invoice number (BT-1) or seller address (BG-5); see Business terms.
Top level
| Key | Content |
|---|---|
number |
Invoice number (BT-1) |
numberLabel, dateLabel |
Labels for number and date (“Invoice number”, “Invoice date”; documents: “Number”, “Date”) |
title |
Document title (see below) |
issueDate, dueDate |
Dates (BT-2, BT-9) |
servicePeriod |
Invoicing period (BG-14), otherwise the delivery date (BT-72), otherwise the period from the earliest start to the latest end of the line periods when every line has one; null otherwise |
servicePeriodLabel |
“Delivery date” for a single date or a one-day period, otherwise “Service period”. German output: “Leistungsdatum” (date of supply) and “Leistungszeitraum” (period of supply) |
deliveryDate |
BT-72 when an invoicing period is also set (otherwise servicePeriod shows it) |
currency |
Invoice currency (BT-5) |
document |
document |
documentInfo |
null for invoices, documentInfo for documents |
seller, buyer |
Parties |
issuer, recipient |
The party in letterhead, sender line and footer, and the party in the address field: seller and buyer; on a self-billed invoice buyer and seller |
payee |
Payee (BG-10): name, identifier, legalRegistration; null without |
taxRepresentative |
Seller tax representative (BG-11): name, vatId, address; null without |
delivery |
recipientName, date (BT-72), address (with the recipient name as first line); null without delivery data |
references |
List of key, label, value for the filled references: buyer_reference, purchase_order, sales_order, contract, project (identifier and name), despatch_advice, tender, invoiced_object, accounting_reference |
precedingInvoices |
List of number, issueDate (BG-3) |
lineGroups |
Line groups |
lines |
All top-level lines |
allowances, charges |
Document allowances and charges |
vatRows |
VAT breakdown |
totals |
Totals; null when not calculated |
payment |
Payment; null without payment instructions |
paymentTerms |
Payment terms |
legalNotes |
List of texts: exemption reasons (BT-120, except category S) and notes with subject REG, ABL or TXD (BT-22), without duplicates |
notes |
Other notes: subject (BT-21), content, html |
attachments |
List of id, description, filename, uri (BG-24) |
texts |
salutation (plain text), intro, closing (HTML from Markdown) |
labels |
All labels of the locale, with the overrides of Presentation::$labels |
design |
Design values |
meta |
locale, custom (values of ->custom()), source |
The title is the first of:
Presentation::$title;- for self-billed type codes: the title
self_billedof the country pack’s correction policy, which also holds the titles of cancellations and corrections. Germany: “Gutschrift”; in English “Self-billed invoice (Gutschrift)”; - the label of the type code (BT-3).
| Label | English text | Used for |
|---|---|---|
title_380 |
Invoice | Type code 380 |
title_381 |
Credit note | Type code 381 and other credit note codes |
title_384 |
Corrected invoice | Type code 384 |
title_389 |
Self-billed invoice | Type code 389 and other self-billed codes |
title_325 |
Proforma invoice | Type code 325 |
title_326 |
Partial invoice | Type code 326 |
title_386 |
Prepayment invoice | Type code 386 |
title_875, title_876, title_877 |
Partial construction invoice, Partial final construction invoice, Final construction invoice | Type codes 875, 876, 877 |
title_default |
Invoice | Every other type code |
document
| Key | Content |
|---|---|
typeCode |
Type code (BT-3) as string, e.g. 380; null for documents |
type |
Name of the type code in the locale; for documents the name of the document type, e.g. “Quote” |
isCreditNote |
Credit note type code |
isSelfBilled |
Self-billed invoice (“Gutschrift”) |
profile, profileKey |
Name and key of the profile of the run, e.g. EN 16931, en16931 |
kind |
invoice, or the document type (quote, order_confirmation, delivery_note, proforma, payment_reminder, account_statement) |
isInvoice |
true for invoices, false for documents |
showPrices |
false for delivery notes |
showTotals |
false for delivery notes, reminders and statements |
Parties
seller and buyer:
| Key | Seller | Buyer |
|---|---|---|
name |
BT-27 | BT-44 |
tradingName |
BT-28 | BT-45 |
displayName |
trading name, otherwise name | trading name, otherwise name |
address |
see below | see below |
vatId |
BT-31 | BT-48 |
taxNumber |
BT-32 | – |
legalRegistration |
BT-30 value | BT-47 value |
additionalLegalInfo |
BT-33 | – |
customerNumber |
– | buyer identifier (BT-46) |
contact |
name, phone, email or null |
name, phone, email or null |
electronicAddress |
BT-34 value | – |
identifiers |
list of BT-29 values | – |
isConsumer |
– | consumer flag |
issuer and recipient are the same nodes.
Every address has lines, street, additional, line3, postcode, city, subdivision, countryCode and country (name in the locale). lines is formatted by the country pack’s address formatter and starts with the name. The country is added in upper case when it differs from the issuer’s country. The fallback formatter writes “postcode city”, for GB, IE, US, CA, AU, LV and UA “city subdivision postcode”.
Lines
lines and lineGroups[].lines contain:
| Key | Content |
|---|---|
position, id |
Line identifier (BT-126) |
name |
Item name (BT-153) |
description |
text (BT-154) and html (escaped plain text with line breaks, or Markdown with the option markdown) |
note |
Line note (BT-127) |
sellerId, buyerId |
Item identifiers (BT-155, BT-156) |
quantity |
Quantity (BT-129), formatted without trailing zeros |
unit |
code (BT-130) and label in the locale |
unitPrice |
Net price (BT-146), at least two decimals |
grossUnitPrice |
Price including VAT (gross price mode) |
listPrice |
Gross price before the price discount (BT-148) |
baseQuantity |
Price base quantity (BT-149) |
unitPriceBase |
Base quantity with unit, e.g. “100 pcs”, for printing with the unit price; null for 1 |
showGrossPrice |
Print grossUnitPrice: the line’s showGrossPrice (LineBuilder::showGrossPrice()), otherwise the presentation’s showGrossPrices, and only if a price including VAT exists |
total |
Line net amount (BT-131) |
carryOver |
Sum of the line net amounts up to this line in display order, only in lineGroups[].lines: the carry-over when a page ends after the line |
vatRate |
Rate (BT-152); null without rate and for margin schemes and travel agents (special VAT schemes without separate VAT) |
vatCategory |
Category code (BT-151) |
period |
Line period (BG-26) |
attributes |
List of name, value (BG-32) |
allowances, charges |
Line allowances and charges: reason, amount, percent, label (“Allowance reason 10 %”) |
subLines |
Sub lines with the same keys |
highlight |
LineBuilder::highlight(); the default templates shade the line and mark it in the secondary colour |
groupId |
Line group |
optional, alternativeTo, serialNumbers |
Document line options (quotes, delivery notes) |
lineGroups lists the groups defined with InvoiceBuilder::group() in their order, then one group with id and title null for the lines without a group (or with an unknown group). Empty groups are left out. Each group has id, title, lines and subtotal (sum of the line net amounts when showSubtotal is set, otherwise null). Print either lineGroups or lines, not both.
Allowances and charges
allowances and charges (BG-20, BG-21) are discounts and surcharges on the whole document. They contain reason (text or code), amount, percent, base, vatRate, vatCategory and label (“Allowance reason 3 %”; with several VAT groups followed by the VAT rate or category, e.g. “(19 % VAT)”).
VAT rows
| Key | Content |
|---|---|
label |
Ready text: “VAT 19%” when it is the only VAT row (its base is the net total printed above), otherwise “19% VAT on 1,000.00 €”; “VAT exempt (…) on …”, “No VAT (reverse charge) on …”, “0% VAT (zero rate) on …”, “Not subject to VAT: …”, “Special scheme, no separate VAT statement: …” |
category, categoryLabel |
Category code and name |
rate |
Rate (BT-119) |
base |
Taxable amount (BT-116) |
amount |
VAT amount (BT-117); null for margin schemes and travel agents (exemption reason codes VATEX-EU-D, -F, -I, -J) |
isSpecialScheme |
Margin scheme or travel agents |
exemptionReason, exemptionReasonCode |
BT-120, BT-121 |
Reverse charge means that the buyer owes the VAT (German terms). Margin schemes and travel agents are special VAT schemes (sections 25 and 25a of the German VAT Act, UStG) that state no separate VAT.
Totals
| Key | Content |
|---|---|
lineNetSum, allowanceSum, chargeSum, netTotal, vatTotal, grossTotal, prepaid, rounding |
Document totals |
vatTotalInTaxCurrency |
VAT in accounting currency (BT-111) |
taxCurrencyNote |
“VAT in USD: … (rate … of …)” when an exchange rate is set |
thirdPartyPayments |
List of description, amount (XRechnung extension) |
amountDue |
Amount due |
amountDueLabel |
“Amount due”; “Amount to be refunded” on credit notes and negative invoices; “Amount paid out” on self-billed invoices |
dueEqualsGross |
The amount due is the gross total: nothing prepaid, rounded or paid by third parties, no refund and no payout |
When dueEqualsGross is true, the default templates print the amount once, with the label gross_total (“Total incl. VAT”; German “Gesamtbetrag”, total amount).
On a negative invoice (e.g. a cancellation with the strategy negative_invoice), amountDue is printed as a positive refund amount; amountDue->raw and the XML keep the negative amount due (BT-115).
Payment
| Key | Content |
|---|---|
meansCode, means, meansText |
Payment means code (BT-81), its name, BT-82 |
remittance |
Remittance information (BT-83), otherwise the invoice number |
accounts |
List of iban (grouped in blocks of four), bic, name |
card |
number (“•••• 1234”), holder; or null |
directDebit |
mandate, creditorId, account; or null |
qrCode |
Payment QR code or null |
isRefund |
Credit note or negative amount due: show no bank details for a transfer by the buyer and no direct debit notice |
isPayout |
Self-billed invoice: the accounts receive the payout |
Payment terms
paymentTerms is null without payment terms (BT-20) and due date. Otherwise it has:
lines: ready texts. These are the terms text; “Please transfer the amount by …” when the buyer pays by credit transfer, otherwise “Please pay the amount by …”, on self-billed invoices “We will transfer the amount to your account by …”; one text per cash discount, such as “2% discount until 10/10/2026: 2,692.76 €”; and the late fee terms.discounts: list ofdays,percent,until,amount,text.dueDate.
On credit notes, quotes, order confirmations and invoices without a positive amount due, discounts are shown as agreement (“For payment within 7 days …”) without date or amount, and no due date text is added.
Design
| Key | Content |
|---|---|
primaryColor, secondaryColor, textColor, mutedColor |
Hex colours (secondaryColor falls back to the primary colour). The default templates print the issuer’s name, the title, the header of the following pages and the gross total or amount due in the primary colour; section titles and the mark of highlighted lines in the secondary colour; the lines below the column heads and above the gross total or amount due in the text colour; secondary texts and labels in the muted colour |
font |
Font family |
fontMedium |
Family for the medium and semibold weights (item names, column heads, title, headings): geistmedium for Geist (normal = medium, bold = semibold); null for other fonts, then the templates use bold |
logo |
Prepared logo (Logos) or null |
letterLayout |
Name of the letter layout |
addressTop, addressLeft, addressWidth, addressHeight |
Address field in mm from the top left page corner |
pageMarginTop |
Top page margin of the first page in the default templates (15 mm): the letterhead starts here |
continuationMarginTop |
Top page margin of the following pages (40 mm), below their header |
letterheadHeight |
Space above the address field: addressTop − pageMarginTop |
logoMaxHeight |
Height of the logo: 14 mm, less when the letterhead is lower (the logo ends 2 mm above the address field), at least 6 mm |
addressZoneTop |
Start of the recipient zone (DIN 5008 “Anschriftzone”) within the address field (17.7 mm), for own templates; the default templates print the recipient directly below the sender line |
senderLineHeight |
Height of the sender line (5 mm) |
noticeZoneHeight |
addressZoneTop − senderLineHeight: the DIN 5008 zone for additions and postal notes (“Zusatz- und Vermerkzone”) above the sender line, for own templates |
foldMarks, punchMark |
Mark positions in mm from the top; empty when the option fold_marks is false (default true) |
paper |
Paper of the presentation |
watermark, watermarkCss |
Watermark text, and the same as escaped CSS string for the HTML overlay |
senderLine |
Configured sender line, otherwise “Name · Street · Postcode City” of the issuer |
footerColumns |
Configured columns, otherwise generated: the issuer’s address lines; phone and e-mail; legal registration and the parts of the additional legal information (split at “ · “ and line breaks); “VAT ID …” and “Tax no. …” of the issuer. The bank details are in the payment block |
footerHasAddress |
The generated footer starts with the issuer’s address |
footerHasTaxIds |
The generated footer shows the issuer’s VAT identifier or tax number (they count as printed for the mandatory field guard); otherwise the default templates print them with the invoice details |
showItemNumbers, showGrossPrices |
Presentation switches |
documentInfo
Only for documents:
| Key | Content |
|---|---|
type |
Document type value |
validUntil |
Quote validity |
reference, referenceDate |
Preceding document (reference()) |
deliveryDate |
Delivery date |
packages |
List of id, type, weight (“2.5 kg”), dimensions, trackingNumber |
lines |
Line options keyed by line identifier: optional, alternativeTo, serialNumbers |
dunning |
level, deadline, deadlineText (“Please pay the total amount by …”), openItems, fees (description, amount), interest (description, days, amount), openTotal, feeTotal, interestTotal, total; null for other types |
statement |
period, items, openingBalance, balance; null for other types |
showAcceptance |
Print the acceptance field (quotes) |
Open items (dunning.openItems[], statement.items[]) have invoiceNumber, invoiceDate, dueDate, description, amount, paid and open.
Mandatory field guard
Some values must appear on every invoice by law. After rendering a PDF or HTML invoice, the guard checks that the template printed them all:
- number and issue date;
- the full name of seller and buyer (
seller.nameor the address lines, which start with it; the trading namedisplayNamealone is not enough) and their address (all address lines, or street, postcode and city); - name, quantity and unit (
unit.label) of every line (inlineGroupsorlines). Section 14 (4) no. 5 UStG requires “Menge und Art” (quantity and kind) of the goods; - label, or base and rate, and the VAT amount of every VAT row;
- the cash discount terms (
paymentTerms.discounts[].textorpaymentTerms.lines) and the amount due; - every legal note (exemption reasons and regulatory notes, e.g. “Gutschrift” or the retention notice of section 14b UStG) and every preceding invoice number;
- the tax representative (name, address, VAT identifier);
- what the country pack requires. Germany: tax number or VAT identifier (also of the tax representative); the service date (
servicePeriod,deliveryDateordelivery.date, or the period of every line); on self-billed invoices the title and the note “Gutschrift”; the retention notice of property related services; the reverse charge or exemption note per VAT row; the VAT identifiers of buyer and seller for reverse charge and intra-community supplies (tax-exempt supplies of goods to a business in another EU country; see Country packs).
How the guard decides whether a value was printed:
- Lists are checked per entry: printing only the first VAT row or note is not enough.
- Values that are null are not required.
- A value counts as printed when the template reads it as a leaf (a single value, not a node), also in a condition such as
@if ($invoice->buyer->vatId), while a loop reads the entries of a list, or withtoArray(). count(),isEmpty(),isset()and reading null values do not count.- Values printed through
issuerandrecipientcount for the seller and the buyer, and the generated footer counts as the issuer’s address. A customsenderLineor customfooterColumnsdo not replace the seller’s address.
Mandatory fields are Quittio\Core\Countries\MandatoryField objects (accessKey, description, legalBasis). The access key is a view model path where [*] stands for every index, & joins paths that must all be printed and | separates alternatives. Country packs return them from mandatoryOutput(InvoiceData $invoice).
A template that omits a mandatory value fails with MandatoryFieldMissing (validation.mandatory_field_guard = exception, default outside production) or logs it (log, default in production); off disables the check. The event MandatoryFieldMissing is dispatched in both modes. Details in Validation.