Syntax Reference
Complete syntax reference for the Tsuku template language (1.4.0).
Variables
Access data using curly braces:
{variable_name}
{object.property}
{array.key}
{nested.deep.value}
{users.0.name}Examples:
$data = [
'name' => 'John',
'user' => ['email' => '[email protected]'],
'product' => ['price' => 29.99]
];
echo $tsuku->process('{name} | {user.email} | {product.price} | [{missing}]', $data);
// Output: John | [email protected] | 29.99 | []A missing variable renders as an empty string in the default SILENT mode (see Strictness Modes & Errors).
{...} is only a variable when it contains a plain path: letters, digits, _ and ., starting with a letter or _. Anything else - { name }, {index + 1}, {product.total()}, JSON or CSS braces - is ordinary text and is printed as written.
Smart Object Access:
For every segment of a path Tsuku tries, in this order:
- Array key:
$user['name'] - Getter method:
$user->getName() - Boolean getter:
$user->isName() - Method with the same name:
$user->name() - Public property:
$user->name
A data key that literally contains dots (['a.b' => ...]) wins over the nested lookup of {a.b}.
Loops
@for Directive
Iterate over arrays:
@for(array as item)
{item}
@endWith Key / Index:
@for(array as item, index)
{index}. {item}
@endNote: Value comes first, then key/index (opposite of PHP). The second variable is the array key: 0, 1, 2, ... for a list, the string key for an associative array.
Examples:
// Simple loop
$template = '@for(products as product)
- {product.name}: ${product.price}
@end';
echo $tsuku->process($template, [
'products' => [['name' => 'Widget', 'price' => 29.99], ['name' => 'Gadget', 'price' => 49.99]],
]);
// Output:
// - Widget: $29.99
// - Gadget: $49.99
// With index
$template = '@for(items as item, index)
{index}. {item}
@end';
echo $tsuku->process($template, ['items' => ['Apple', 'Banana']]);
// Output:
// 0. Apple
// 1. Banana
// Associative array: the key variable holds the key
$template = '@for(prices as price, currency)
{currency}: {price}
@end';
echo $tsuku->process($template, ['prices' => ['EUR' => '9.99', 'USD' => '10.99']]);
// Output:
// EUR: 9.99
// USD: 10.99
// Nested loops
$template = '@for(categories as category)
Category: {category.name}
@for(category.products as product)
- {product.name}
@end
@end';
echo $tsuku->process($template, [
'categories' => [
['name' => 'Tools', 'products' => [['name' => 'Hammer'], ['name' => 'Saw']]],
['name' => 'Garden', 'products' => [['name' => 'Rake']]],
],
]);
// Output:
// Category: Tools
// - Hammer
// - Saw
// Category: Garden
// - RakeLimits:
@foriterates arrays. ATraversableobject or a generator passed toprocess()renders nothing - convert it withiterator_to_array()first. (For very large collections useprocessToStream(), which accepts any iterable.)- There is no arithmetic:
{index + 1}prints literally. Precompute such values in PHP (e.g. apositionfield) or register a custom function. - There are no
loop.first/loop.lasthelpers.@if(index > 0)detects "not the first item" of a list; anything that needs the last item has to be prepared in the data.
Conditionals
@if, @elseif, @else
@if(condition)
Content if true
@end@if(condition)
Content if true
@elseif(other_condition)
Content if the other condition is true
@else
Content otherwise
@endAn @if takes any number of @elseif branches, at most one @else, and a single @end.
@unless Directive
Inverse of @if:
@unless(condition)
Content if false
@end
@unless(condition)
Content if false
@else
Content if true
@endConditions
Conditions are used by @if, @unless, @elseif and @?{...}.
| Operator | Meaning |
|---|---|
== != | Equal / not equal (PHP loose comparison: "10" == 10) |
> < >= <= | Comparison (numeric strings compare as numbers) |
in, not in | List on the right: membership, comparing as strings ("7231" in ids matches 7231). String on the right: case-sensitive substring. Anything else: false |
not, ! | Negation |
and, && | Both (short-circuit) |
or, || | Either (short-circuit) |
( ... ) | Grouping |
Operands: string literals ("..." or '...'), numbers (10, -1, 9.99), variable paths (product.price), function calls (@length(items)) and parenthesised expressions. A value used alone is tested for truthiness with PHP empty() rules: 0, "0", "", [], null and false are false. A condition may span several lines.
and, or, not and in are keywords only as whole words, so product.order, inventory or notes are ordinary variables.
Examples:
$template = '@if(stock > 0)
In Stock
@end
@if(price >= 100)
Premium Product
@else
Standard Product
@end
@if(status == "active" and role != "guest")
Welcome back, member!
@end';
echo $tsuku->process($template, ['stock' => 3, 'price' => 49, 'status' => 'active', 'role' => 'editor']);
// Output:
// In Stock
// Standard Product
// Welcome back, member!More condition shapes:
@if(product.qty > 0 and ("sale" in product.tags or product.price < 10))
@if("7231" in @split(product.groups, ",")) exact id membership in a comma-separated string
@if(@length(product.images)) a function result tested for truthiness
@if(not product.hidden)true, false and null are not literals
true, false and null are looked up as variables (as in every earlier version). A missing variable is null, so flag == true compares flag with null - the opposite of what it looks like:
$template = '@if(flag == true)
flag == true
@else
flag != true
@end';
echo $tsuku->process($template, ['flag' => false]);
// Output: flag == trueTest the value itself instead: @if(flag) / @if(not flag).
Malformed conditions throw a ParseException that names the line and column; an unknown function inside a condition throws a FunctionNotFoundException. See Syntax Validation and the Conditionals guide.
Inline Conditions
@?{condition "value if true" : "value if false"}An optional ? may separate the condition from the first value. Each value is a string literal, a number, a variable path or a function call:
$template = '{name}: @?{stock > 0 "In Stock" : "Out of Stock"}, @?{price >= 100 ? "Premium" : "Standard"}, @?{user.active "Yes" : "No"}';
echo $tsuku->process($template, ['name' => 'Widget', 'stock' => 0, 'price' => 150, 'user' => ['active' => true]]);
// Output: Widget: Out of Stock, Premium, Yes
echo $tsuku->process('@?{qty > 0 and visible label : @upper(fallback)}', [
'qty' => 0, 'visible' => true, 'label' => 'x', 'fallback' => 'sold out',
]);
// Output: SOLD OUTThere is no {condition ? a : b} form - braces only ever hold a variable path.
Pattern Matching
@match Directive
Match a value against multiple cases:
@match(expression)
@case(value1)
Content for value1
@case(value2)
Content for value2
@default
Content if no match
@endMultiple Values Per Case:
@match(expression)
@case(value1, value2, value3)
Content for any of these values
@default
Default content
@end- The
@matchsubject is a variable path or a literal. Function calls and operators are not evaluated there - compute the value in PHP, or use@if/@elseifconditions. @casevalues are string literals, numbers or variable paths. They are compared with PHP loose comparison (==); the first matching case wins.@defaultis optional and must be the last branch.- Do not match booleans with
@case(1, true):trueis a (missing) variable, i.e.null, andfalse == null, so such a case matchestrue,falseand0alike. Use@if(flag)or@?{flag "yes" : "no"}.
Examples:
$template = '@match(status)
@case("active")
✓ Active
@case("pending")
⏳ Pending
@case("suspended")
⚠ Suspended
@default
❌ Unknown
@end';
echo $tsuku->process($template, ['status' => 'pending']);
// Output: ⏳ Pending$template = '@match(user.role)
@case("admin", "moderator")
Full Access
@case("user")
Standard Access
@case("guest")
Limited Access
@default
No Access
@end';
echo $tsuku->process($template, ['user' => ['role' => 'guest']]);
// Output: Limited AccessFunctions
Call functions using the @ prefix. The opening parenthesis must follow the name directly:
@function_name(arg1, arg2, ...)Arguments are string literals ("..." or '...'), numbers, variable paths or nested function calls. Variables keep their type (an array stays an array); a missing variable is passed as ""; a number literal is passed as a string ("2") unless the PHP parameter is typed. Quotes are respected, so @replace(name, ")", "") works.
Built-in Functions
String Functions:
@upper(text) // UPPERCASE (byte-based, like strtoupper)
@lower(text) // lowercase
@capitalize(text) // First letter uppercase
@trim(text) // Remove surrounding whitespace
@substr(text, start, len) // Substring (len optional)
@replace(text, find, rep) // Replace
@concat(str1, str2, ...) // ConcatenateNumber Functions:
@number(value, decimals) // Format with thousands separator
@number(value, decimals, decPoint, sep) // Custom formatting
@number_format(...) // Alias of @number
@round(value, precision) // Round
@ceil(value) // Round up
@floor(value) // Round down
@abs(value) // Absolute valueArray Functions:
@join(array, separator) // Join elements (separator defaults to ",")
@length(array) // Count elements (string length for strings)
@first(array) // First element
@last(array) // Last element
@split(string, delim) // String to list (delimiter defaults to ",")
@contains(haystack, x) // Same rules as "x in haystack"Escaping Functions:
@html(text) // HTML escaping
@xml(text) // XML escaping
@json(text) // JSON string, including the surrounding quotes
@url(text) // URL encoding
@csv(text) // CSV escaping
@escape(text, type) // Generic escaping: html (default), xml, json, url, csvDate Functions:
@date(format) // Current date
@date(format, timestamp) // Format a Unix timestamp
@strtotime(string) // String to timestampUtility Functions:
@default(value, fallback) // Fallback for empty values (PHP empty(): "", "0", 0, null, [])See Built-in Functions for details and examples.
Function Nesting
Functions can be nested:
echo $tsuku->process('@upper(@trim(text))|@html(@upper(name))|@number(@abs(value), 2)', [
'text' => ' hi ', 'name' => '<b>', 'value' => -3.5,
]);
// Output: HI|<B>|3.50Custom Functions
Register with registerFunction():
$tsuku->registerFunction('currency', function(float $amount): string {
return '$' . number_format($amount, 2);
});
echo $tsuku->process('Price: @currency(product.price)', ['product' => ['price' => 1299.5]]);
// Output: Price: $1,299.50A function name must be an identifier, and names that start with end or else, or with default (other than default itself), are read as directives - see Function Names.
Custom Directives
Directives registered with registerDirective() wrap a block: @name(arguments) ... @end, or @name ... @end without arguments. The handler receives the rendered body and the resolved arguments:
$tsuku->registerDirective('cdata', fn(string $body): string => '<![CDATA[' . $body . ']]>');
echo $tsuku->process('<title>@cdata{product.name}@end</title>', ['product' => ['name' => 'Fish & Chips']]);
// Output: <title><![CDATA[Fish & Chips]]></title>See Custom Directives.
Comments
Tsuku has no comment syntax. Everything that is not a variable, function or directive is output:
$template = '// This is not a comment in Tsuku
Name: {name}';
echo $tsuku->process($template, ['name' => 'John']);
// Output:
// // This is not a comment in Tsuku
// Name: JohnWorkaround: keep comments in PHP, next to the template:
// PHP comment before template
$template = 'Name: {name}';Whitespace Handling
Text is output as written, with one exception: whitespace that only lays out block directives (@for, @if, @unless, @elseif, @else, @match, @case, @default, @end and custom directives).
- A line that holds only directives renders nothing - neither its indentation nor its line break.
- Indentation in front of
@else,@elseif,@case,@defaultand@endis dropped. - Everything else - spaces inside content lines, blank lines, the line break after a content line - is kept.
$template = 'Name: {name}
@if(email)
Email: {email}
@end
Done';
echo $tsuku->process($template, ['name' => 'John', 'email' => '[email protected]']);
// Output:
// Name: John
// Email: [email protected]
// DoneThe five exact rules, inline blocks and CRLF handling are described in Whitespace Control. A \ at the end of a line joins it with the next line (Line Continuation).
Special Characters
Literal @
@@ prints a literal @. A bare @ that does not start a function, directive or @?{ is a ParseException, so e-mail addresses and CSS at-rules in template text need the escape:
echo $tsuku->process('Email: user@@example.com', []);
// Output: Email: [email protected]
echo $tsuku->process('@@media (max-width: 600px) { .a { color: {color}; } }', ['color' => 'red']);
// Output: @media (max-width: 600px) { .a { color: red; } }Values that come from your data ({user.email}) are never parsed, so they need no escaping.
Braces
Braces need no escaping: only {plain.path} is a variable. JSON and CSS braces pass through, and so do Magento directives such as {{widget ...}}, {{media url="..."}} and {{trans "..."}}:
echo $tsuku->process('{{widget type="Magento\Cms\Block\Widget\Block" block_id="footer"}} {{media url="wysiwyg/a.jpg"}}', []);
// Output: {{widget type="Magento\Cms\Block\Widget\Block" block_id="footer"}} {{media url="wysiwyg/a.jpg"}}Double braces are not an escape. {{name}} is a {, the variable {name} and a }:
echo $tsuku->process('{{name}}', ['name' => 'Ann']);
// Output: {Ann}To print a literal {name}, build it from parts:
echo $tsuku->process('@concat("{", "name}")', []);
// Output: {name}More in Escapes & Special Characters.
Operator Precedence
Inside conditions, from lowest to highest:
or,||and,&¬,!- Comparisons:
==,!=,>,<,>=,<=,in,not in
So a or b and c means a or (b and c), and not a == b means not (a == b). Use parentheses to group differently. Comparisons cannot be chained (1 < a < 3 is an error - write 1 < a and a < 3).
Grammar Summary
template → node*
node → text | "@@" | variable | function | inline_if | block
variable → "{" path "}"
path → identifier ("." (identifier | digits))*
function → "@" name "(" [argument ("," argument)*] ")"
argument → string | number | path | function
inline_if → "@?{" condition ["?"] value ":" value "}"
value → string | number | path | function
block → for | if | unless | match | custom
for → "@for(" path "as" identifier ["," identifier] ")" template "@end"
if → "@if(" condition ")" template elseif* ["@else" template] "@end"
unless → "@unless(" condition ")" template elseif* ["@else" template] "@end"
elseif → "@elseif(" condition ")" template
match → "@match(" subject ")" case* ["@default" template] "@end"
subject → string | number | path
case → "@case(" subject ("," subject)* ")" template
custom → "@" registered_name ["(" [argument ("," argument)*] ")"] template "@end"
condition → or
or → and (("or" | "||") and)*
and → not (("and" | "&&") not)*
not → ("not" | "!") not | comparison
comparison → operand [("==" | "!=" | ">" | "<" | ">=" | "<=" | "in" | "not" "in") operand]
operand → string | number | path | function | "(" or ")"Built-in directives accept whitespace before their parenthesis (@if (x)); functions and custom directives do not.
Complete Example
$template = 'Product Catalog
Generated: @date("Y-m-d", generated_at)
@for(products as product)
{product.position}. @upper(product.name)
SKU: {product.sku}
Price: $@number(product.price, 2)
Status: @if(product.stock == 0)
Out of Stock
@elseif(product.stock < 10)
Low Stock ({product.stock})
@else
In Stock
@end
@if(product.featured)
⭐ FEATURED PRODUCT
@end
@end
Total Products: @length(products)';
$data = [
'generated_at' => 1705320000, // 2024-01-15 12:00 UTC
'products' => [
[
'position' => 1, // precomputed: templates have no arithmetic
'name' => 'Widget Pro',
'sku' => 'WID-001',
'price' => 29.99,
'stock' => 150,
'featured' => true
],
[
'position' => 2,
'name' => 'Gadget Max',
'sku' => 'GAD-002',
'price' => 49.99,
'stock' => 5,
'featured' => false
],
[
'position' => 3,
'name' => 'Tool Set',
'sku' => 'TOO-003',
'price' => 19.99,
'stock' => 0,
'featured' => false
]
]
];
echo $tsuku->process($template, $data);Output:
Product Catalog
Generated: 2024-01-15
1. WIDGET PRO
SKU: WID-001
Price: $29.99
Status: In Stock
⭐ FEATURED PRODUCT
2. GADGET MAX
SKU: GAD-002
Price: $49.99
Status: Low Stock (5)
3. TOOL SET
SKU: TOO-003
Price: $19.99
Status: Out of Stock
Total Products: 3Syntax Validation
Invalid syntax throws Qoliber\Tsuku\Exception\ParseException (a TsukuException). The message names the line and column; getTemplateLine() and getTemplateColumn() return them as numbers.
// ✅ Valid
echo $tsuku->process('@for(items as item)...@end', ['items' => [1]]);
// Output: ...
// ❌ Missing @end
$tsuku->process('@for(items as item)...', ['items' => [1]]);
// Throws: ParseException: Expected @end but reached end of file at line 1, column 23 (@for opened at line 1, column 1)
// ❌ No parenthesis after @for
$tsuku->process('@for items...@end', []);
// Throws: ParseException: Invalid @ syntax at line 1, column 1 (write @@ for a literal @)
// ❌ Bare @ in text
$tsuku->process('Contact: [email protected]', []);
// Throws: ParseException: Invalid @ syntax at line 1, column 14 (write @@ for a literal @)
// ❌ Malformed condition
$tsuku->process('@if(qty = 1)one@end', []);
// Throws: ParseException: Unexpected '=' (use == to compare) at line 1, column 9 in condition 'qty = 1'
// ❌ Unknown function in a condition
$tsuku->process('@if(@nope(items))x@end', []);
// Throws: FunctionNotFoundException: Unknown function '@nope' at line 1, column 5 in condition '@nope(items)'
// An unclosed brace is not an error: it is plain text
echo $tsuku->process('Hello {name', ['name' => 'John']);
// Output: Hello {nameUnknown functions in output (@nope(x) outside a condition) and missing variables are not syntax errors: they follow the strictness mode.
Best Practices
Use Escaping Functions
// ✅ Good - XSS protected
@html(user.comment)
// ❌ Bad - XSS vulnerable
{user.comment}Consistent Formatting
// ✅ Good - readable; the directive-only lines produce no output
@for(items as item)
{item.name}
@end
// ❌ Bad - hard to read
@for(items as item){item.name}@endThe two forms do not render the same: the first prints each name on its own line (with the four spaces of indentation, which belong to the content line), the second prints all names joined.
Meaningful Variable Names
// ✅ Good
@for(products as product)
// ❌ Bad
@for(products as p)Next Steps
- Tsuku Class → - API reference
- Built-in Functions → - Function details
- Examples → - Real-world examples