Best Practices
Effective patterns and recommendations for using Tsuku in production.
Template Organization
Keep Templates Simple
Good - Simple and readable:
$template = 'Name,Email,Status
@for(users as user)
@csv(user.name),@csv(user.email),{user.status}
@end';
$data = ['users' => [
['name' => 'Ann', 'email' => '[email protected]', 'status' => 'active'],
['name' => 'Bob, Jr.', 'email' => '[email protected]', 'status' => 'pending'],
]];
echo $tsuku->process($template, $data);
// Output:
// Name,Email,Status
// Ann,[email protected],active
// "Bob, Jr.",[email protected],pendingBad - Too complex:
$template = '@for(users as user)@if(user.active)@if(user.email)@if(user.verified){user.name}@end@end@end@end';Better - one condition, one line per row:
$template = '@for(users as user)
@if(user.active and user.email and user.verified)
{user.name}
@end
@end';
$data = ['users' => [
['name' => 'Ann', 'active' => true, 'email' => '[email protected]', 'verified' => true],
['name' => 'Bob', 'active' => true, 'email' => '', 'verified' => true],
]];
echo $tsuku->process($template, $data);
// Output: AnnSince 1.4.0 conditions understand and/or/not (and &&/||/!), parentheses and in - see Conditionals. Lines that hold only directives produce no output, so the directives can sit on their own lines.
Break Down Complex Templates
For complex output, write the parts as separate strings and combine them into one template before processing. One process() call parses the template once and renders every row:
// Header part
$header = 'Product Report - @date("F j, Y", generated_at)
SKU,Name,Price,Stock';
// Row part
$row = '@csv(product.sku),@csv(product.name),@number(product.price, 2, ".", ""),{product.stock}';
// One template, one call
$template = $header . "\n@for(products as product)\n" . $row . "\n@end";
$output = $tsuku->process($template, [
'generated_at' => 1761350400,
'products' => $products,
]);With $products = [['sku' => 'WID-001', 'name' => 'Widget', 'price' => 1299.5, 'stock' => 5], ['sku' => 'GAD-002', 'name' => 'Gadget, Deluxe', 'price' => 29.99, 'stock' => 0]] the output is:
Product Report - October 25, 2025
SKU,Name,Price,Stock
WID-001,Widget,1299.50,5
GAD-002,"Gadget, Deluxe",29.99,0Calling process() once per row would parse the row template again for every product (see Performance). Note @number(price, 2, ".", ""): the default thousands separator (1,299.50) would split a CSV column in two.
Use Consistent Formatting
Indent templates the way you indent code. Whitespace control (1.4.0) drops the indentation of directive-only lines and of @case/@default/@else/@end, so the output stays clean:
Readable formatting:
$template = '@for(orders as order)
Order #{order.id}
Customer: {order.customer.name}
Total: $@number(order.total, 2)
Status: @match(order.status)
@case("pending")
Pending
@case("shipped")
Shipped
@case("delivered")
Delivered
@default
Unknown
@end
@end';
$data = ['orders' => [
['id' => 1001, 'customer' => ['name' => 'Ann'], 'total' => 59.9, 'status' => 'shipped'],
['id' => 1002, 'customer' => ['name' => 'Bob'], 'total' => 5, 'status' => 'lost'],
]];
echo $tsuku->process($template, $data);Output:
Order #1001
Customer: Ann
Total: $59.90
Status: Shipped
Order #1002
Customer: Bob
Total: $5.00
Status: UnknownPut each case body on its own line, as above. A body written on the directive's line keeps its spaces: @default Unknown would print Status: Unknown (two spaces). The rules are on the Whitespace Control page; you no longer need \ line continuations just to keep indented directives out of the output.
Security Best Practices
Always Escape User Input
Critical Rule: Escape ALL user-provided data.
$data = ['user' => ['comment' => '<script>alert(1)</script>']];
// ❌ WRONG - XSS vulnerability
$template = '<div class="comment">{user.comment}</div>';
// Output: <div class="comment"><script>alert(1)</script></div>
// ✅ CORRECT - XSS protected
$template = '<div class="comment">@html(user.comment)</div>';
// Output: <div class="comment"><script>alert(1)</script></div>Choose the Right Escaper
| Output Format | Use |
|---|---|
| HTML content | @html() |
| HTML attributes | @html() |
| XML | @xml() |
| CSV | @csv() |
| JSON | @json() (it adds the quotes itself) |
| URLs | @url() |
Validate Before Templating
// Validate data before processing
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Invalid email');
}
// Then use in template (still escape!)
$template = 'Email: @html(email)';
$data = ['email' => '[email protected]'];
// Output: Email: [email protected]Never Build SQL with Templates
// ❌ NEVER DO THIS - SQL injection risk
$template = 'SELECT * FROM users WHERE name = "{name}"';
// ✅ Use prepared statements instead
$stmt = $pdo->prepare('SELECT * FROM users WHERE name = ?');
$stmt->execute([$name]);Write @@ for a Literal @
Every @ in template text starts a directive or function, so an e-mail address or a CSS @media rule in a template throws a ParseException. Double it:
$template = 'Questions? Write to support@@example.com';
// Output: Questions? Write to [email protected]
$template = 'Questions? Write to [email protected]';
// Throws: ParseException: Invalid @ syntax at line 1, column 28 (write @@ for a literal @)Values from your data are never parsed, so an @ in {user.email} needs no escaping. See Escapes & Special Characters.
Performance Optimization
Reuse Tsuku Instance
Good - Reuse instance:
$tsuku = new Tsuku();
foreach ($batches as $batch) {
$output = $tsuku->process($template, $batch);
// Process output...
}Bad - Creating new instance every time:
foreach ($batches as $batch) {
$tsuku = new Tsuku(); // Wasteful!
$output = $tsuku->process($template, $batch);
}Cache Parsed Templates
process() parses the template on every call; there is no built-in template cache. For a template that you render many separate times, parse it once and reuse the tree:
use Qoliber\Tsuku\Compiler\Compiler;
use Qoliber\Tsuku\DirectiveRegistry;
use Qoliber\Tsuku\Function\FunctionRegistry;
use Qoliber\Tsuku\ProcessingContext;
use Qoliber\Tsuku\StrictnessMode;
use Qoliber\Tsuku\Template\TemplateParser;
$functions = new FunctionRegistry(); // built-in functions; register() your own here
$directives = new DirectiveRegistry(); // register() custom directives here
$context = new ProcessingContext(StrictnessMode::SILENT);
// Parse template once
$ast = (new TemplateParser($functions, $directives))->parse($template);
// Reuse AST for multiple data sets
foreach ($dataSets as $data) {
$compiler = new Compiler($data, $context, $functions, $directives);
$output = $compiler->compile($ast);
// Process output...
}Functions and directives registered on a Tsuku instance are not visible here - register them on the registries you pass in. For one big export (one process() call with all rows) this makes no difference; see Performance.
Stream Large Exports
For tens of thousands of rows use processToStream(): it renders the template row by row from any iterable (for example a generator reading from the database) and hands each piece to a callback, so neither the rows nor the output have to fit in memory.
Register Functions Once
class TemplateService
{
private Tsuku $tsuku;
public function __construct()
{
$this->tsuku = new Tsuku();
// Register all functions once
$this->registerFunctions();
}
private function registerFunctions(): void
{
$this->tsuku->registerFunction('currency', function(float $amount): string {
return '$' . number_format($amount, 2);
});
// Register other functions...
}
public function process(string $template, array $data): string
{
return $this->tsuku->process($template, $data);
}
}Data Preparation
Prepare Data Before Templating
Transform data into template-friendly structure:
// Transform complex objects into simple arrays
$data = [
'products' => array_map(function($product) {
return [
'id' => $product->getId(),
'name' => $product->getName(),
'price' => $product->getFinalPrice(),
'stock' => $product->getStockQty(),
];
}, $productCollection)
];
$template = '@for(products as product)
{product.id},{product.name},{product.price}
@end';Compute Values in PHP
Templates have no arithmetic: {item.qty * item.price} is printed as literal text, and x - 1 inside a condition throws a ParseException. Compute totals, margins and counters in PHP and pass them in:
$template = '@for(items as item)
{item.name}: {item.qty} x $@number(item.price, 2) = $@number(item.line_total, 2)
@end';
$data = ['items' => array_map(
fn(array $item): array => $item + ['line_total' => $item['qty'] * $item['price']],
[['name' => 'Widget', 'qty' => 2, 'price' => 9.5], ['name' => 'Gadget', 'qty' => 1, 'price' => 20]]
)];
echo $tsuku->process($template, $data);
// Output:
// Widget: 2 x $9.50 = $19.00
// Gadget: 1 x $20.00 = $20.00The same goes for "is this the last item?" - there is no loop.last. Use the index for the first item (@if(index > 0)), or precompute a flag in PHP.
Use Smart Object Access
Tsuku automatically calls getters:
class Product
{
public function __construct(
private string $name,
private float $price,
private bool $available,
) {
}
public function getName(): string { return $this->name; }
public function getPrice(): float { return $this->price; }
public function isAvailable(): bool { return $this->available; }
}
// Template automatically uses getName(), getPrice(), isAvailable()
$template = '@if(product.available)
{product.name}: $@number(product.price, 2)
@end';
$data = ['product' => new Product('Widget', 9.5, true)];
echo $tsuku->process($template, $data);
// Output: Widget: $9.50@for only loops over arrays: convert collections and iterators with iterator_to_array() (or stream them with processToStream(), which accepts any iterable).
Check Group/Category Ids with @split
in with a string on the right is a substring test, so "7231" in "72310,5" is true. For comma-separated id lists, split them first - in with a list compares whole values:
$template = '@if("7231" in product.group_ids)substring match!@end';
$data = ['product' => ['group_ids' => '72310,5']];
// Output: substring match!
$template = '@if("7231" in @split(product.group_ids))B2B price@else Retail price@end';
$data = ['product' => ['group_ids' => '72310,7231,5']];
// Output: B2B priceError Handling
Provide Fallback Values
Use @default() for missing data:
$template = 'Name: @default(user.name, "Unknown")
Email: @default(user.email, "N/A")';
$data = ['user' => ['name' => 'Ann', 'email' => '']];
// Output:
// Name: Ann
// Email: N/A@default() uses PHP's empty(), so 0 and "0" count as empty too: @default(stock, "N/A") prints N/A for a stock of 0. Use @if for numbers.
Validate Template Syntax
Catch syntax errors early. All Tsuku exceptions extend Qoliber\Tsuku\Exception\TsukuException:
use Qoliber\Tsuku\Exception\FunctionNotFoundException;
use Qoliber\Tsuku\Exception\ParseException;
use Qoliber\Tsuku\Exception\TsukuException;
try {
$result = $tsuku->process($template, $data);
} catch (ParseException $e) {
// Broken template: unclosed @for, bare @, "=" instead of "==", arithmetic in a condition, ...
error_log(sprintf(
'Template error at line %d, column %d: %s',
$e->getTemplateLine(),
$e->getTemplateColumn(),
$e->getMessage()
));
$result = "Error processing template";
} catch (FunctionNotFoundException $e) {
// A condition calls a function that is not registered
error_log('Unknown function @' . $e->getFunctionName());
$result = "Error processing template";
} catch (TsukuException $e) {
// STRICT mode: missing variable or function, or a function threw
error_log('Rendering failed: ' . $e->getMessage());
$result = "Error processing template";
}What each one looks like:
$template = 'SKU,Name
@for(products as product)
{product.sku},{product.name}
';
// Throws: ParseException: Expected @end but reached end of file at line 4, column 1 (@for opened at line 2, column 1)
$template = '@if(@is_sale(product))Sale@end';
// Throws: FunctionNotFoundException: Unknown function '@is_sale' at line 1, column 5Parse errors are thrown in every strictness mode. Missing variables and functions are only errors in STRICT mode - see Strictness Modes & Errors.
Run Tests and CI in STRICT Mode
A typo in a variable name renders as empty output in the default SILENT mode. Render your templates in STRICT mode in tests so the typo fails the build:
use Qoliber\Tsuku\StrictnessMode;
$tsuku = new Tsuku(StrictnessMode::STRICT);
$tsuku->process('{product.nmae}', ['product' => ['name' => 'Widget']]);
// Throws: TsukuException: Variable 'product.nmae' not found in dataTest with Edge Cases
$edgeCases = [
'empty_array' => [],
'null_values' => ['name' => null],
'special_chars' => ['text' => '<script>alert("XSS")</script>'],
'unicode' => ['text' => '你好世界 🌍'],
];
foreach ($edgeCases as $name => $data) {
$result = $tsuku->process($template, $data);
// Verify output...
}Common Patterns
CSV Export Pattern
$template = 'SKU,Name,Description,Price,Stock
@for(products as product)
@csv(product.sku),@csv(product.name),@csv(product.description),@number(product.price, 2, ".", ""),{product.stock}
@end';
$data = ['products' => [
['sku' => 'WID-001', 'name' => 'Widget', 'description' => 'A "premium" widget, new', 'price' => 1299.5, 'stock' => 5],
['sku' => 'GAD-002', 'name' => 'Gadget', 'description' => 'Simple gadget', 'price' => 29.99, 'stock' => 0],
]];
$result = $tsuku->process($template, $data);
file_put_contents('export.csv', $result);Result:
SKU,Name,Description,Price,Stock
WID-001,Widget,"A ""premium"" widget, new",1299.50,5
GAD-002,Gadget,Simple gadget,29.99,0XML Feed Pattern
$template = '<?xml version="1.0" encoding="UTF-8"?>
<products>
@for(products as product)
<product id="{product.id}">
<name>@xml(product.name)</name>
<description>@xml(product.description)</description>
<price>@number(product.price, 2, ".", "")</price>
<availability>@match(product.stock)
@case(0)
out_of_stock
@default
in_stock
@end</availability>
</product>
@end
</products>';
$data = ['products' => [
['id' => 1, 'name' => 'Fish & Chips', 'description' => '<b>Crispy</b>', 'price' => 1299.5, 'stock' => 5],
['id' => 2, 'name' => 'Gadget', 'description' => 'Simple', 'price' => 29.99, 'stock' => 0],
]];
echo $tsuku->process($template, $data);Output:
<?xml version="1.0" encoding="UTF-8"?>
<products>
<product id="1">
<name>Fish & Chips</name>
<description><b>Crispy</b></description>
<price>1299.50</price>
<availability>in_stock</availability>
</product>
<product id="2">
<name>Gadget</name>
<description>Simple</description>
<price>29.99</price>
<availability>out_of_stock</availability>
</product>
</products>The @match opens after <availability> and its @end is followed by </availability>, so the whole block renders inside that one line.
JSON API Response Pattern
There is no loop.last, so put the comma before every element except the first. Start the loop at the end of the [ line and end each element with }@end:
$template = '{
"success": true,
"data": [@for(items as item, index)@if(index > 0),@end
{
"id": {item.id},
"name": @json(item.name),
"description": @json(item.description)
}@end
]
}';
$data = ['items' => [
['id' => 1, 'name' => 'Widget', 'description' => 'Small'],
['id' => 2, 'name' => 'Gadget', 'description' => 'Large'],
]];
echo $tsuku->process($template, $data);Output:
{
"success": true,
"data": [
{
"id": 1,
"name": "Widget",
"description": "Small"
},
{
"id": 2,
"name": "Gadget",
"description": "Large"
}
]
}index is the array key, so pass a list (array_values()). The 1.3 idiom @if(index < length(items) - 1),@end never printed a comma and throws a ParseException in 1.4.0 (no arithmetic in conditions).
Email Template Pattern
$template = 'Dear @html(customer.name),
Your order #@number(order.id, 0, "", "") has been confirmed.
Order Details:
@for(order.items as item)
- @html(item.name) x{item.qty} - $@number(item.price, 2)
@end
Total: $@number(order.total, 2)
Thank you for your purchase!';
$data = [
'customer' => ['name' => 'Ann'],
'order' => [
'id' => 10042,
'items' => [['name' => 'Widget', 'qty' => 2, 'price' => 9.5], ['name' => 'Gadget', 'qty' => 1, 'price' => 1200]],
'total' => 1219,
],
];
echo $tsuku->process($template, $data);Output:
Dear Ann,
Your order #10042 has been confirmed.
Order Details:
- Widget x2 - $9.50
- Gadget x1 - $1,200.00
Total: $1,219.00
Thank you for your purchase!Wrapper Pattern: Custom Directives
When the same wrapper appears around many values, register a custom directive instead of repeating it. The handler receives the rendered body:
$tsuku->registerDirective('cdata', fn(string $body): string =>
'<![CDATA[' . str_replace(']]>', ']]]]><![CDATA[>', $body) . ']]>'
);
$template = '<name>@cdata{product.name}@end</name>';
$data = ['product' => ['name' => 'Fish & Chips']];
// Output: <name><![CDATA[Fish & Chips]]></name>Custom Functions Best Practices
Type Hints and Return Types
Types are optional - Tsuku calls any callable and casts its return value to a string - but they document the function and catch bad data:
// ✅ Good - Clear types
$tsuku->registerFunction('format_phone', function(string $phone): string {
return preg_replace('/(\d{3})(\d{3})(\d{4})/', '($1) $2-$3', $phone);
});
// ❌ Bad - No types
$tsuku->registerFunction('format_phone', function($phone) {
return preg_replace('/(\d{3})(\d{3})(\d{4})/', '($1) $2-$3', $phone);
});Arguments arrive as they are in your data (a number written in the template, like the 2 in @substr(name, 0, 2), arrives as the string "2"); PHP converts scalars to the declared type. When the conversion fails (an array passed to a string parameter), the call throws a TypeError, which Tsuku handles like any error in a function: empty output in SILENT mode, a warning in WARNING mode, a TsukuException ("Function 'format_phone' error: ...") in STRICT mode.
Do not start function names with end, else or default, and do not use for, if, unless, match, case or elseif: the template would read @endpoint(...) as @end and @if(...) as the directive. Registration does not check names - see Custom Functions.
Pure Functions
Keep functions side-effect free:
// ✅ Good - Pure function
$tsuku->registerFunction('format_sku', function(string $sku): string {
return strtoupper(trim($sku));
});
// ❌ Bad - Side effects
$tsuku->registerFunction('log_product', function(string $id): string {
Database::insert('logs', ['product_id' => $id]); // Don't do this!
return $id;
});Escape Output in Functions
The return value of a function is printed as it is - nothing escapes it for you:
$tsuku->registerFunction('user_badge', function(string $name, string $role): string {
$safeName = htmlspecialchars($name, ENT_QUOTES, 'UTF-8');
$safeRole = htmlspecialchars($role, ENT_QUOTES, 'UTF-8');
return "<span class=\"badge\">{$safeName} - {$safeRole}</span>";
});
$template = '@user_badge(user.name, user.role)';
$data = ['user' => ['name' => 'Ann <3', 'role' => 'admin']];
// Output: <span class="badge">Ann <3 - admin</span>Testing Templates
Unit Test Your Templates
use PHPUnit\Framework\TestCase;
use Qoliber\Tsuku\StrictnessMode;
use Qoliber\Tsuku\Tsuku;
class ProductExportTest extends TestCase
{
private Tsuku $tsuku;
protected function setUp(): void
{
// STRICT: a missing variable fails the test instead of rendering empty
$this->tsuku = new Tsuku(StrictnessMode::STRICT);
}
public function testCsvExport(): void
{
$template = 'SKU,Name
@for(products as product)
@csv(product.sku),@csv(product.name)
@end';
$data = [
'products' => [
['sku' => 'ABC-123', 'name' => 'Widget'],
['sku' => 'XYZ-789', 'name' => 'Gadget'],
]
];
$result = $this->tsuku->process($template, $data);
$this->assertSame("SKU,Name\nABC-123,Widget\nXYZ-789,Gadget\n", $result);
}
public function testXssProtection(): void
{
$template = '@html(text)';
$data = ['text' => '<script>alert("XSS")</script>'];
$result = $this->tsuku->process($template, $data);
$this->assertStringNotContainsString('<script>', $result);
$this->assertStringContainsString('<script>', $result);
}
}Test Edge Cases
public function testEmptyArray(): void
{
$template = '@for(items as item){item}@end';
$result = $this->tsuku->process($template, ['items' => []]);
$this->assertEmpty($result);
}
public function testNestedLoops(): void
{
$template = '@for(categories as category)
@for(category.products as product)
{product.name}
@end
@end';
$data = [
'categories' => [
['products' => [['name' => 'A'], ['name' => 'B']]],
['products' => [['name' => 'C']]],
]
];
$result = $this->tsuku->process($template, $data);
$this->assertSame("A\nB\nC\n", $result);
}Anti-Patterns to Avoid
❌ Don't Put Logic in Templates
Conditions can combine comparisons with and/or since 1.4.0, but business rules still belong in PHP, where they can be tested and reused:
// Bad - Business rules in the template
$template = '@if(price > 100 and discount > 0 and category == "electronics")Discounted Price@end';
// Good - Pre-compute in PHP
$data = [
'show_discount' => $price > 100 && $discount > 0 && $category === 'electronics'
];
$template = '@if(show_discount)Discounted Price@end';❌ Don't Reparse Same Template
// Bad - Parsing in loop
foreach ($items as $item) {
$tsuku->process($template, ['item' => $item]); // Reparses every time!
}
// Good - Process all at once
$tsuku->process($template, ['items' => $items]);❌ Don't Mix Output Formats
// Bad - Mixing HTML and CSV
$template = '<div>@csv(text)</div>'; // Wrong escaping!
// Good - One format per template
$htmlTemplate = '<div>@html(text)</div>';
$csvTemplate = '@csv(text)';❌ Don't Compare with true, false or null
There are no boolean or null literals: true in a condition is a variable named true, which is missing and therefore null. @if(flag == true) is true when flag is false:
$template = '@if(flag == true)on@else off@end';
$data = ['flag' => false];
// Output: onTest the value itself: @if(flag), @if(not flag). The same trap applies to @case(true) in @match. See Conditionals.
Deployment Checklist
Before deploying templates to production:
- [ ] All user input is escaped with appropriate function
- [ ] No SQL queries built with templates
- [ ] Templates are tested with edge cases, in
STRICTmode - [ ] Custom functions are pure (no side effects)
- [ ] Error handling is in place (
ParseException,TsukuException) - [ ] Large exports use
processToStream() - [ ] Performance has been considered
- [ ] Templates are documented
- [ ] Code has been reviewed
Next Steps
- Examples → - See best practices in action
- Security → - Deep dive into security
- Upgrading from 1.3 → - What changed in 1.4.0
- API Reference → - Complete API documentation