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 \.
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
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],
],
];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,3echo $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.
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
// 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'],
],
],
];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=Bambooecho $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.
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
$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];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.90echo $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.
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
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],
],
];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_stockecho $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.
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
// 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];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%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.
Artikelnummer;Bezeichnung;Preis;Bestand
@for(products as product)
@ssv(product.sku);@ssv(product.name);@number(product.price, 2, ",", ".");{product.qty}
@end<?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],
],
];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
// @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().
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
// 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)'],
],
];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
// 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
$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
- XML Generation → - product feeds and sitemaps
- Line Continuation → - long rows over several template lines
- Security & Escaping → - escaping for every output format