Skip to content

XML Generation ​

Feeds, sitemaps and API requests. In every example, text from the data goes through @xml(value), which escapes & < > " ' and is therefore safe in element text and in attribute values; prices use @number(value, 2, ".", "") so that 1249.50 never becomes 1,249.50. Directive lines are indented with the elements they control and print nothing.

Each example has the template, the data, the generated output and the PHP call (how the examples work).

Google Shopping feed ​

An RSS 2.0 feed with the g: namespace, as Google Merchant Center reads it. Extra images come from a nested loop; g:sale_price is only written when it is lower than the regular price (and); a product without a GTIN gets a g:mpn instead. The availability is an inline @if / @elseif / @else block: content comes before the @if and after the @end, so the line breaks around each branch are dropped and the value sits between the tags. @join(product.categories, " > ") builds the product type, and @xml() escapes the >.

tsuku-xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
  <channel>
    <title>@xml(store.name)</title>
    <link>@xml(store.url)</link>
    <description>@xml(store.description)</description>
    @for(products as product)
    <item>
      <g:id>@xml(product.sku)</g:id>
      <g:title>@xml(product.name)</g:title>
      <g:description>@xml(product.description)</g:description>
      <g:link>@xml(product.url)</g:link>
      <g:image_link>@xml(product.image)</g:image_link>
      @for(product.gallery as image)
      <g:additional_image_link>@xml(image)</g:additional_image_link>
      @end
      <g:availability>@if(product.qty > 0)
        in_stock
      @elseif(product.backorders)
        backorder
      @else
        out_of_stock
      @end</g:availability>
      <g:price>@number(product.price, 2, ".", "") {store.currency}</g:price>
      @if(product.special_price and product.special_price < product.price)
      <g:sale_price>@number(product.special_price, 2, ".", "") {store.currency}</g:sale_price>
      @end
      <g:brand>@xml(product.brand)</g:brand>
      @if(product.gtin)
      <g:gtin>@xml(product.gtin)</g:gtin>
      @else
      <g:mpn>@xml(product.sku)</g:mpn>
      @end
      <g:condition>new</g:condition>
      <g:product_type>@xml(@join(product.categories, " > "))</g:product_type>
      <g:shipping_weight>@number(product.weight, 2, ".", "") kg</g:shipping_weight>
    </item>
    @end
  </channel>
</rss>
php
<?php

return [
    'store' => [
        'name' => 'Kumo Tea',
        'url' => 'https://shop.example.com',
        'description' => 'Japanese tea & teaware',
        'currency' => 'EUR',
    ],
    'products' => [
        [
            'sku' => 'TEA-KYU-350',
            'name' => 'Kyusu Teapot 350 ml',
            'description' => 'Tokoname clay teapot with a fine <mesh> filter.',
            'url' => 'https://shop.example.com/kyusu-teapot-350-ml',
            'image' => 'https://shop.example.com/media/catalog/product/k/y/kyusu-350.jpg',
            'gallery' => [
                'https://shop.example.com/media/catalog/product/k/y/kyusu-350-side.jpg',
                'https://shop.example.com/media/catalog/product/k/y/kyusu-350-lid.jpg',
            ],
            'qty' => 12,
            'backorders' => false,
            'price' => 49.00,
            'special_price' => null,
            'brand' => 'Gyokko',
            'gtin' => '4901234567894',
            'categories' => ['Teaware', 'Teapots'],
            'weight' => 0.42,
        ],
        [
            'sku' => 'TEA-MAT-SET',
            'name' => 'Matcha Whisk & Bowl Set',
            'description' => 'Bamboo chasen (100 tines), chawan and scoop.',
            'url' => 'https://shop.example.com/matcha-whisk-bowl-set',
            'image' => 'https://shop.example.com/media/catalog/product/m/a/matcha-set.jpg',
            'gallery' => [],
            'qty' => 0,
            'backorders' => true,
            'price' => 39.90,
            'special_price' => 29.90,
            'brand' => 'Kumo Tea',
            'gtin' => '',
            'categories' => ['Teaware', 'Matcha'],
            'weight' => 0.65,
        ],
    ],
];
xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
  <channel>
    <title>Kumo Tea</title>
    <link>https://shop.example.com</link>
    <description>Japanese tea &amp; teaware</description>
    <item>
      <g:id>TEA-KYU-350</g:id>
      <g:title>Kyusu Teapot 350 ml</g:title>
      <g:description>Tokoname clay teapot with a fine &lt;mesh&gt; filter.</g:description>
      <g:link>https://shop.example.com/kyusu-teapot-350-ml</g:link>
      <g:image_link>https://shop.example.com/media/catalog/product/k/y/kyusu-350.jpg</g:image_link>
      <g:additional_image_link>https://shop.example.com/media/catalog/product/k/y/kyusu-350-side.jpg</g:additional_image_link>
      <g:additional_image_link>https://shop.example.com/media/catalog/product/k/y/kyusu-350-lid.jpg</g:additional_image_link>
      <g:availability>in_stock</g:availability>
      <g:price>49.00 EUR</g:price>
      <g:brand>Gyokko</g:brand>
      <g:gtin>4901234567894</g:gtin>
      <g:condition>new</g:condition>
      <g:product_type>Teaware &gt; Teapots</g:product_type>
      <g:shipping_weight>0.42 kg</g:shipping_weight>
    </item>
    <item>
      <g:id>TEA-MAT-SET</g:id>
      <g:title>Matcha Whisk &amp; Bowl Set</g:title>
      <g:description>Bamboo chasen (100 tines), chawan and scoop.</g:description>
      <g:link>https://shop.example.com/matcha-whisk-bowl-set</g:link>
      <g:image_link>https://shop.example.com/media/catalog/product/m/a/matcha-set.jpg</g:image_link>
      <g:availability>backorder</g:availability>
      <g:price>39.90 EUR</g:price>
      <g:sale_price>29.90 EUR</g:sale_price>
      <g:brand>Kumo Tea</g:brand>
      <g:mpn>TEA-MAT-SET</g:mpn>
      <g:condition>new</g:condition>
      <g:product_type>Teaware &gt; Matcha</g:product_type>
      <g:shipping_weight>0.65 kg</g:shipping_weight>
    </item>
  </channel>
</rss>
php
echo $tsuku->process($template, $data);

Sitemap with language alternates ​

@for(page.alternates as href, language) loops over an associative array: the second variable is the key, here the language code. A page without translations gets no xhtml:link lines. The & in the second URL becomes &amp;, as the sitemap protocol requires.

tsuku-xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  @for(pages as page)
  <url>
    <loc>@xml(page.loc)</loc>
    <lastmod>@date("Y-m-d", page.updated_at)</lastmod>
    @for(page.alternates as href, language)
    <xhtml:link rel="alternate" hreflang="@xml(language)" href="@xml(href)"/>
    @end
  </url>
  @end
</urlset>
php
<?php

return [
    'pages' => [
        [
            'loc' => 'https://shop.example.com/en/teapots',
            'updated_at' => strtotime('2026-10-20'),
            'alternates' => [
                'en' => 'https://shop.example.com/en/teapots',
                'de' => 'https://shop.example.com/de/teekannen',
                'nl' => 'https://shop.example.com/nl/theepotten',
            ],
        ],
        [
            'loc' => 'https://shop.example.com/en/teapots?page=2&limit=24',
            'updated_at' => strtotime('2026-10-18'),
            'alternates' => [],
        ],
    ],
];
xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://shop.example.com/en/teapots</loc>
    <lastmod>2026-10-20</lastmod>
    <xhtml:link rel="alternate" hreflang="en" href="https://shop.example.com/en/teapots"/>
    <xhtml:link rel="alternate" hreflang="de" href="https://shop.example.com/de/teekannen"/>
    <xhtml:link rel="alternate" hreflang="nl" href="https://shop.example.com/nl/theepotten"/>
  </url>
  <url>
    <loc>https://shop.example.com/en/teapots?page=2&amp;limit=24</loc>
    <lastmod>2026-10-18</lastmod>
  </url>
</urlset>
php
echo $tsuku->process($template, $data);

RSS feed with CDATA ​

The article body is HTML. Escaped with @xml() it would be valid too, but many feed readers expect it in a CDATA section, so the example registers a block directive: @cdata ... @end wraps whatever its body renders and splits a ]]> in the data so the section cannot end early (PHP tab). Text inside CDATA is not escaped. See Custom Directives.

tsuku-xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>@xml(blog.title)</title>
    <link>@xml(blog.url)</link>
    <description>@xml(blog.description)</description>
    <lastBuildDate>@date("r", blog.updated_at)</lastBuildDate>
    @for(posts as post)
    <item>
      <title>@xml(post.title)</title>
      <link>@xml(post.url)</link>
      <guid isPermaLink="true">@xml(post.url)</guid>
      <pubDate>@date("r", post.published_at)</pubDate>
      @for(post.categories as category)
      <category>@xml(category)</category>
      @end
      <description>@xml(post.excerpt)</description>
      <content:encoded>@cdata{post.html}@end</content:encoded>
    </item>
    @end
  </channel>
</rss>
php
<?php

return [
    'blog' => [
        'title' => 'Kumo Tea Journal',
        'url' => 'https://shop.example.com/journal',
        'description' => 'Brewing guides & news from the tea room',
        'updated_at' => strtotime('2026-10-22 09:30'),
    ],
    'posts' => [
        [
            'title' => 'Gyokuro at 50 °C: why cooler water tastes sweeter',
            'url' => 'https://shop.example.com/journal/gyokuro-50-degrees',
            'published_at' => strtotime('2026-10-22 08:00'),
            'categories' => ['Brewing', 'Green tea'],
            'excerpt' => 'Low temperature keeps the bitterness <em>down</em>.',
            'html' => '<p>Gyokuro is shaded for three weeks before harvest &amp; brewed <strong>cool</strong>.</p>',
        ],
    ],
];
xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Kumo Tea Journal</title>
    <link>https://shop.example.com/journal</link>
    <description>Brewing guides &amp; news from the tea room</description>
    <lastBuildDate>Thu, 22 Oct 2026 09:30:00 +0000</lastBuildDate>
    <item>
      <title>Gyokuro at 50 °C: why cooler water tastes sweeter</title>
      <link>https://shop.example.com/journal/gyokuro-50-degrees</link>
      <guid isPermaLink="true">https://shop.example.com/journal/gyokuro-50-degrees</guid>
      <pubDate>Thu, 22 Oct 2026 08:00:00 +0000</pubDate>
      <category>Brewing</category>
      <category>Green tea</category>
      <description>Low temperature keeps the bitterness &lt;em&gt;down&lt;/em&gt;.</description>
      <content:encoded><![CDATA[<p>Gyokuro is shaded for three weeks before harvest &amp; brewed <strong>cool</strong>.</p>]]></content:encoded>
    </item>
  </channel>
</rss>
php
<?php

// @cdata ... @end wraps its rendered body in a CDATA section. A "]]>" inside the body
// would end the section early, so it is split across two sections.
$tsuku->registerDirective('cdata', static fn (string $body): string =>
    '<![CDATA[' . str_replace(']]>', ']]]]><![CDATA[>', $body) . ']]>');

echo $tsuku->process($template, $data);

SOAP request ​

A sales order sent to an ERP. The line numbers come from the array keys: the items are keyed from 1, and @for(order.items as item, line) hands over the key. @date("c", ...) writes an ISO 8601 timestamp, and the <erp:Remarks> element is left out when there is no comment.

tsuku-xml
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:erp="urn:example:erp:orders">
  <soap:Body>
    <erp:CreateSalesOrder>
      <erp:OrderNumber>@xml(order.increment_id)</erp:OrderNumber>
      <erp:OrderDate>@date("c", order.created_at)</erp:OrderDate>
      <erp:Customer number="@xml(order.customer.number)">
        <erp:Name>@xml(order.customer.name)</erp:Name>
        <erp:Email>@xml(order.customer.email)</erp:Email>
      </erp:Customer>
      <erp:Lines>
        @for(order.items as item, line)
        <erp:Line number="{line}">
          <erp:Sku>@xml(item.sku)</erp:Sku>
          <erp:Quantity>{item.qty}</erp:Quantity>
          <erp:UnitPrice currency="EUR">@number(item.price, 2, ".", "")</erp:UnitPrice>
        </erp:Line>
        @end
      </erp:Lines>
      @if(order.comment)
      <erp:Remarks>@xml(order.comment)</erp:Remarks>
      @end
    </erp:CreateSalesOrder>
  </soap:Body>
</soap:Envelope>
php
<?php

return [
    'order' => [
        'increment_id' => '000002203',
        'created_at' => strtotime('2026-10-21 14:05:09'),
        'customer' => ['number' => 'C-10442', 'name' => 'Anna de Vries', 'email' => '[email protected]'],
        // keyed from 1: the loop's second variable is the array key, used as the line number
        'items' => [
            1 => ['sku' => 'TEA-KYU-350', 'qty' => 1, 'price' => 49.00],
            2 => ['sku' => 'TEA-SEN-100', 'qty' => 2, 'price' => 14.90],
        ],
        'comment' => 'Gift wrap, no invoice in the parcel',
    ],
];
xml
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:erp="urn:example:erp:orders">
  <soap:Body>
    <erp:CreateSalesOrder>
      <erp:OrderNumber>000002203</erp:OrderNumber>
      <erp:OrderDate>2026-10-21T14:05:09+00:00</erp:OrderDate>
      <erp:Customer number="C-10442">
        <erp:Name>Anna de Vries</erp:Name>
        <erp:Email>[email protected]</erp:Email>
      </erp:Customer>
      <erp:Lines>
        <erp:Line number="1">
          <erp:Sku>TEA-KYU-350</erp:Sku>
          <erp:Quantity>1</erp:Quantity>
          <erp:UnitPrice currency="EUR">49.00</erp:UnitPrice>
        </erp:Line>
        <erp:Line number="2">
          <erp:Sku>TEA-SEN-100</erp:Sku>
          <erp:Quantity>2</erp:Quantity>
          <erp:UnitPrice currency="EUR">14.90</erp:UnitPrice>
        </erp:Line>
      </erp:Lines>
      <erp:Remarks>Gift wrap, no invoice in the parcel</erp:Remarks>
    </erp:CreateSalesOrder>
  </soap:Body>
</soap:Envelope>
php
echo $tsuku->process($template, $data);

SVG chart ​

Templates have no arithmetic - {index * 50} is printed as text - so PHP computes the bar positions and the template only places them. @?{bar.best "#3451b2" : "#8ea2e8"} picks the colour of the best month.

tsuku-xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {chart.width} {chart.height}" width="{chart.width}" height="{chart.height}" role="img" aria-label="@xml(chart.title)">
  <title>@xml(chart.title)</title>
  <line x1="0" y1="{chart.baseline}" x2="{chart.width}" y2="{chart.baseline}" stroke="#a1a1aa"/>
  @for(bars as bar)
  <g>
    <rect x="{bar.x}" y="{bar.y}" width="{chart.bar_width}" height="{bar.height}" rx="3" fill="@?{bar.best "#3451b2" : "#8ea2e8"}"/>
    <text x="{bar.center}" y="{bar.value_y}" text-anchor="middle" font-size="11" fill="#52525b">@number(bar.value, 0)</text>
    <text x="{bar.center}" y="{chart.label_y}" text-anchor="middle" font-size="12" fill="#52525b">@xml(bar.label)</text>
  </g>
  @end
</svg>
php
<?php

// Templates have no arithmetic, so PHP computes the geometry and the template only places it.
$orders = ['Jun' => 412, 'Jul' => 388, 'Aug' => 451, 'Sep' => 536, 'Oct' => 604];
$width = 360;
$baseline = 150;
$barWidth = 44;
$step = $width / count($orders);
$scale = 120 / max($orders);

$bars = [];
foreach (array_keys($orders) as $i => $month) {
    $height = (int) round($orders[$month] * $scale);
    $bars[] = [
        'label' => $month,
        'value' => $orders[$month],
        'best' => $orders[$month] === max($orders),
        'x' => (int) round($i * $step + ($step - $barWidth) / 2),
        'y' => $baseline - $height,
        'height' => $height,
        'center' => (int) round($i * $step + $step / 2),
        'value_y' => $baseline - $height - 6,
    ];
}

return [
    'chart' => [
        'title' => 'Orders per month',
        'width' => $width,
        'height' => 175,
        'baseline' => $baseline,
        'bar_width' => $barWidth,
        'label_y' => $baseline + 18,
    ],
    'bars' => $bars,
];
xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 175" width="360" height="175" role="img" aria-label="Orders per month">
  <title>Orders per month</title>
  <line x1="0" y1="150" x2="360" y2="150" stroke="#a1a1aa"/>
  <g>
    <rect x="14" y="68" width="44" height="82" rx="3" fill="#8ea2e8"/>
    <text x="36" y="62" text-anchor="middle" font-size="11" fill="#52525b">412</text>
    <text x="36" y="168" text-anchor="middle" font-size="12" fill="#52525b">Jun</text>
  </g>
  <g>
    <rect x="86" y="73" width="44" height="77" rx="3" fill="#8ea2e8"/>
    <text x="108" y="67" text-anchor="middle" font-size="11" fill="#52525b">388</text>
    <text x="108" y="168" text-anchor="middle" font-size="12" fill="#52525b">Jul</text>
  </g>
  <g>
    <rect x="158" y="60" width="44" height="90" rx="3" fill="#8ea2e8"/>
    <text x="180" y="54" text-anchor="middle" font-size="11" fill="#52525b">451</text>
    <text x="180" y="168" text-anchor="middle" font-size="12" fill="#52525b">Aug</text>
  </g>
  <g>
    <rect x="230" y="44" width="44" height="106" rx="3" fill="#8ea2e8"/>
    <text x="252" y="38" text-anchor="middle" font-size="11" fill="#52525b">536</text>
    <text x="252" y="168" text-anchor="middle" font-size="12" fill="#52525b">Sep</text>
  </g>
  <g>
    <rect x="302" y="30" width="44" height="120" rx="3" fill="#3451b2"/>
    <text x="324" y="24" text-anchor="middle" font-size="11" fill="#52525b">604</text>
    <text x="324" y="168" text-anchor="middle" font-size="12" fill="#52525b">Oct</text>
  </g>
</svg>
php
echo $tsuku->process($template, $data);
Preview

Streaming a large feed ​

process() builds the whole document in memory. processToStream() renders the part before the @for once, then the loop body once per row, then the rest, and hands each piece to a callback, so memory use does not grow with the catalog. The rows can come from a generator; the template needs exactly one top-level @for over the streamed collection. See Streaming Large Datasets.

tsuku-xml
<?xml version="1.0" encoding="UTF-8"?>
<catalog store="@xml(store.code)" currency="@xml(store.currency)">
  @for(products as product)
  <product sku="@xml(product.sku)">
    <name>@xml(product.name)</name>
    <price>@number(product.price, 2, ".", "")</price>
    <qty>{product.qty}</qty>
  </product>
  @end
</catalog>
php
<?php

// A generator: a real one would read the catalog page by page, so only one row is ever in memory.
$products = (static function (): Generator {
    yield ['sku' => 'TEA-KYU-350', 'name' => 'Kyusu Teapot 350 ml', 'price' => 49.00, 'qty' => 12];
    yield ['sku' => 'TEA-SEN-100', 'name' => 'Sencha Asamushi 100 g', 'price' => 14.90, 'qty' => 140];
    yield ['sku' => 'TEA-TET-080', 'name' => 'Cast Iron Kettle "Tetsubin" 0.8 l', 'price' => 89.00, 'qty' => 0];
})();

return [
    'store' => ['code' => 'nl_en', 'currency' => 'EUR'],
    'products' => $products,
];
xml
<?xml version="1.0" encoding="UTF-8"?>
<catalog store="nl_en" currency="EUR">
  <product sku="TEA-KYU-350">
    <name>Kyusu Teapot 350 ml</name>
    <price>49.00</price>
    <qty>12</qty>
  </product>
  <product sku="TEA-SEN-100">
    <name>Sencha Asamushi 100 g</name>
    <price>14.90</price>
    <qty>140</qty>
  </product>
  <product sku="TEA-TET-080">
    <name>Cast Iron Kettle &quot;Tetsubin&quot; 0.8 l</name>
    <price>89.00</price>
    <qty>0</qty>
  </product>
</catalog>
php
<?php

// The header and footer are rendered once, the @for body once per row, and every piece
// goes to the writer as soon as it is ready.
$tsuku->processToStream(
    $template,
    ['store' => $data['store']], // shared by the header, every row and the footer
    $data['products'],           // any iterable; a generator is consumed once
    'products',                  // the @for collection that is streamed
    static function (string $chunk): void {
        echo $chunk;             // in production: fwrite($handle, $chunk)
    },
);

Checking the result ​

A missing @xml() or an unbalanced tag only shows up when a consumer rejects the file. Parse generated feeds in a test:

php
$xml = $tsuku->process($template, $data);

$document = new DOMDocument();
if (!$document->loadXML($xml)) {
    throw new RuntimeException('The feed is not well-formed XML');
}

Next Steps ​

Released under the MIT License.