A lightweight PHP templating library for transforming data into ANY text format
Tsuku is a powerful template processing library built with a clean Lexer → Parser → Compiler architecture. Transform your data into CSV, XML, JSON, XSD, or any text format you need using simple, intuitive templates.
Perfect for e-commerce exports, API responses, configuration files, and data transformations.
- 🎯 Any text format: CSV, XML, JSON, YAML, TOML, HTML, Markdown, INI, XSD, or custom formats
- 🔄 Control flow: Loops (
@for), conditionals (@if,@unless,@else), pattern matching (@match) - 🪺 Deep nesting: Unlimited levels of nested directives
- 🎨 Smart object/array access: Automatic getter detection, method calls, property access
- 🔧 Custom functions: Register your own
@function()handlers - 🎭 Widget support: Build Magento-style widgets with custom functions
- 🛠️ Clean architecture: Lexer → Parser → Compiler pipeline (AST-based)
- 🚀 PHP 8.1+: Modern PHP with zero dependencies
- ✅ Production-ready: 196 tests, 423 assertions, 88% mutation score
- 📦 Preserves formatting: Exact whitespace and newline control
- ⚡ Fast: Single-pass compilation, efficient AST walking
- 🔒 Type-safe: Full PHP 8.1+ type hints and strict types
Tsuku is fast - designed for high-volume data transformations:
| Benchmark | Performance | Throughput |
|---|---|---|
| Simple templates | 0.2ms per render | ~5,000 renders/sec |
| Complex templates | 1.0ms per render | ~1,000 renders/sec |
| 1,000 variables | 1.8ms per render | ~550 renders/sec |
| CSV export (1,000 products) | 3.9ms per export | ~250 exports/sec |
Real-world capacity:
- 250,000+ products/second for CSV exports
- Sub-millisecond rendering for typical templates
- Low memory footprint (~60KB per render)
Run benchmarks yourself:
php benchmarks/run-all.phpSee benchmarks/ for detailed performance tests.
- PHP 8.1 or higher
composer require qoliber/tsukuuse Qoliber\Tsuku\Tsuku;
$data = ['product' => 'Widget', 'price' => 29.99];
$template = 'Product: {product}, Price: ${price}';
$tsuku = new Tsuku();
echo $tsuku->process($template, $data);
// Output: Product: Widget, Price: $29.99$template = 'Products:
@for(products as product)
- {product.name}: ${product.price}
@end';
$data = [
'products' => [
['name' => 'Widget A', 'price' => '29.99'],
['name' => 'Widget B', 'price' => '39.99'],
],
];
echo $tsuku->process($template, $data);
// Output:
// Products:
// - Widget A: $29.99
// - Widget B: $39.99$template = '@for(items as item)
{item.name}: @if(item.stock > 0)
✓ Available
@else
✗ Out of Stock
@end
@end';// Works with both arrays AND objects!
class Product {
private $price = 99.99;
public function getPrice() { return $this->price; }
public function isAvailable() { return true; }
}
$template = 'Price: ${product.price}, Available: {product.available}';
$tsuku->process($template, ['product' => new Product()]);
// Output: Price: $99.99, Available: 1// Register your own functions
$tsuku->registerFunction('currency', fn($amount, $code = 'USD') =>
match($code) {
'USD' => '$' . number_format($amount, 2),
'EUR' => '€' . number_format($amount, 2),
default => $code . ' ' . number_format($amount, 2)
}
);
$template = 'Total: @currency(price, "EUR")';
$tsuku->process($template, ['price' => 99.99]);
// Output: Total: €99.99For large catalogs (10k+ rows) where holding the whole rendered output in memory is impractical, use processToStream. It renders the template incrementally — header once, each row in turn, footer once — and writes each piece through a callback as soon as it's ready.
$tsuku = new Tsuku();
$template = '<?xml version="1.0"?>
<feed currency="{currency}">
@for(products as product)
<item>
<sku>{product.sku}</sku>
<price>{product.price} {currency}</price>
</item>
@end
</feed>';
$rows = function (): Generator {
// yield products lazily from your data source
foreach (loadProductsFromDb() as $row) {
yield $row;
}
};
$handle = fopen('feed.xml', 'wb');
$tsuku->processToStream(
$template,
['currency' => 'EUR'], // shared by header, footer, and each row
$rows(), // any iterable; generators are consumed lazily
'products', // name of the @for collection to stream
fn(string $chunk) => fwrite($handle, $chunk)
);
fclose($handle);Constraints:
- Template must contain exactly one top-level
@forover the streaming variable. Multiple matches or nesting inside@if/@forthrows. - The streamed iterable is consumed once. Generators cannot be rewound.
- Memory usage is bounded by the size of the largest single piece (header, footer, or one row), regardless of total row count.
$template = 'SKU,Name,Price,Stock
@for(products as product)
@csv(product.sku),@csv(product.name),$@number(product.price, 2),{product.stock}
@end';
$data = [
'products' => [
['sku' => 'WID-001', 'name' => 'Widget', 'price' => 29.99, 'stock' => '100'],
['sku' => 'GAD-002', 'name' => 'Gadget, Premium', 'price' => 1299.50, 'stock' => '50'],
],
];
file_put_contents('export.csv', $tsuku->process($template, $data));
// Output:
// SKU,Name,Price,Stock
// WID-001,Widget,$29.99,100
// GAD-002,"Gadget, Premium",$1,299.50,50$template = '<?xml version="1.0"?>
<catalog>
@for(products as product)
<product id="{product.id}">
<name>{product.name}</name>
<price>{product.price}</price>
@if(product.stock > 0)
<availability>in-stock</availability>
@end
</product>
@end
</catalog>';$template = 'services:
@for(services as service)
{service.name}:
image: {service.image}
@if(service.ports)ports:
@for(service.ports as port)
- "{port}"
@end
@end
@end';$template = '<ul class="products">
@for(products as product)
<li>
<h3>@html(product.name)</h3>
<p>$@number(product.price, 2)</p>
<div class="description">@html(product.description)</div>
@if(product.stock > 0)
<span class="in-stock">Available</span>
@else
<span class="out-of-stock">Out of Stock</span>
@end
</li>
@end
</ul>';
$data = [
'products' => [
[
'name' => 'Premium Widget',
'price' => 1299.99,
'description' => 'A <strong>powerful</strong> widget',
'stock' => 5,
],
],
];
// Output: HTML entities escaped to prevent XSS
// <h3>Premium Widget</h3>
// <p>$1,299.99</p>
// <div class="description">A <strong>powerful</strong> widget</div>Use {variableName} or {object.property} for dot notation:
{name}
{product.name}
{category.products.0.name}
Smart Object/Array Access:
// All of these work:
{product.price} // Array: $product['price'] OR Object: $product->getPrice()
{user.name} // Array: $user['name'] OR Object: $user->getName()
{product.available} // Array: $product['available'] OR Object: $product->isAvailable()
{item.total} // Array: $item['total'] OR Object: $item->total() OR $item->getTotal()@for(collection as item)
{item.property}
@end
With key/value (value first, then key):
@for(items as item, key)
{key}: {item}
@end
If/Else:
@if(variable > 0)
Content when true
@else
Content when false
@end
Unless:
@unless(variable > 0)
Content when false
@else
Content when true
@end
Match (Pattern Matching):
@match(status)
@case("active")
✓ Active
@case("pending")
⏳ Pending
@case("suspended")
⚠ Suspended
@default
❌ Unknown
@end
Match with multiple values:
@match(user.role)
@case("admin", "moderator")
Full Access
@case("user", "guest")
Limited Access
@default
No Access
@end
Supported operators: >, <, >=, <=, ==, !=
String functions:
@upper(text) // HELLO
@lower(text) // hello
@capitalize(text) // Hello
@trim(text) // Remove whitespace
@substr(text, start, length) // Extract substring
@replace(text, search, replace) // Replace text
Number functions:
@number(value, decimals, decPoint, thousandsSep) // 1,234.56
@number(1234.567, 2) // 1,234.57
@number(1234.567, 2, ",", ".") // 1.234,57
@round(value, precision) // Round number
@ceil(value) // Round up
@floor(value) // Round down
@abs(value) // Absolute value
Array functions:
@join(items, ", ") // Join with separator
@length(items) // Count items
@first(items) // First element
@last(items) // Last element
Escaping functions:
@html(text) // HTML-safe: <script>
@xml(text) // XML-safe escaping
@json(text) // JSON-safe escaping
@url(text) // URL encoding: Hello%20World
@csv(text) // CSV escaping with quotes
@escape(text, "html") // Generic escape (html/xml/json/url/csv)
Date/Utility functions:
@date("Y-m-d", timestamp) // Format date
@default(value, "fallback") // Use fallback if empty
Register your own:
$tsuku->registerFunction('badge', function(string $text, string $color = 'blue'): string {
return "<span class=\"badge badge-{$color}\">{$text}</span>";
});
// Use in template:
// @badge(status, "green")Nest directives as deep as you need:
@for(categories as category)
Category: {category.name}
@for(category.products as product)
Product: {product.name}
@for(product.variants as variant)
Variant: {variant.sku} - ${variant.price}
@end
@end
@end
Tsuku uses a clean three-stage compiler pipeline inspired by traditional programming language design:
Template String → Lexer → Tokens → Parser → AST → Compiler → Output String
What it means: "Lexer" comes from "lexical analysis" - breaking text into meaningful chunks
Location: src/Lexer/Lexer.php
The Lexer reads the raw template string character by character and breaks it into tokens (meaningful units):
Input: "Hello {name}, @if(admin)welcome@end"
Tokens: [
TEXT("Hello "),
VARIABLE("name"),
TEXT(", "),
DIRECTIVE_IF("admin"),
TEXT("welcome"),
DIRECTIVE_END
]Why? Makes parsing easier by converting a string into structured chunks.
What it means: Builds a tree structure showing how pieces relate to each other
Location: src/Ast/Parser.php
The Parser takes tokens and builds an AST (Abstract Syntax Tree) - a tree structure representing the template's logical structure:
Tokens: [TEXT("Hello "), VARIABLE("name"), DIRECTIVE_IF(...)]
AST:
TemplateNode
├── TextNode("Hello ")
├── VariableNode("name")
└── IfNode(condition: "admin")
└── TextNode("welcome")Why? The tree structure makes it easy to handle nesting and execute directives in the correct order.
What it means: Walks the tree and generates the final output
Location: src/Compiler/Compiler.php
The Compiler walks the AST tree using the Visitor Pattern and generates the output string:
AST Tree → Visitor Pattern → Final Output
TemplateNode.accept(compiler)
├── TextNode.accept(compiler) → "Hello "
├── VariableNode.accept(compiler) → "John" (looks up data)
└── IfNode.accept(compiler)
└── if (condition) TextNode.accept(compiler) → "welcome"
Output: "Hello John, welcome"Why? Clean separation: data lookup, conditionals, loops all handled in one place.
AST (Abstract Syntax Tree)
- A tree representation of your template structure
- Each node = one piece (text, variable, loop, condition)
- Example:
@if(x)@for(items)...@end@endbecomes a tree with IfNode containing ForNode
Node
- One element in the AST tree
- Types:
TextNode,VariableNode,ForNode,IfNode,FunctionNode, etc. - Each node knows how to compile itself
Token
- Smallest meaningful unit from Lexer
- Like words in a sentence
- Types:
TEXT,VARIABLE,DIRECTIVE_IF,DIRECTIVE_FOR, etc.
Visitor Pattern
- Design pattern where nodes "accept" a visitor (the compiler)
- Allows separating tree structure from processing logic
- Each node has
accept(NodeVisitor $visitor)method
✅ Exact whitespace preservation - Lexer captures everything ✅ Proper nesting validation - Parser builds correct tree or throws error ✅ Clean separation of concerns - Each stage has one job ✅ Easy to extend - Add new node types without breaking existing code ✅ Fast execution - Single pass through the tree ✅ Type safety - PHP 8.1+ types ensure correctness
Tsuku follows industry-standard naming for compiler components:
| Class Name | Purpose | Location |
|---|---|---|
Lexer |
Lexical analyzer - breaks text into tokens | src/Lexer/ |
Token |
One meaningful unit (like a word) | src/Lexer/Token.php |
TokenType |
Enum of all token types | src/Lexer/TokenType.php |
Parser |
Syntax analyzer - builds AST from tokens | src/Ast/Parser.php |
*Node |
AST tree nodes (TextNode, ForNode, etc.) |
src/Ast/ |
NodeVisitor |
Interface for visiting AST nodes | src/Ast/NodeVisitor.php |
Compiler |
Code generator - walks AST to create output | src/Compiler/Compiler.php |
Tsuku |
Main API entry point | src/Tsuku.php |
Naming Philosophy:
- Lexer/Parser/Compiler - Standard compiler pipeline terms
- Node suffix - Indicates AST node type (
TextNode,IfNode) - Registry suffix - Stores and manages items (
FunctionRegistry) - Visitor suffix - Implements visitor pattern (
NodeVisitor) - Exception suffix - Error types (
TsukuException,ParseException)
$tsuku = new Tsuku();
$result = $tsuku->process('@if(admin){name}@end', ['admin' => true, 'name' => 'John']);
// Internally:
// 1. Lexer::tokenize() → [DIRECTIVE_IF("admin"), VARIABLE("name"), DIRECTIVE_END]
// 2. Parser::parse() → IfNode(condition: "admin", children: [VariableNode("name")])
// 3. Compiler::compile() → Walks tree:
// - IfNode: evaluate condition (true) → execute children
// - VariableNode: lookup "name" in data → "John"
// 4. Output: "John"This architecture is the same used by:
- Programming languages (PHP, JavaScript, Python)
- Template engines (Twig, Blade, Smarty)
- Markup processors (Markdown, BBCode)
Further Reading:
# Install dependencies
composer install
# Run tests
composer test
# Run mutation testing
composer test:mutation
# Run static analysis
composer analyse
# Check code style
composer cs:check
# Fix code style
composer cs:fixMIT License - see LICENSE file for details
Created by qoliber - Like a hummingbird (koliber), swift and precise in data transformation.
Tsuku (つく) means "to create" or "to make" in Japanese, reflecting the library's purpose of creating text output from data.