Template editor writing guide
A practical guide to customising your document templates in Phasio
You don't need to be a developer to customise your templates. This guide covers inserting data, showing or hiding fields, and building custom tables for parts and expenses.
š” The fastest way to get a custom template is to use the AI writing assistant at the bottom of this page.
Start from the default
Every template comes pre-filled with a working layout. Open the Source tab to edit the HTML directly, or use the Visual tab for click-to-edit formatting.
Custom Document, Group Traveller, and Order Traveller templates start with a fully annotated starter template ā it includes a page header, footer, logo, and a parts table with comments explaining each section. Edit it to match your layout and branding.
ā ļø Do not remove
xmlns:th="http://www.thymeleaf.org"from the<html>tag ā it enables all variable and conditional features. Standard HTML5 is supported; no strict XHTML syntax required.
Insert a variable
Click Variables in the toolbar to browse and insert any variable ā it is placed at your cursor automatically with the correct syntax.
To type a variable directly in the Source tab:
[(${VARIABLE_NAME})]Example:
<p>Order: [(${ORDER_NUMBER})]</p>
<p>Customer: [(${CUSTOMER_NAME})]</p>
<p>Total: [(${FINAL_PRICE})]</p>Variable reference
Your company
| Variable | What it inserts |
|---|---|
LOGO_IMG | Your company logo ā pre-rendered <img> tag on standard templates; raw base64 on custom templates (see Using the logo) |
OPERATOR_NAME | Your company name |
OPERATOR_EMAIL | Your company email |
OPERATOR_PHONE | Your company phone |
OPERATOR_LOCATION | Your company location |
GST_NUMBER | Your GST / VAT number |
PAYMENT_INFORMATION | Bank / payment details |
OPERATOR_NOTES | Notes added to the order |
Order
| Variable | What it inserts |
|---|---|
ORDER_NUMBER | Order number |
QUOTE_NUMBER | Quote number associated with the order |
ORDER_DATE | Date the order was placed |
ESTIMATE_DATE | Date the quote was sent |
CONFIRMATION_DATE | Date the customer confirmed |
PAYMENT_DATE | Date payment was received |
TARGET_DELIVERY_DATE | Estimated delivery date |
PURCHASE_ORDER_NUMBER | Customer's PO number |
Customer
| Variable | What it inserts |
|---|---|
CUSTOMER_NAME | Customer organisation name |
CUSTOMER_GST | Customer's GST / tax number |
CUSTOMER_ID | Customer contact name |
PAYMENT_TERMS | Payment terms assigned to the customer (account customers only ā e.g. Net 30) |
CUSTOMER | Raw customer object ā access individual fields (see below) |
BILLING_ADDRESS | Pre-formatted billing address block |
BILLING_ADDRESS_DATA | Raw billing address ā access individual fields (see below) |
SHIPPING_ADDRESS | Pre-formatted shipping address block |
SHIPPING_ADDRESS_DATA | Raw shipping address ā access individual fields (see below) |
CUSTOMER fields: CUSTOMER.organisationName, CUSTOMER.firstName, CUSTOMER.lastName, CUSTOMER.email, CUSTOMER.phone, CUSTOMER.metadata (key-value map of custom organisation metadata fields)
BILLING_ADDRESS_DATA / SHIPPING_ADDRESS_DATA fields: street1, street2, city, state, country, zip, name, company, email, phone
<!-- Use the pre-formatted block for a quick address block -->
<td th:utext="${BILLING_ADDRESS}"></td>
<!-- Or access individual fields for a custom layout -->
<td>
<div th:text="${BILLING_ADDRESS_DATA.name}"></div>
<div th:text="${BILLING_ADDRESS_DATA.street1}"></div>
<div th:text="${BILLING_ADDRESS_DATA.city + ', ' + BILLING_ADDRESS_DATA.country}"></div>
</td>
<!-- Access a custom metadata field by key (use th:if to guard against missing keys) -->
<div th:if="${CUSTOMER.metadata != null and CUSTOMER.metadata.containsKey('account_number')}">
Account #: [(${CUSTOMER.metadata['account_number']})]
</div>Pricing
| Variable | What it inserts |
|---|---|
SUB_TOTAL | Parts total before extras |
SHIPPING_FEE | Shipping charge |
DISCOUNT | Discount amount |
DISCOUNT_PERCENTAGE | Discount percentage |
TAXES | Pre-rendered tax rows ā wrap in a <table> (see below) |
PRICE_BEFORE_TAX | Total before tax |
FINAL_PRICE | Total amount due |
TOP_UP_PRICE | Top-up to reach minimum order |
LINE_ITEM_NAMES | Pricing line item names (pre-rendered HTML) |
LINE_ITEM_PRICES | Pricing line item amounts (pre-rendered HTML) |
Accounting integration
These variables are available when an accounting integration (Bexio, Xero, LexOffice, Weclapp, Zoho, etc.) is connected and a reference has been synced for the order. They are blank when no integration is active or the order has not yet been pushed to the accounting system.
| Variable | What it inserts |
|---|---|
ACCOUNTING_INVOICE_NUMBER | Invoice reference number from the accounting system |
ACCOUNTING_ESTIMATE_NUMBER | Estimate reference number from the accounting system |
ACCOUNTING_CONFIRMATION_NUMBER | Confirmation reference number from the accounting system |
Available on: Order Invoice, Order Estimate, Order Confirmation, Traveller Sheet, Custom Document, and per-order inside ORDERS on Order Traveller templates.
Use th:if to show the reference only when it exists:
<p th:if="${ACCOUNTING_INVOICE_NUMBER}">
Invoice ref: [(${ACCOUNTING_INVOICE_NUMBER})]
</p>On an Order Traveller, access the reference per order:
<span th:if="${order.accountingInvoiceNumber != null}"
th:text="${order.accountingInvoiceNumber}"></span>Parts and expenses
| Variable | What it inserts |
|---|---|
PARTS_TABLE | Built-in parts table (ready to drop in) ā includes a Unit Price column |
PARTS | Parts list ā iterate to build your own table (see below). Each part has: name, technology, material, quantity, volume, surfaceArea, length, width, height, thumbnailImg, color, postProcessings, infill (nullable), precision (nullable), unitPrice (nullable), currency (nullable), customerArticleReference (nullable ā customer's article reference for the part) |
EXPENSES_TABLE | Built-in expenses table (ready to drop in) |
EXPENSES | Expenses list ā iterate to build your own table (see below) |
Production (Group Traveller only)
| Variable | What it inserts |
|---|---|
COMPANY_LOGO | Company logo ā raw base64 string (see Using the logo) |
GROUP_NAME | Production group name |
GROUP_DATE | Date the group was created |
MACHINE | Machine used |
MATERIAL | Material used |
MATERIAL_BATCH | Batch identifier |
DUE_DATE | Group due date |
EXPORT_DATE | Date the document was generated |
LINE_ITEMS | Parts list ā iterate to list parts in the group. Each item has: partName, customerName, quantity, thumbnailImg, purchaseOrderNumber, orderNumber, unitPrice (nullable), currency (nullable) |
ALL_WORKFLOW_STEPS | List of all workflow step names |
LINE_ITEMS_WITH_MAPS | Parts paired with workflow step data ā use to build routing tables. Each entry exposes .lineItem (full part object), .stepMap, .unitPrice (nullable shortcut), .currency (nullable shortcut) |
Orders (Order Traveller only)
| Variable | What it inserts |
|---|---|
COMPANY_LOGO | Company logo ā raw base64 string (see Using the logo) |
EXPORT_DATE | Date the document was generated |
GROUP_NAME | Production group name (e.g. BUILD-2025-001) ā set when the traveller is downloaded from a production step; blank when downloaded directly from an order |
ORDERS | List of orders ā iterate over each order with its parts and totals. Each order has: orderNumber, orderDate, customerName, customerGst, billingAddress, shippingAddress, purchaseOrderNumber, finalPrice (nullable), currency, accountingInvoiceNumber (nullable), accountingEstimateNumber (nullable), accountingConfirmationNumber (nullable), allWorkflowSteps, lineItemsWithMaps. Each entry in lineItemsWithMaps exposes .lineItem (with unitPrice and currency fields), .stepMap, .unitPrice (shortcut), .currency (shortcut) |
Using the logo
LOGO_IMG behaves differently depending on the template type:
Standard templates (Order Invoice, Order Estimate, Order Confirmation, Traveller Sheet, Consignment Label) ā LOGO_IMG is a pre-rendered <img> tag from the server. Output it directly:
<!-- In a table cell -->
<td th:utext="${LOGO_IMG}"></td>
<!-- Inline -->
[(${LOGO_IMG})]Custom templates (Custom Document, Group Traveller, Order Traveller) ā LOGO_IMG and COMPANY_LOGO are raw base64 strings. Use both th:src and th:attr for reliable rendering:
<img th:if="${LOGO_IMG != null and !#strings.isEmpty(LOGO_IMG)}"
th:src="${'data:image/png;base64,' + LOGO_IMG}"
th:attr="src=|data:image/png;base64,${LOGO_IMG}|"
style="max-height: 40px; width: auto;"
alt="" />Show a field only when it has a value
Use th:if to hide a block when the variable is empty. This is useful for optional fields like PO number or notes.
<p th:if="${PURCHASE_ORDER_NUMBER}">
PO: [(${PURCHASE_ORDER_NUMBER})]
</p>
<p th:if="${OPERATOR_NOTES}">
Notes: [(${OPERATOR_NOTES})]
</p>This works on any element ā <p>, <div>, <tr>, and so on.
Visual indicator: In the Visual tab, elements with th:if show a blue outline and label so you can see them at a glance without reading the source.
Using taxes
TAXES is a pre-rendered HTML block ā not a plain value you can inline like FINAL_PRICE. It outputs one <tr> per tax component applied to the order. Each row has three cells:
| Cell | Content |
|---|---|
| 1 (empty) | Spacer |
| 2 | Tax short name + percentage ā e.g. VAT (10%) |
| 3 | Formatted tax amount ā e.g. $125.00 |
Because TAXES outputs <tr> elements, you must wrap it in a <table> and use [(\${TAXES})] (unescaped output):
<table style="width: 100%;">
[(${TAXES})]
</table>Use th:if="${TAXES}" to hide the block entirely when no taxes are configured on the order:
<table th:if="${TAXES}" style="width: 100%;">
[(${TAXES})]
</table>A typical use is inside a totals summary table alongside SUB_TOTAL and FINAL_PRICE. Because TAXES outputs <tr> rows, it slots directly between the other rows:
<table style="width: 50%; margin-left: auto;">
<tr>
<td>Subtotal</td>
<td style="text-align: right;">[(${SUB_TOTAL})]</td>
</tr>
<tr>
<td>Shipping</td>
<td style="text-align: right;">[(${SHIPPING_FEE})]</td>
</tr>
[(${TAXES})]
<tr>
<td><strong>Total</strong></td>
<td style="text-align: right;"><strong>[(${FINAL_PRICE})]</strong></td>
</tr>
</table>ā¹ļø
TAXESis only available on Order Invoice, Order Estimate, Order Confirmation, and Custom Document templates. It is not set on Traveller or Consignment Label templates.
Build a custom parts table
Use PARTS to build your own layout. Each part has: name, technology, material, color, quantity, unitPrice, processingPrice, thumbnailImg (raw base64), postProcessings (a list with name and price), infill (nullable string ā e.g. "20%"), precision (nullable string ā e.g. "Standard"), and physical dimensions: volume (mm³), height, width, length (mm), area (mm²). shrinkWrapVolume and minimumWallThickness are available on Pro and Factory Floor plans. customerArticleReference (nullable string) is the customer's article reference for the part ā use th:if to show it only when present.
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 6px;">Part</th>
<th style="text-align: left; padding: 6px;">Material</th>
<th style="text-align: left; padding: 6px;">Colour</th>
<th style="text-align: right; padding: 6px;">Qty</th>
<th style="text-align: right; padding: 6px;">Total</th>
</tr>
</thead>
<tbody>
<tr th:each="part : ${PARTS}">
<td style="padding: 6px;">[(${part.name})]</td>
<td style="padding: 6px;">[(${part.technology})], [(${part.material})]</td>
<td style="padding: 6px;" th:text="${part.color}"></td>
<td style="padding: 6px;">[(${part.width})] Ć [(${part.height})] Ć [(${part.length})] mm</td>
<td style="text-align: right; padding: 6px;">[(${part.quantity})]</td>
<td style="text-align: right; padding: 6px;">[(${part.processingPrice})]</td>
</tr>
</tbody>
</table>To include a thumbnail image:
<img th:if="${part.thumbnailImg != null and !#strings.isEmpty(part.thumbnailImg)}"
th:src="${'data:image/png;base64,' + part.thumbnailImg}"
th:attr="src=|data:image/png;base64,${part.thumbnailImg}|"
style="max-width: 40px; max-height: 40px;" alt="" />Visual indicator: In the Visual tab, rows with th:each show an amber outline and label so loop blocks are easy to spot.
Build a custom expenses table
Use EXPENSES to build your own layout. Each expense has name, description, price, and type (FIXED or HOURLY). Hourly expenses also have ratePerHour and hours.
<table style="width: 100%; border-collapse: collapse;">
<tr th:each="expense : ${EXPENSES}">
<td style="padding: 6px;">
[(${expense.name})]
<div th:if="${expense.description != null and !#strings.isEmpty(expense.description)}"
style="font-size: 0.9em; color: #666;">
[(${expense.description})]
</div>
</td>
<td style="padding: 6px;" th:if="${expense.type.name() == 'HOURLY'}">
[(${expense.hours})] hrs Ć [(${expense.ratePerHour})]
</td>
<td style="text-align: right; padding: 6px;">[(${expense.price})]</td>
</tr>
</table>Group Traveller ā parts and workflow routing
Use LINE_ITEMS to list parts in the group. Each item has: partName, customerName, quantity, thumbnailImg, purchaseOrderNumber, orderNumber, unitPrice (nullable), and currency (nullable).
Use LINE_ITEMS_WITH_MAPS and ALL_WORKFLOW_STEPS together to build a routing matrix ā rows are parts, columns are workflow steps. Each entry also exposes .unitPrice and .currency shortcuts for easy price rendering:
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr>
<th style="text-align: left; padding: 6px;">Part</th>
<th style="text-align: right; padding: 6px;">Unit Price</th>
<th th:each="step : ${ALL_WORKFLOW_STEPS}" style="padding: 6px; text-align: center;">
[(${step})]
</th>
</tr>
</thead>
<tbody>
<tr th:each="entry : ${LINE_ITEMS_WITH_MAPS}">
<td style="padding: 6px;">[(${entry['lineItem'].partName})]</td>
<td style="padding: 6px; text-align: right; white-space: nowrap;">
<span th:if="${entry.unitPrice != null}"
th:text="${(entry.currency != null and entry.currency != '') ? entry.currency + ' ' : ''} + ${#numbers.formatDecimal(entry.unitPrice, 1, 'COMMA', 2, 'POINT')}"></span>
<span th:if="${entry.unitPrice == null}">ā</span>
</td>
<td th:each="step : ${ALL_WORKFLOW_STEPS}" style="padding: 6px; text-align: center;">
<span th:if="${entry['stepMap'].containsKey(step) and entry['stepMap'].get(step).completed}">ā</span>
<span th:unless="${entry['stepMap'].containsKey(step) and entry['stepMap'].get(step).completed}">ā</span>
</td>
</tr>
</tbody>
</table>Order Traveller ā iterating orders
Use ORDERS to iterate over each order. Each order has: orderNumber, orderDate, customerName, customerGst, billingAddress, shippingAddress, purchaseOrderNumber, finalPrice (nullable), currency, accountingInvoiceNumber (nullable), accountingEstimateNumber (nullable), accountingConfirmationNumber (nullable), allWorkflowSteps, and lineItemsWithMaps. Each entry in lineItemsWithMaps exposes .unitPrice and .currency shortcuts for per-part pricing.
GROUP_NAME is a top-level variable (not per-order) ā it is the production group name when the traveller is downloaded from a production step, and blank otherwise.
<!-- Optional group name header ā only shown when downloaded from a production step -->
<h2 th:if="${GROUP_NAME}">Group: [(${GROUP_NAME})]</h2>
<div th:each="order : ${ORDERS}">
<h3>Order [(${order.orderNumber})] ā [(${order.customerName})]</h3>
<p>[(${order.orderDate})]</p>
<p th:if="${order.purchaseOrderNumber != null and !#strings.isEmpty(order.purchaseOrderNumber)}">
PO: [(${order.purchaseOrderNumber})]
</p>
<!-- Accounting reference ā show whichever is set -->
<p th:if="${order.accountingInvoiceNumber != null}">
Invoice ref: [(${order.accountingInvoiceNumber})]
</p>
<p th:if="${order.accountingEstimateNumber != null and order.accountingInvoiceNumber == null}">
Estimate ref: [(${order.accountingEstimateNumber})]
</p>
<!-- Parts with unit price -->
<table style="width: 100%; border-collapse: collapse;">
<tr th:each="entry : ${order.lineItemsWithMaps}">
<td>[(${entry['lineItem'].partName})]</td>
<td>Qty: [(${entry['lineItem'].quantity})]</td>
<td style="text-align: right;">
<span th:if="${entry.unitPrice != null}"
th:text="${(entry.currency != null and entry.currency != '') ? entry.currency + ' ' : ''} + ${#numbers.formatDecimal(entry.unitPrice, 1, 'COMMA', 2, 'POINT')}"></span>
<span th:if="${entry.unitPrice == null}">ā</span>
</td>
</tr>
</table>
<!-- Order total -->
<p th:if="${order.finalPrice != null}" style="text-align: right; font-weight: bold;">
Total:
<span th:text="${(order.currency != null and order.currency != '') ? order.currency + ' ' : ''} + ${#numbers.formatDecimal(order.finalPrice, 1, 'COMMA', 2, 'POINT')}"></span>
</p>
</div>Repeating page header and footer
Add id="page-header" or id="page-footer" to repeat an element on every page. Place the element anywhere in the <body> ā the renderer pulls it out of the normal flow automatically.
<div id="page-header">
[(${OPERATOR_NAME})] | Order [(${ORDER_NUMBER})]
</div>
<div id="page-footer" data-footer-height="40px">
Page <span class="page-current"></span> of <span class="page-total"></span>
</div>Use data-footer-height to control how much space is reserved at the bottom of each page. The default is 40px, which fits a single line. Increase it when your footer has more content:
<div id="page-footer" data-footer-height="60px">
<table width="100%">
<tr>
<td style="font-size: 8pt; color: #999;" th:text="${OPERATOR_NAME}"></td>
<td style="text-align: right; font-size: 8pt; color: #999;">
Page <span class="page-current"></span> of <span class="page-total"></span>
</td>
</tr>
<tr>
<td colspan="2" style="font-size: 7pt; color: #bbb;" th:text="${OPERATOR_EMAIL}"></td>
</tr>
</table>
</div>Page numbers ā use these two spans inside any footer or header content:
| Span | Renders as |
|---|---|
<span class="page-current"></span> | Current page number |
<span class="page-total"></span> | Total page count |
Side-by-side columns
The PDF renderer (OpenPDF) does not support flexbox or CSS Grid. Use <table> for multi-column layouts:
<table style="width: 100%;">
<tr>
<td style="vertical-align: top;">
<img th:if="${LOGO_IMG != null and !#strings.isEmpty(LOGO_IMG)}"
th:src="${'data:image/png;base64,' + LOGO_IMG}"
style="max-height: 40px;" alt="" />
</td>
<td style="vertical-align: top; text-align: right;">
[(${OPERATOR_NAME})]<br/>
[(${OPERATOR_EMAIL})]
</td>
</tr>
</table>Fonts
| Font | font-family value |
|---|---|
| Poppins (default) | Poppins |
| Arimo | Arimo |
| Gentium Plus | Gentium |
| Source Serif Pro | SourceSerif |
ā ļø For templates in Russian, Ukrainian, Bulgarian, or Greek use
Arimoto ensure all characters render correctly.
Page sizes
Standard templates default to A4. Custom Document, Group Traveller, and Order Traveller templates let you set any size in millimetres when creating the template.
| Size | Width Ć Height |
|---|---|
| A4 (default) | 210 Ć 297 mm |
| A3 | 297 Ć 420 mm |
| A5 | 148 Ć 210 mm |
| US Letter | 215.9 Ć 279.4 mm |
Write templates with AI
Describe what you want and an AI assistant will write the HTML for you. Use the buttons below to open a new chat with everything it needs already loaded.
Example prompts:
- "Create an invoice with logo on the left and company details on the right."
- "Build a parts table showing thumbnail, name, dimensions, and quantity."
- "Add a PO number row that only shows when a PO number is set."
- "Build a Group Traveller with a routing matrix showing which workflow steps each part goes through."
AI writing assistant
Opens a new chat and copies the context to your clipboard ā paste it in to get started.
Last updated on