Skip to content

CSV Exports ​

CSV looks simple until a value contains a comma, a quote or a line break, or a price is printed as 1,249.50. The rules used in every example below:

  • Text goes through @csv(value). It wraps the value in double quotes, doubling any " inside, when the value contains a comma, a double quote or a line break; other values are printed unchanged.
  • Numbers go through @number(value, 2, ".", ""). The default @number(value, 2) adds a comma as thousands separator, which splits the value into two columns.
  • Rows start at the beginning of the line, so directive lines (@for, @if, @end) are not indented here; they print nothing. A \ at the end of a line joins it with the next one, so a long row can be written over several template lines.

Each example has the template, the data, the generated output and the PHP call (how the examples work).

Product catalog ​

The products contain commas, double quotes and a line break; @csv() quotes exactly those fields, and the multi-line description stays one CSV field. The price 1249.50 keeps its full value because the thousands separator is switched off. The row is split over two template lines with \.

tsuku
sku,name,description,price,qty
@for(products as product)
@csv(product.sku),@csv(product.name),@csv(product.description),\
@number(product.price, 2, ".", ""),{product.qty}
@end
php
<?php

return [
    'products' => [
        ['sku' => 'TEA-KYU-350', 'name' => 'Kyusu Teapot 350 ml', 'description' => 'Tokoname clay, fine mesh filter', 'price' => 49.00, 'qty' => 12],
        ['sku' => 'TEA-TET-080', 'name' => 'Cast Iron Kettle "Tetsubin" 0.8 l', 'description' => 'Enamelled inside, for gas, electric & induction', 'price' => 89.00, 'qty' => 0],
        ['sku' => 'TEA-SET-PRO', 'name' => 'Tea Room Set, 12 pieces', 'description' => "Teapot, six cups and five coasters.\nGift box included.", 'price' => 1249.50, 'qty' => 3],
    ],
];
csv
sku,name,description,price,qty
TEA-KYU-350,Kyusu Teapot 350 ml,"Tokoname clay, fine mesh filter",49.00,12
TEA-TET-080,"Cast Iron Kettle ""Tetsubin"" 0.8 l","Enamelled inside, for gas, electric & induction",89.00,0
TEA-SET-PRO,"Tea Room Set, 12 pieces","Teapot, six cups and five coasters.
Gift box included.",1249.50,3
php
echo $tsuku->process($template, $data);

Magento product import ​

A file for Magento's System > Data Transfer > Import. Several category paths go into one column with @csv(@join(product.categories, ",")), the code=value pairs of additional_attributes likewise. special_price stays empty when there is none, and is_in_stock uses or: a product with backorders counts as in stock.

tsuku
sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,price,special_price,qty,is_in_stock,visibility,url_key,additional_attributes
@for(products as product)
@csv(product.sku),,Default,simple,@csv(@join(product.categories, ",")),base,@csv(product.name),\
@number(product.price, 2, ".", ""),@if(product.special_price)@number(product.special_price, 2, ".", "")@end,\
@number(product.qty, 0, ".", ""),@?{product.qty > 0 or product.backorders "1" : "0"},\
"Catalog, Search",@csv(product.url_key),@csv(@join(product.attributes, ","))
@end
php
<?php

// Magento's import format: several category paths in one column, "code=value" pairs in additional_attributes.
return [
    'products' => [
        [
            'sku' => 'TEA-KYU-350',
            'categories' => ['Default Category/Teaware/Teapots', 'Default Category/New'],
            'name' => 'Kyusu Teapot 350 ml',
            'price' => 49.00,
            'special_price' => null,
            'qty' => 12,
            'backorders' => false,
            'url_key' => 'kyusu-teapot-350-ml',
            'attributes' => ['color=Red-brown', 'material=Tokoname clay'],
        ],
        [
            'sku' => 'TEA-MAT-SET',
            'categories' => ['Default Category/Teaware/Matcha'],
            'name' => 'Matcha Whisk & Bowl Set',
            'price' => 39.90,
            'special_price' => 29.90,
            'qty' => 0,
            'backorders' => true,
            'url_key' => 'matcha-whisk-bowl-set',
            'attributes' => ['material=Bamboo'],
        ],
    ],
];
csv
sku,store_view_code,attribute_set_code,product_type,categories,product_websites,name,price,special_price,qty,is_in_stock,visibility,url_key,additional_attributes
TEA-KYU-350,,Default,simple,"Default Category/Teaware/Teapots,Default Category/New",base,Kyusu Teapot 350 ml,49.00,,12,1,"Catalog, Search",kyusu-teapot-350-ml,"color=Red-brown,material=Tokoname clay"
TEA-MAT-SET,,Default,simple,Default Category/Teaware/Matcha,base,Matcha Whisk & Bowl Set,39.90,29.90,0,1,"Catalog, Search",matcha-whisk-bowl-set,material=Bamboo
php
echo $tsuku->process($template, $data);

Orders with line items ​

One row per order line, with the order columns filled only on the first line of each order: the inner loop's index is 0 for the first item. The row totals come from PHP, because templates have no arithmetic. @else,,@end writes the two empty columns; the body starts right after @else, so no space is printed.

tsuku
order,date,customer,sku,qty,price,row_total
@for(orders as order)
@for(order.items as item, index)
@if(index == 0){order.increment_id},@date("Y-m-d", order.created_at),@csv(order.customer)@else,,@end,\
@csv(item.sku),{item.qty},@number(item.price, 2, ".", ""),@number(item.row_total, 2, ".", "")
@end
@end
php
<?php

$orders = [
    ['increment_id' => '000002203', 'created_at' => strtotime('2026-10-21'), 'customer' => 'de Vries, Anna', 'items' => [
        ['sku' => 'TEA-KYU-350', 'qty' => 1, 'price' => 49.00],
        ['sku' => 'TEA-SEN-100', 'qty' => 2, 'price' => 14.90],
    ]],
    ['increment_id' => '000002204', 'created_at' => strtotime('2026-10-22'), 'customer' => 'Jan Kowalski', 'items' => [
        ['sku' => 'TEA-MAT-SET', 'qty' => 1, 'price' => 29.90],
    ]],
];

// Templates have no arithmetic: row totals are calculated here.
foreach ($orders as &$order) {
    foreach ($order['items'] as &$item) {
        $item['row_total'] = $item['qty'] * $item['price'];
    }
}
unset($order, $item);

return ['orders' => $orders];
csv
order,date,customer,sku,qty,price,row_total
000002203,2026-10-21,"de Vries, Anna",TEA-KYU-350,1,49.00,49.00
,,,TEA-SEN-100,2,14.90,29.80
000002204,2026-10-22,Jan Kowalski,TEA-MAT-SET,1,29.90,29.90
php
echo $tsuku->process($template, $data);

Stock report ​

The status column is an @if / @elseif / @else chain that starts at the end of the row. Because content precedes the @if on its line, the block is inline: the line break and indentation at the start of each branch are dropped, so a branch continues the row, and the line break at its end finishes it. The first branch combines two conditions with and not. See Whitespace Control.

tsuku
sku,name,qty,reorder_level,status
@for(items as item)
@csv(item.sku),@csv(item.name),{item.qty},{item.reorder_level},@if(item.qty == 0 and not item.incoming_at)
    out_of_stock
@elseif(item.qty == 0)
    incoming @date("Y-m-d", item.incoming_at)
@elseif(item.qty <= item.reorder_level)
    reorder
@else
    ok
@end
@end
php
<?php

return [
    'items' => [
        ['sku' => 'TEA-KYU-350', 'name' => 'Kyusu Teapot 350 ml', 'qty' => 12, 'reorder_level' => 5, 'incoming_at' => null],
        ['sku' => 'TEA-SEN-100', 'name' => 'Sencha Asamushi 100 g', 'qty' => 18, 'reorder_level' => 40, 'incoming_at' => null],
        ['sku' => 'TEA-MAT-SET', 'name' => 'Matcha Whisk & Bowl Set', 'qty' => 0, 'reorder_level' => 5, 'incoming_at' => strtotime('2026-11-04')],
        ['sku' => 'TEA-TET-080', 'name' => 'Cast Iron Kettle "Tetsubin" 0.8 l', 'qty' => 0, 'reorder_level' => 2, 'incoming_at' => null],
    ],
];
csv
sku,name,qty,reorder_level,status
TEA-KYU-350,Kyusu Teapot 350 ml,12,5,ok
TEA-SEN-100,Sencha Asamushi 100 g,18,40,reorder
TEA-MAT-SET,Matcha Whisk & Bowl Set,0,5,incoming 2026-11-04
TEA-TET-080,"Cast Iron Kettle ""Tetsubin"" 0.8 l",0,2,out_of_stock
php
echo $tsuku->process($template, $data);

Optional columns ​

The same @if(include_cost) adds the cost columns to the header and to every row. An @end that follows content keeps the line break after it, so every line ends with exactly one line break whether the columns are printed or not; with include_cost set to false the file has three columns. The margin is calculated in PHP.

tsuku
sku,name,price@if(include_cost),cost,margin@end
@for(products as product)
@csv(product.sku),@csv(product.name),@number(product.price, 2, ".", "")@if(include_cost),@number(product.cost, 2, ".", ""),@number(product.margin, 1, ".", "")%@end
@end
php
<?php

// Cost columns only for the purchasing team; the margin is calculated here, not in the template.
$products = [
    ['sku' => 'TEA-KYU-350', 'name' => 'Kyusu Teapot 350 ml', 'price' => 49.00, 'cost' => 21.50],
    ['sku' => 'TEA-SEN-100', 'name' => 'Sencha Asamushi 100 g', 'price' => 14.90, 'cost' => 6.20],
];
foreach ($products as &$product) {
    $product['margin'] = ($product['price'] - $product['cost']) / $product['price'] * 100;
}
unset($product);

return ['include_cost' => true, 'products' => $products];
csv
sku,name,price,cost,margin
TEA-KYU-350,Kyusu Teapot 350 ml,49.00,21.50,56.1%
TEA-SEN-100,Sencha Asamushi 100 g,14.90,6.20,58.4%
php
echo $tsuku->process($template, $data);

Semicolon-separated file ​

German and Dutch spreadsheet settings expect ; between fields and a decimal comma. @number(value, 2, ",", ".") writes 1.249,50. @csv() quotes values with a comma, not with a semicolon, so the example registers @ssv(), which quotes for this format (PHP tab). A tab-separated file needs the same kind of function: see the marketplace feed.

tsuku
Artikelnummer;Bezeichnung;Preis;Bestand
@for(products as product)
@ssv(product.sku);@ssv(product.name);@number(product.price, 2, ",", ".");{product.qty}
@end
php
<?php

return [
    'products' => [
        ['sku' => 'TEA-KYU-350', 'name' => 'Kyusu-Teekanne 350 ml', 'price' => 49.00, 'qty' => 12],
        ['sku' => 'TEA-TET-080', 'name' => 'Gusseisen-Kessel "Tetsubin" 0,8 l', 'price' => 89.00, 'qty' => 0],
        ['sku' => 'TEA-SET-PRO', 'name' => 'Teeset; 12-teilig', 'price' => 1249.50, 'qty' => 3],
    ],
];
csv
Artikelnummer;Bezeichnung;Preis;Bestand
TEA-KYU-350;Kyusu-Teekanne 350 ml;49,00;12
TEA-TET-080;"Gusseisen-Kessel ""Tetsubin"" 0,8 l";89,00;0
TEA-SET-PRO;"Teeset; 12-teilig";1.249,50;3
php
<?php

// @csv() quotes values that contain a comma. A semicolon file needs quotes around ";" instead.
$tsuku->registerFunction('ssv', static function (mixed $value): string {
    $value = (string) $value;

    return preg_match('/[;"\r\n]/', $value) === 1 ? '"' . str_replace('"', '""', $value) . '"' : $value;
});

echo $tsuku->process($template, $data);

Safe to open in a spreadsheet ​

@csv() escapes the CSV format only. A value that starts with =, +, - or @ runs as a formula when the file is opened in Excel or Google Sheets, so an export of anything visitors typed needs more: the registered @csv_safe() puts an apostrophe in front of such values and then quotes like @csv().

tsuku
submitted,email,name,message
@for(messages as message)
@date("Y-m-d H:i", message.submitted_at),@csv_safe(message.email),@csv_safe(message.name),@csv_safe(message.message)
@end
php
<?php

// Contact form submissions, exported for the support team.
return [
    'messages' => [
        ['submitted_at' => strtotime('2026-10-20 09:12'), 'email' => '[email protected]', 'name' => 'Anna', 'message' => 'Is the kyusu dishwasher safe?'],
        ['submitted_at' => strtotime('2026-10-20 11:47'), 'email' => '[email protected]', 'name' => '=HYPERLINK("https://evil.example","Click for a refund")', 'message' => '@SUM(1+1)'],
    ],
];
csv
submitted,email,name,message
2026-10-20 09:12,[email protected],Anna,Is the kyusu dishwasher safe?
2026-10-20 11:47,[email protected],"'=HYPERLINK(""https://evil.example"",""Click for a refund"")",'@SUM(1+1)
php
<?php

// Values typed by visitors can start with = + - @ and run as a formula in Excel or Google Sheets.
// A leading apostrophe makes the spreadsheet show them as text; then the value is quoted like @csv().
$tsuku->registerFunction('csv_safe', static function (mixed $value): string {
    $value = (string) $value;
    if ($value !== '' && str_contains("=+-@\t\r", $value[0])) {
        $value = "'" . $value;
    }

    return preg_match('/[,"\r\n]/', $value) === 1 ? '"' . str_replace('"', '""', $value) . '"' : $value;
});

echo $tsuku->process($template, $data);

Serving the file ​

php
$csv = $tsuku->process($template, $data);

header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="products-' . date('Y-m-d') . '.csv"');
echo "\xEF\xBB\xBF"; // UTF-8 byte order mark, so Excel reads umlauts and € correctly
echo $csv;

process() returns the whole file as one string. For exports with many thousands of rows, processToStream() renders the header, each row and the footer separately and hands every piece to a callback such as fwrite(), so memory use stays flat - see Streaming Large Datasets.

Next Steps ​

Released under the MIT License.