Skip to content

Templates ​

openbill renders every document on the server from a template written in HTML, CSS and Liquid. Gotenberg turns the result into a PDF. The same rendering drives the preview, the PDF download and the archived PDF of a finalized document.

Built-in templates ​

The template gallery with Klar, Kante and Leise

TemplateLook
Klarcalm, classic layout with a clear table
Kantebold: a strong colour band, a large total, heavy rules
Leiseminimal: lots of white space, a serif typeface, hairlines

The built-in templates speak English and German (they follow the language of the document), show group subtotals, optional items, tax rows, legal notes and page numbers. They are read-only and are updated with openbill (php artisan migrate creates them, php artisan app:sync-templates brings them up to date). To change one, duplicate it (Templates → Duplicate) and edit the copy.

Which template a document uses ​

A document uses its own template if it has one, else the default template of its document type, else Klar. When a document is finalized, the template is frozen together with the other master data and the PDF is archived. Changing or deleting the template later never changes a finalized document or its PDF.

Template editor ​

The template editor: code on the left, preview on the right

Templates shows every template with a preview of its first page (rendered on the server with sample data). Import ZIP adds a template package, Duplicate creates an editable copy (“Klar (copy)”), Delete removes your own templates (not while documents still use them).

The editor has five tabs – header, content, footer, CSS and manifest – with syntax highlighting; the manifest tab also has a form for the paper (format, header and footer height, side margins). On the left, the field bar lists the core and custom fields of every level; a click inserts the expression at the cursor. On the right, the preview renders the unsaved state on the server 250 ms after the last change and highlights the area of the current tab. Sample data switches between the sample scenarios and real documents (also drafts). Errors appear above the preview with file and line and are underlined in the code; a click jumps there. Save (⌘S / Ctrl+S) writes the parts, Export downloads the saved state as package. Built-in templates open read-only. Assets (fonts, images) are not edited here: import them with the package or upload them with PUT /api/templates/{id}/files. To work in your own editor with a live preview, download the template toolkit (Toolkit in the editor header).

Template package ​

A template is exchanged as a ZIP file:

FileContent
manifest.jsonname, version, paper and more (required, see below)
template.liquidthe document body (required)
header.liquidheader on every page (optional)
footer.liquidfooter on every page (optional)
styles.cssCSS for the body (optional, plain CSS without Liquid)
assets/…images (png, jpg, gif, webp, svg) and fonts (woff, woff2, ttf, otf)

The files may also sit in one folder inside the ZIP. Limits: 10 MB per package, 20 MB unpacked, 100 files, 2 MB per asset. Other files, paths with .. and absolute paths are refused.

Reference assets with a relative path, in HTML and in CSS:

html
<img src="assets/logo.svg" alt="">
css
@font-face { font-family: Brand; src: url(assets/brand.woff2); }

openbill embeds them as data URLs while rendering. Images and fonts can only come this way – as template assets (or the organization logo, org.logo): a rendered document may not load anything else. Every document carries a Content-Security-Policy (default-src 'none'; img-src data:; font-src data:; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'), so links to http(s)://… images, fonts, stylesheets or frames and all scripts are blocked, and Gotenberg runs without JavaScript and without network access as a second line of defence.

manifest.json ​

json
{
  "name": "Nordlicht Letter",
  "version": "1.0.0",
  "author": "Studio Nordlicht",
  "license": "MIT",
  "description": "Letterhead with logo",
  "documentTypes": ["offer", "invoice"],
  "expectedFields": [
    { "scope": "document", "key": "project_name", "type": "text", "fallback": "" }
  ],
  "paper": { "format": "A4", "marginX": "20mm", "headerHeight": "30mm", "footerHeight": "20mm" },
  "translations": {
    "en": { "hotline": "Hotline" },
    "de": { "hotline": "Hotline" }
  }
}
KeyRequiredMeaning
nameyesdisplay name (at most 100 characters)
versionyesversion like 1.0.0
author, license, descriptionnotexts
documentTypesnodocument types the template is meant for: offer, confirmation, invoice, cancellation
expectedFieldsnocustom fields the template uses (scope, key, type, fallback), shown as hints when a field is missing
papernoformat (A4 or Letter), marginX (left and right), headerHeight, footerHeight; lengths in mm, cm, in, pt or px. Default: A4, 20mm, 30mm, 20mm
translationsnoyour own words for the t filter, per language (en, de)

Page layout ​

Every page has three areas, exactly like Chromium prints them:

  • the header fills the top margin (headerHeight) across the full page width,
  • the footer fills the bottom margin (footerHeight),
  • the body sits in between, marginX away from the left and right edge, and flows over as many pages as it needs.

Header and footer are separate HTML documents (as wide as the page and as high as their margin): styles.css does not apply to them, so put a <style> element into header.liquid and footer.liquid. Set font sizes there explicitly. Backgrounds and colours are printed.

Liquid reference ​

Templates use standard Liquid ({% if %}, {% for %}, {% assign %}, {% capture %}, {% case %}, standard filters like upcase, default, append, size) plus the context and filters below.

Context ​

All amounts are strings with a dot and two decimals ("1130.50"), dates are YYYY-MM-DD. All texts are already HTML-escaped: print them as they are, escape is not needed.

org – your organization

KeyContent
name, street, zip, city, countryaddress (country as ISO code, DE)
logologo as data URL, empty when there is none: {% if org.logo != blank %}<img src="{{ org.logo }}">{% endif %}
vat_id, tax_numberVAT ID, tax number
registration_idregister number, e.g. the commercial register (HRB 12345, Amtsgericht Hamburg)
iban, bic, bank_namebank account as entered; {{ org.iban | iban }} prints the IBAN in groups of four
email, phone, websitecontact
taxation_mode, small_businessregular or small_business; small_business is true for small businesses (§ 19 UStG)
localedefault language

customer – empty when the document has no customer: number, name, street, zip, city, country, email, vat_id, tax_treatment (domestic, eu_reverse_charge, third_country), kind (business, consumer), locale.

contact – the contact person, may be empty: salutation, first_name, last_name, full_name, position, email, phone.

document

KeyContent
type, type_labeloffer, confirmation, invoice, cancellation; the name in the document language
statusdraft, open, accepted, paid, …
numbernumber; drafts show "Draft"/"Entwurf"
date, due_date, valid_until, service_date, service_date_enddates (may be empty)
localedocument language (en, de)
intro, closingtexts before and after the items (may contain Liquid themselves, see below)
net_total, tax_total, gross_totaltotals
optional_totalsum of optional items (offers), not part of the totals
tax_breakdownlist of rows with category, percent ("19"), net, tax, gross
legal_noteslist of legal notes (small business, reverse charge, third country)
source_numbernumber of the document this one was created from (offer → invoice, invoice → cancellation)
payment_qrGiroCode (EPC QR code for SEPA transfers) as SVG data URL, see GiroCode

groups – the item groups in order: title, has_title (false for items without group), total, optional_total, positions.

positions – all items of all groups in order (the same objects as in groups[].positions):

KeyContent
indexrunning number of items and optional items; empty for text lines
kind, is_item, is_text, is_optionalitem, text, optional and the matching flags
title, descriptiontexts
quantityquantity without trailing zeros ("10", "1.5")
unit, unit_name, unit_codesymbol (h), name (Hour), UN/ECE code (HUR)
unit_priceprice per unit, at least two decimals ("95.00", "12.3456")
tax_percenttax rate ("19")
totalnet amount of the line
grouptitle of the group (empty for items without group)

page – only in header.liquid and footer.liquid: {{ page.number }} and {{ page.total }} (see page placeholders).

Custom fields ​

Custom fields sit directly on their object, with their key: {{ document.project_name }}, {{ customer.industry }}, {{ contact.department }}, {{ org.managing_director }}, {{ p.ticket }} for a position p. Built-in keys are reserved, so a custom field never hides one. A field without value prints nothing; use default for a fallback: {{ document.po_number | default: "–" }}.

Filters ​

FilterExampleResult (de / en)
money{{ document.gross_total | money }}1.130,50 € / €1,130.50
money: decimals{{ p.unit_price | money: 4 }}12,3456 € / €12.3456
number: decimals{{ p.quantity | number: 2 }}1,50 / 1.50 (default 2 decimals)
date{{ document.date | date }}04.10.2026 / Oct 4, 2026. A whole number is read as Unix timestamp (seconds).
date: format{{ document.date | date: "%Y-%m-%d" }}strftime pattern like Liquid's standard date
nl2br{{ document.intro | nl2br }}line breaks become <br>
iban{{ org.iban | iban }}DE02 1203 0000 0000 2020 51 (groups of four)
t{{ 'total' | t }}Gesamt / Total

Formats follow the document language. Amounts are rounded commercially. money and number print nothing for values that are not numbers. An unknown filter is an error.

The math filters plus, minus, times, divided_by and round compute with exact decimals and return a decimal text ({{ document.net_total | times: 1.19 }} → "1345.2950", round rounds half up); with two integers they keep Liquid's integer behaviour ({{ 10 | divided_by: 4 }} → 2). Two whole numbers stay whole numbers (divided_by rounds down); a result beyond ±9.2 × 10¹⁸ comes back as exact text and is computed as a decimal from then on (a following divided_by no longer rounds down). Prefer the totals of the context over computing amounts in the template.

Escaping ​

All texts of the context are HTML-escaped exactly once before rendering, so:

  • don't add escape; it works like escape_once and never escapes twice, so it changes nothing,
  • truncate and slice count the escaped text and may cut an entity (&am…); shorten texts only when they contain no &, <, > or quotes,
  • inside intro and closing texts, filters that produce HTML (nl2br) are escaped with the text: use nl2br on document.intro in the template, not inside the intro.

Words in the document language (t) ​

{{ 'key' | t }} looks the word up in translations of your manifest (document language), then in openbill's vocabulary, else prints the key. openbill's vocabulary: amount_due, credit, customer_number, date, details, draft, due_date, for, net, number, number_short, of, offer_total, optional, optional_total, page, pos, price, project, quantity, reference, service, service_date, subtotal, sum, tax_number, total, unit_price, valid_until, vat, vat_id, vat_long, vat_on.

Page placeholders ​

In the header and the footer, {{ page.number }} and {{ page.total }} become the current page and the number of pages:

liquid
{{ 'page' | t }} {{ page.number }} {{ 'of' | t }} {{ page.total }}

They only work in header.liquid and footer.liquid, written exactly like this (filters can't be applied to them).

Intro and closing ​

document.intro and document.closing (usually from text blocks) are Liquid too and are rendered first, with the same context: Please transfer the amount by {{ document.due_date | date }}. Print them with nl2br to keep the line breaks.

Empty texts ​

In Liquid only nil and false are false: an empty text like "" counts as true. Texts that may be empty (logo, VAT ID, descriptions, intro, custom fields) are therefore tested with != blank, which is false for empty texts and for missing values:

liquid
{% if org.vat_id != blank %}{{ 'vat_id' | t }} {{ org.vat_id }}{% else %}{{ 'tax_number' | t }} {{ org.tax_number }}{% endif %}

Values that are empty instead of texts (customer, contact, dates, source_number) and flags (is_text, has_title) work with a plain {% if %}.

GiroCode ​

document.payment_qr is a GiroCode (EPC QR code after EPC069-12) that banking apps scan to fill in a SEPA transfer: recipient org.name (at most 70 characters), org.iban, org.bic, the amount document.gross_total and the document number as reference. It is a data URL of an SVG image with a quiet zone, so it goes straight into an <img>:

liquid
{% if document.payment_qr != blank %}<img src="{{ document.payment_qr }}" alt="" style="width: 18mm">{% endif %}

It is filled only for invoices that are finalized (not drafts, so no draft number ends up in the code), have a total between 0.01 and 999,999,999.99 and an organization with a valid IBAN; otherwise it is empty. Once finalized it is built from the frozen master data of the snapshot. Print it at about 18 mm or larger.

Comparing amounts ​

Amounts are strings. Compare them with > or <, which compare numbers: {% if document.optional_total > 0 %}. Don't use == 0 or != 0 with amounts: "0.00" != 0 is true in Liquid.

Limits and errors ​

A template can never break openbill or block it:

  • each part (header, body, footer, styles) may be at most 200 KB, the rendered HTML of a part at most 5 MB,
  • loops and output are limited (a loop over a range like (1..n) may have at most 10 000 items; endless or huge loops stop with "render limit"),
  • {% render %} and {% include %} are not available, {% tablerow %} neither,
  • styles.css must not contain </style>,
  • the paper must leave room for the body: twice marginX less than the page width, headerHeight + footerHeight at least 20mm less than the page height,
  • on import, every part must stay within the 200 KB, field keys in expectedFields must look like project_name, and assets must really be what their extension says,
  • a rendering stops after 5 seconds; the clock is checked in every loop iteration and before every filter call, so a long filter chain stops in time as well,
  • no scripts and no external URLs: <script>, <iframe>, http(s):// images, fonts and stylesheets are blocked (use template assets), and every <meta http-equiv> (refresh, own policies) is a template error,
  • a rendering may use at most 128 MB of memory; capture and every filter that can make its result larger than its input (replace, append, join, split, base64_encode, url_encode, upcase, …) check the size of the result against the output limit before building it, arrays from filters have at most 100 000 items, and numbers in math filters at most 1 000 digits.
  • file: URLs: Gotenberg may read local files, but the content security policy at the top of every rendered document blocks everything except data: – a template referring to file:///tmp/… or other local paths shows nothing. Never use file: in templates; use assets.

Every problem is reported with the part (header, body, footer, css, manifest, intro, closing), the file and the line, for example body · template.liquid · line 3 · Unexpected character }. The preview shows the error in place of the broken part. Finalizing and the draft PDF refuse to run while the template has errors, so a broken template never ends up in an archived PDF.

Sample data ​

For previews without a real document openbill has five sample scenarios: short_invoice, long_invoice (several pages, groups), small_business, reverse_charge and offer_with_options. They use your organization (or the fictional Studio Nordlicht on a fresh installation), a fictional customer, fixed dates and sample values for your custom fields. To get one as PDF on the command line:

bash
php artisan app:render-sample klar long_invoice --locale=en

The PDF is written to storage/app/private/samples/.

Example ​

liquid
<h1>{{ document.type_label }} {{ document.number }}</h1>
<p>{{ customer.name }}<br>{{ customer.street }}<br>{{ customer.zip }} {{ customer.city }}</p>

{% if document.intro != blank %}<p>{{ document.intro | nl2br }}</p>{% endif %}

<table>
  {% for g in groups %}
    {% if g.has_title %}<tr><th colspan="3">{{ g.title }}</th></tr>{% endif %}
    {% for p in g.positions %}
      <tr>
        <td>{{ p.index }}</td>
        <td>{{ p.title }}{% if p.is_optional %} ({{ 'optional' | t }}){% endif %}</td>
        <td>{% unless p.is_text %}{{ p.total | money }}{% endunless %}</td>
      </tr>
    {% endfor %}
  {% endfor %}
</table>

<p>{{ 'total' | t }}: {{ document.gross_total | money }}</p>
{% for note in document.legal_notes %}<p>{{ note }}</p>{% endfor %}