Skip to content

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.

tsuku
@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
<?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,
    ],
];
txt
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
<?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.

tsuku
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
<?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'],
];
txt
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     #000002207
php
echo $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.

tsuku
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
<?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'],
        ],
    ],
];
tsv
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
<?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.

tsuku
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
<?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'),
    ],
];
txt
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 Tea
php
echo $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.

tsuku
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
<?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'],
    ],
];
csv
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,1
php
echo $tsuku->process($template, $data);

Escaping by format ​

OutputEscape text withNumbers
HTML email, web page@html()@number(value, 2)
Plain-text email, labelnothing@number(value, 2)
CSV@csv()@number(value, 2, ".", "")
TSV, semicolon CSVa 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 ​

Released under the MIT License.