E-commerce
Documents a shop sends or exchanges every day: invoices, shipping labels, marketplace feeds, emails and price lists. HTML emails and product feeds have their own pages: order confirmation email, Google Shopping feed, Magento import file.
Prepare amounts in PHP
Templates have no arithmetic: {item.qty * item.price} is printed as literal text, and @number(item.qty * item.price, 2) does not multiply. Row totals, tax and discounts are calculated where the data is built - see the Data tab of each example.
Each example has the template, the data, the generated output and the PHP call (how the examples work).
Invoice
A plain-text invoice with fixed-width columns. The registered @pad(value, width, "right") cuts or pads a value to its column, using mb_ functions so that ß and € count as one character (PHP tab). The payment line depends on whether the invoice is paid.
@pad(@concat("INVOICE ", invoice.number), 49)@pad(@date("j F Y", invoice.date), 20, "right")
{seller.name}, {seller.street}, {seller.postcode} {seller.city}
VAT {seller.vat_id}
Bill to:
{invoice.customer.company}
{invoice.customer.street}
{invoice.customer.postcode} {invoice.customer.city}
VAT {invoice.customer.vat_id}
@pad("SKU", 14)@pad("Description", 28)@pad("Qty", 5, "right")@pad("Price", 11, "right")@pad("Total", 11, "right")
---------------------------------------------------------------------
@for(invoice.items as item)
@pad(item.sku, 14)@pad(item.name, 28)@pad(item.qty, 5, "right")@pad(@number(item.price, 2), 11, "right")@pad(@number(item.row_total, 2), 11, "right")
@end
---------------------------------------------------------------------
@pad("Subtotal", 58, "right")@pad(@number(invoice.subtotal, 2), 11, "right")
@pad(@concat("VAT ", invoice.vat_rate, "%"), 58, "right")@pad(@number(invoice.vat, 2), 11, "right")
@pad("Total EUR", 58, "right")@pad(@number(invoice.total, 2), 11, "right")
@if(invoice.paid_at)
Paid on @date("j F Y", invoice.paid_at). Thank you!
@else
Please transfer EUR @number(invoice.total, 2) by @date("j F Y", invoice.due_date)
to {seller.iban}, reference {invoice.number}.
@end<?php
$items = [
['sku' => 'TEA-KYU-350', 'name' => 'Kyusu Teapot 350 ml', 'qty' => 6, 'price' => 32.00],
['sku' => 'TEA-SEN-100', 'name' => 'Sencha Asamushi 100 g', 'qty' => 40, 'price' => 8.90],
['sku' => 'TEA-MAT-SET', 'name' => 'Matcha Set, wholesale (6)', 'qty' => 10, 'price' => 21.50],
];
// Templates have no arithmetic: every amount on the invoice is calculated here.
foreach ($items as &$item) {
$item['row_total'] = $item['qty'] * $item['price'];
}
unset($item);
$subtotal = array_sum(array_column($items, 'row_total'));
$vat = round($subtotal * 0.21, 2);
return [
'seller' => [
'name' => 'Kumo Tea B.V.', 'street' => 'Keizersgracht 12', 'postcode' => '1015 CS', 'city' => 'Amsterdam',
'vat_id' => 'NL123456789B01', 'iban' => 'NL91 ABNA 0417 1643 00',
],
'invoice' => [
'number' => 'INV-2026-0418',
'date' => strtotime('2026-10-23'),
'due_date' => strtotime('2026-11-06'),
'paid_at' => null,
'customer' => [
'company' => 'Teehaus Lindner GmbH', 'street' => 'Bergmannstraße 5', 'postcode' => '10961', 'city' => 'Berlin',
'vat_id' => 'DE811907980',
],
'items' => $items,
'subtotal' => $subtotal,
'vat_rate' => 21,
'vat' => $vat,
'total' => $subtotal + $vat,
],
];INVOICE INV-2026-0418 23 October 2026
Kumo Tea B.V., Keizersgracht 12, 1015 CS Amsterdam
VAT NL123456789B01
Bill to:
Teehaus Lindner GmbH
Bergmannstraße 5
10961 Berlin
VAT DE811907980
SKU Description Qty Price Total
---------------------------------------------------------------------
TEA-KYU-350 Kyusu Teapot 350 ml 6 32.00 192.00
TEA-SEN-100 Sencha Asamushi 100 g 40 8.90 356.00
TEA-MAT-SET Matcha Set, wholesale (6) 10 21.50 215.00
---------------------------------------------------------------------
Subtotal 763.00
VAT 21% 160.23
Total EUR 923.23
Please transfer EUR 923.23 by 6 November 2026
to NL91 ABNA 0417 1643 00, reference INV-2026-0418.<?php
// Fixed-width columns: @pad(value, width) cuts or pads a value to the width, "right" aligns numbers.
$tsuku->registerFunction('pad', static function (mixed $value, int $width, string $align = 'left'): string {
$value = mb_substr((string) $value, 0, $width);
$padding = str_repeat(' ', $width - mb_strlen($value));
return $align === 'right' ? $padding . $value : $value . $padding;
});
echo $tsuku->process($template, $data);Shipping label
@upper() for the courier's scanner. The @if(recipient.company) line is indented like the address but prints nothing itself, so a private address has no empty line. The @match adds a service banner for express and Saturday deliveries only.
FROM @upper(sender.name)
@upper(sender.street)
@upper(sender.postcode) @upper(sender.city)
@upper(sender.country)
TO @upper(recipient.name)
@if(recipient.company)
@upper(recipient.company)
@end
@upper(recipient.street)
@upper(recipient.postcode) @upper(recipient.city)
@upper(recipient.country)
@match(shipment.service)
@case("express")
*** EXPRESS - DELIVER BEFORE 12:00 ***
@case("saturday")
*** SATURDAY DELIVERY ***
@end
TRACKING {shipment.tracking}
WEIGHT @number(shipment.weight, 2) kg
ORDER #{order.increment_id}<?php
return [
'sender' => ['name' => 'Kumo Tea', 'street' => 'Keizersgracht 12', 'postcode' => '1015 CS', 'city' => 'Amsterdam', 'country' => 'Netherlands'],
'recipient' => [
'name' => 'Lena Lindner',
'company' => 'Teehaus Lindner GmbH',
'street' => 'Bergmannstrasse 5',
'postcode' => '10961',
'city' => 'Berlin',
'country' => 'Germany',
],
'shipment' => ['service' => 'express', 'tracking' => 'JVGL0612345678', 'weight' => 4.25],
'order' => ['increment_id' => '000002207'],
];FROM KUMO TEA
KEIZERSGRACHT 12
1015 CS AMSTERDAM
NETHERLANDS
TO LENA LINDNER
TEEHAUS LINDNER GMBH
BERGMANNSTRASSE 5
10961 BERLIN
GERMANY
*** EXPRESS - DELIVER BEFORE 12:00 ***
TRACKING JVGL0612345678
WEIGHT 4.25 kg
ORDER #000002207echo $tsuku->process($template, $data);Marketplace feed
A tab-separated listing file, the format of marketplace "flat file" uploads. A tab or line break inside a value would start a new column or row, and @csv() does not handle tabs, so the example registers @tsv(), which replaces them with a space (PHP tab). product.bullets.1 reads a list element by index; a missing element is an empty field.
sku title brand ean price quantity condition main_image_url bullet_point_1 bullet_point_2
@for(products as product)
@tsv(product.sku) @tsv(product.name) @tsv(product.brand) {product.ean} @number(product.price, 2, ".", "") {product.qty} new @tsv(product.image) @tsv(product.bullets.0) @tsv(product.bullets.1)
@end<?php
return [
'products' => [
[
'sku' => 'TEA-KYU-350',
'name' => 'Kyusu Teapot 350 ml',
'brand' => 'Gyokko',
'ean' => '4901234567894',
'price' => 49.00,
'qty' => 12,
'image' => 'https://shop.example.com/media/catalog/product/k/y/kyusu-350.jpg',
'bullets' => ["Tokoname clay,\thand-made", "Fine mesh filter\nfor leaf tea"],
],
[
'sku' => 'TEA-SEN-100',
'name' => 'Sencha Asamushi 100 g',
'brand' => 'Kumo Tea',
'ean' => '8712345678906',
'price' => 14.90,
'qty' => 140,
'image' => 'https://shop.example.com/media/catalog/product/s/e/sencha-100.jpg',
'bullets' => ['First flush from Shizuoka'],
],
],
];sku title brand ean price quantity condition main_image_url bullet_point_1 bullet_point_2
TEA-KYU-350 Kyusu Teapot 350 ml Gyokko 4901234567894 49.00 12 new https://shop.example.com/media/catalog/product/k/y/kyusu-350.jpg Tokoname clay, hand-made Fine mesh filter for leaf tea
TEA-SEN-100 Sencha Asamushi 100 g Kumo Tea 8712345678906 14.90 140 new https://shop.example.com/media/catalog/product/s/e/sencha-100.jpg First flush from Shizuoka<?php
// A tab or line break inside a value would start a new column or row: replace them with a space.
$tsuku->registerFunction('tsv', static fn (mixed $value): string => preg_replace('/[\t\r\n]+/', ' ', (string) $value));
echo $tsuku->process($template, $data);Abandoned cart email
A plain-text email (text/plain, nothing to escape). The coupon paragraph depends on two conditions: with a coupon and a large enough basket the code applies, with a coupon and a smaller basket the email says how much is missing (@elseif), and without a coupon there is no paragraph. The blank line in front of each paragraph sits inside its branch, so the email never has two blank lines in a row.
Hi {customer.firstname},
you left these in your basket:
@for(cart.items as item)
{item.qty} x {item.name} (€@number(item.row_total, 2))
@end
Basket total: €@number(cart.subtotal, 2)
@if(coupon and cart.subtotal >= coupon.min_subtotal)
Order before @date("l, j F", coupon.expires_at) and use the code {coupon.code} for @number(coupon.percent, 0)% off.
@elseif(coupon)
Add €@number(coupon.missing, 2) more and use the code {coupon.code} for @number(coupon.percent, 0)% off.
@end
Continue where you left off:
{cart.url}
Kumo Tea<?php
$subtotal = 63.90;
$minSubtotal = 75.00;
// Plain-text email (text/plain): nothing to escape. Amounts are calculated in PHP.
return [
'customer' => ['firstname' => 'Jan'],
'cart' => [
'items' => [
['qty' => 1, 'name' => 'Kyusu Teapot 350 ml', 'row_total' => 49.00],
['qty' => 1, 'name' => 'Sencha Asamushi 100 g', 'row_total' => 14.90],
],
'subtotal' => $subtotal,
'url' => 'https://shop.example.com/checkout/cart/restore/token/c81e728d',
],
'coupon' => [
'code' => 'COMEBACK10',
'percent' => 10,
'min_subtotal' => $minSubtotal,
'missing' => max(0, $minSubtotal - $subtotal),
'expires_at' => strtotime('2026-10-31'),
],
];Hi Jan,
you left these in your basket:
1 x Kyusu Teapot 350 ml (€49.00)
1 x Sencha Asamushi 100 g (€14.90)
Basket total: €63.90
Add €11.10 more and use the code COMEBACK10 for 10% off.
Continue where you left off:
https://shop.example.com/checkout/cart/restore/token/c81e728d
Kumo Teaecho $tsuku->process($template, $data);Price list per customer group
A wholesale price list for one B2B customer. Magento stores a multiselect such as the allowed customer groups as ids joined with commas; customer.group_id in @split(product.customer_groups) tests for an exact id, so group 7231 sees the products for 2,7231 and 7231 but not the one for 17231,9 - a substring test such as @contains(product.customer_groups, "7231") would match that too. Discontinued products are left out with and; a condition may continue on the next line.
sku,name,unit,price,min_qty
@for(products as product)
@if(customer.group_id in @split(product.customer_groups)
and product.status == "active")
@csv(product.sku),@csv(product.name),@csv(product.unit),@number(product.price, 2, ".", ""),{product.min_qty}
@end
@end<?php
// customer_groups is a multiselect as Magento stores it: ids joined with commas.
return [
'customer' => ['company' => 'Teehaus Lindner GmbH', 'group_id' => 7231],
'products' => [
['sku' => 'TEA-KYU-350', 'name' => 'Kyusu Teapot 350 ml', 'unit' => 'piece', 'price' => 32.00, 'min_qty' => 6, 'status' => 'active', 'customer_groups' => '2,7231'],
['sku' => 'TEA-SEN-1KG', 'name' => 'Sencha Asamushi, bulk', 'unit' => 'kg', 'price' => 71.50, 'min_qty' => 1, 'status' => 'active', 'customer_groups' => '7231'],
['sku' => 'TEA-GYO-1KG', 'name' => 'Gyokuro, bulk', 'unit' => 'kg', 'price' => 148.00, 'min_qty' => 1, 'status' => 'active', 'customer_groups' => '17231,9'],
['sku' => 'TEA-MAT-SET', 'name' => 'Matcha Set, wholesale (6)', 'unit' => 'pack', 'price' => 129.00, 'min_qty' => 1, 'status' => 'discontinued', 'customer_groups' => '2,7231'],
],
];sku,name,unit,price,min_qty
TEA-KYU-350,Kyusu Teapot 350 ml,piece,32.00,6
TEA-SEN-1KG,"Sencha Asamushi, bulk",kg,71.50,1echo $tsuku->process($template, $data);Escaping by format
| Output | Escape text with | Numbers |
|---|---|---|
| HTML email, web page | @html() | @number(value, 2) |
| Plain-text email, label | nothing | @number(value, 2) |
| CSV | @csv() | @number(value, 2, ".", "") |
| TSV, semicolon CSV | a registered function (see above and CSV Exports) | @number(value, 2, ".", "") or @number(value, 2, ",", ".") |
| XML feed | @xml() | @number(value, 2, ".", "") |
| JSON | @json() | @number(value, 2, ".", "") |
Order numbers such as 000002203 are strings: print them with {order.increment_id}, because @number(order.increment_id) would print 2,203.
Next Steps
- HTML Generation → - the HTML order confirmation email
- Custom Functions → - functions such as
@padand@tsv - Security & Escaping → - escaping for every output format