Configuration Files
YAML, .env, nginx and TOML files from deployment data. Most of these formats depend on indentation, and Tsuku's whitespace control is built for that: a line that holds only directives (@if(...), @for(...), @end) prints nothing at all, so directives can be indented like the configuration around them, while content lines keep their own indentation.
Each example has the template, the data, the generated output and the PHP call (how the examples work).
Application settings (YAML)
Booleans come from @?{app.debug "true" : "false"}: Tsuku has no boolean literals, and {app.debug} would print 1 or nothing. Strings that can contain YAML syntax - : , #, quotes - go through @json(); YAML 1.2 reads JSON strings, so "Kumo Tea: Shop" and the password stay intact. The cache section is one of two blocks, and the retry block is left out for a queue without retries.
app:
name: @json(app.name)
env: {app.env}
debug: @?{app.debug "true" : "false"}
url: {app.url}
database:
host: {db.host}
port: {db.port}
name: {db.name}
user: {db.user}
password: @json(db.password)
cache:
@if(cache.driver == "redis")
driver: redis
host: {cache.host}
port: {cache.port}
@else
driver: file
path: {cache.path}
@end
queues:
@for(queues as queue)
- name: {queue.name}
workers: {queue.workers}
@if(queue.retry)
retry:
attempts: {queue.retry.attempts}
backoff: {queue.retry.backoff}s
@end
@end<?php
return [
'app' => ['name' => 'Kumo Tea: Shop', 'env' => 'production', 'debug' => false, 'url' => 'https://shop.example.com'],
'db' => ['host' => 'db.internal', 'port' => 3306, 'name' => 'shop', 'user' => 'shop', 'password' => 'p4ss: #1 "x"'],
'cache' => ['driver' => 'redis', 'host' => 'redis.internal', 'port' => 6379, 'path' => null],
'queues' => [
['name' => 'emails', 'workers' => 2, 'retry' => ['attempts' => 5, 'backoff' => 30]],
['name' => 'feeds', 'workers' => 1, 'retry' => null],
],
];app:
name: "Kumo Tea: Shop"
env: production
debug: false
url: https://shop.example.com
database:
host: db.internal
port: 3306
name: shop
user: shop
password: "p4ss: #1 \u0022x\u0022"
cache:
driver: redis
host: redis.internal
port: 6379
queues:
- name: emails
workers: 2
retry:
attempts: 5
backoff: 30s
- name: feeds
workers: 1echo $tsuku->process($template, $data);Docker Compose
Every optional section of a service - build or image, ports, environment, volumes, depends_on - is an @if around a nested @for, indented exactly like the YAML it produces. A missing key and an empty list both count as false, so redis gets only its image. @for(service.environment as value, key) writes a map from an associative array.
services:
@for(services as service)
{service.name}:
@if(service.build)
build: {service.build}
@else
image: {service.image}
@end
@if(service.ports)
ports:
@for(service.ports as port)
- "{port}"
@end
@end
@if(service.environment)
environment:
@for(service.environment as value, key)
{key}: @json(value)
@end
@end
@if(service.volumes)
volumes:
@for(service.volumes as volume)
- {volume}
@end
@end
@if(service.depends_on)
depends_on:
@for(service.depends_on as dependency)
- {dependency}
@end
@end
@end
volumes:
@for(volumes as volume)
{volume}:
@end<?php
return [
'services' => [
[
'name' => 'web',
'build' => '.',
'ports' => ['8080:80'],
'environment' => ['APP_ENV' => 'dev', 'DB_HOST' => 'db'],
'volumes' => ['./src:/var/www/html'],
'depends_on' => ['db', 'redis'],
],
[
'name' => 'db',
'image' => 'mysql:8.4',
'environment' => ['MYSQL_DATABASE' => 'shop', 'MYSQL_ROOT_PASSWORD' => 'change-me'],
'volumes' => ['db-data:/var/lib/mysql'],
],
['name' => 'redis', 'image' => 'redis:7-alpine'],
],
'volumes' => ['db-data'],
];services:
web:
build: .
ports:
- "8080:80"
environment:
APP_ENV: "dev"
DB_HOST: "db"
volumes:
- ./src:/var/www/html
depends_on:
- db
- redis
db:
image: mysql:8.4
environment:
MYSQL_DATABASE: "shop"
MYSQL_ROOT_PASSWORD: "change-me"
volumes:
- db-data:/var/lib/mysql
redis:
image: redis:7-alpine
volumes:
db-data:echo $tsuku->process($template, $data);Kubernetes deployment
Deeply nested YAML with a list of containers. The second container has no port and no resource limits, and those sections disappear without leaving blank lines or stray indentation behind.
apiVersion: apps/v1
kind: Deployment
metadata:
name: {app.name}
labels:
@for(app.labels as value, key)
{key}: {value}
@end
spec:
replicas: {app.replicas}
selector:
matchLabels:
app: {app.name}
template:
metadata:
labels:
app: {app.name}
spec:
containers:
@for(app.containers as container)
- name: {container.name}
image: "{container.image}:{container.tag}"
@if(container.port)
ports:
- containerPort: {container.port}
@end
env:
@for(container.env as value, name)
- name: {name}
value: @json(value)
@end
@if(container.resources)
resources:
limits:
cpu: {container.resources.cpu}
memory: {container.resources.memory}
@end
@end<?php
return [
'app' => [
'name' => 'shop-web',
'labels' => ['app' => 'shop-web', 'team' => 'commerce'],
'replicas' => 3,
'containers' => [
[
'name' => 'php-fpm',
'image' => 'registry.example.com/shop/php-fpm',
'tag' => '2026.10.3',
'port' => 9000,
'env' => ['APP_ENV' => 'production', 'REDIS_HOST' => 'redis.internal'],
'resources' => ['cpu' => '500m', 'memory' => '768Mi'],
],
[
'name' => 'log-shipper',
'image' => 'fluent/fluent-bit',
'tag' => '3.1',
'port' => null,
'env' => ['FLB_LOG_LEVEL' => 'warn'],
'resources' => null,
],
],
],
];apiVersion: apps/v1
kind: Deployment
metadata:
name: shop-web
labels:
app: shop-web
team: commerce
spec:
replicas: 3
selector:
matchLabels:
app: shop-web
template:
metadata:
labels:
app: shop-web
spec:
containers:
- name: php-fpm
image: "registry.example.com/shop/php-fpm:2026.10.3"
ports:
- containerPort: 9000
env:
- name: APP_ENV
value: "production"
- name: REDIS_HOST
value: "redis.internal"
resources:
limits:
cpu: 500m
memory: 768Mi
- name: log-shipper
image: "fluent/fluent-bit:3.1"
env:
- name: FLB_LOG_LEVEL
value: "warn"echo $tsuku->process($template, $data);Environment file
A .env file per environment. @match(cache.driver) writes the settings of the configured cache only; the # line is plain text to Tsuku and is copied into the file. Values with spaces are quoted in the template. Values that can contain ", $ or line breaks need a registered function that escapes them the way your dotenv parser expects.
# {app.env} settings, generated @date("Y-m-d", generated_at). Do not edit by hand.
APP_ENV={app.env}
APP_DEBUG=@?{app.debug "true" : "false"}
APP_URL={app.url}
DB_HOST={db.host}
DB_PORT={db.port}
DB_DATABASE={db.name}
CACHE_DRIVER={cache.driver}
@match(cache.driver)
@case("redis")
REDIS_HOST={cache.host}
REDIS_PORT={cache.port}
@case("memcached")
MEMCACHED_HOST={cache.host}
@end
MAIL_FROM_ADDRESS={mail.address}
MAIL_FROM_NAME="{mail.name}"<?php
// Values from your own deployment config. Quote the ones with spaces; values that can
// contain '"', '$' or line breaks need a function that escapes them for your dotenv parser.
return [
'generated_at' => strtotime('2026-10-25'),
'app' => ['env' => 'staging', 'debug' => true, 'url' => 'https://staging.shop.example.com'],
'db' => ['host' => '10.0.2.15', 'port' => 3306, 'name' => 'shop_staging'],
'cache' => ['driver' => 'redis', 'host' => '10.0.2.16', 'port' => 6379],
'mail' => ['address' => '[email protected]', 'name' => 'Kumo Tea (staging)'],
];# staging settings, generated 2026-10-25. Do not edit by hand.
APP_ENV=staging
APP_DEBUG=true
APP_URL=https://staging.shop.example.com
DB_HOST=10.0.2.15
DB_PORT=3306
DB_DATABASE=shop_staging
CACHE_DRIVER=redis
REDIS_HOST=10.0.2.16
REDIS_PORT=6379
MAIL_FROM_ADDRESS=[email protected]
MAIL_FROM_NAME="Kumo Tea (staging)"echo $tsuku->process($template, $data);nginx server block
A staging host: @unless(site.production) adds password protection, a loop writes one location per redirect, and @first(site.domains) names the certificate directory. nginx's own $uri and $args and the braces of its blocks are plain text to Tsuku. The blank lines between blocks live inside the @unless and the @for, so a production host without redirects gets no double blank lines.
server {
listen 443 ssl;
http2 on;
server_name @join(site.domains, " ");
root {site.root};
ssl_certificate /etc/letsencrypt/live/@first(site.domains)/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/@first(site.domains)/privkey.pem;
@unless(site.production)
auth_basic "Staging";
auth_basic_user_file /etc/nginx/htpasswd/{site.name};
@end
@for(site.redirects as redirect)
location = {redirect.from} {
return 301 {redirect.to};
}
@end
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php{site.php}-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}<?php
return [
'site' => [
'name' => 'shop-staging',
'production' => false,
'domains' => ['staging.shop.example.com', 'staging.shop.example.nl'],
'root' => '/var/www/shop/current/pub',
'php' => '8.3',
'redirects' => [
['from' => '/thee', 'to' => '/tea'],
['from' => '/sale', 'to' => '/offers'],
],
],
];server {
listen 443 ssl;
http2 on;
server_name staging.shop.example.com staging.shop.example.nl;
root /var/www/shop/current/pub;
ssl_certificate /etc/letsencrypt/live/staging.shop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/staging.shop.example.com/privkey.pem;
auth_basic "Staging";
auth_basic_user_file /etc/nginx/htpasswd/shop-staging;
location = /thee {
return 301 /tea;
}
location = /sale {
return 301 /offers;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}echo $tsuku->process($template, $data);Cargo.toml
TOML arrays and inline tables written on one line: [@for(...)@if(index > 0), @end"{feature}"@end] puts a separator in front of every element but the first. The { version = ... } braces of the inline table are text, because only {plain.path} is a variable. The values here are package names and versions; free text in TOML strings would need a function that escapes " and \.
[package]
name = "{package.name}"
version = "{package.version}"
edition = "2021"
authors = [@for(package.authors as author, index)@if(index > 0), @end"{author}"@end]
[dependencies]
@for(dependencies as dependency)
@if(dependency.features)
{dependency.name} = { version = "{dependency.version}", features = [@for(dependency.features as feature, index)@if(index > 0), @end"{feature}"@end] }
@else
{dependency.name} = "{dependency.version}"
@end
@end<?php
return [
'package' => [
'name' => 'feed-worker',
'version' => '0.4.2',
'authors' => ['Anna de Vries <[email protected]>', 'Jan Kowalski <[email protected]>'],
],
'dependencies' => [
['name' => 'serde', 'version' => '1.0', 'features' => ['derive']],
['name' => 'tokio', 'version' => '1.40', 'features' => ['rt-multi-thread', 'macros']],
['name' => 'quick-xml', 'version' => '0.36', 'features' => []],
],
];[package]
name = "feed-worker"
version = "0.4.2"
edition = "2021"
authors = ["Anna de Vries <[email protected]>", "Jan Kowalski <[email protected]>"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "macros"] }
quick-xml = "0.36"echo $tsuku->process($template, $data);Checking the result
Parse a generated file before it is deployed, for example with symfony/yaml:
use Symfony\Component\Yaml\Yaml;
$yaml = $tsuku->process($template, $data);
Yaml::parse($yaml); // throws a ParseException for invalid YAML
file_put_contents('docker-compose.yml', $yaml);Next Steps
- Whitespace Control → - the five rules behind indented templates
- E-commerce → - invoices, labels and feeds
- Conditionals → -
@if,@unless,@matchand conditions