Who this is for. The developer who knows one engine and has to read, review or write the other, and anyone estimating a port. Each row has an id, so a review comment can link straight to it. The engine pages are Smarty and Twig; the behaviour that differs, beyond syntax, is on Behaviour that differs between the engines.

The equivalence table

ConstructSmartyTwig
Print a value{$title}{{ title }}
Call a method on a record{$page->getName()}{{ page.getName() }}
Getter or area as an attribute{$component->title}{{ component.title }}
Array key{$item.title}, {$item['data-id']}{{ item.title }}, {{ item['data-id'] }}
Modifier or filter{$title|truncate:60:'…'}{{ title|u.truncate(60, '…') }}
Fallback for a missing value{$title|default:'Untitled'}{{ title|default('Untitled') }} or {{ title ?? 'Untitled' }}
Escape for HTML{$title|escape}{{ title|e }}
Escape for a URL{$q|escape:'url'}{{ q|e('url') }}
Encode for JavaScript or JSON{$data|json_encode}{{ data|json_encode }}
Join strings{'Page '|cat:$n}{{ 'Page ' ~ n }}
A variable inside a string"Results for {$q}""Results for #{q}"
Condition{if $a == 1 && !$b}…{elseif $c}…{else}…{/if}{% if a == 1 and not b %}…{% elseif c %}…{% else %}…{% endif %}
Ternary{($n == 1) ? 'item' : 'items'}, condition in parentheses{{ n == 1 ? 'item' : 'items' }}
Loop{foreach $items as $item}…{/foreach}{% for item in items %}…{% endfor %}
Loop with keys{foreach $map as $key => $value}{% for key, value in map %}
Empty list{foreachelse}{% else %}
Pass number, position$item@iteration (from 1), $item@index (from 0)loop.index, loop.index0
First, last, count$item@first, $item@last, $item@totalloop.first, loop.last, loop.length
Leave or skip a pass{break}, {continue}None: filter the list first, items|filter(i => …), or |slice
Count from 1 to 5{for $i = 1 to 5}…{/for}{% for i in 1..5 %}…{% endfor %}
Assign{$count = 3}{% set count = 3 %}, also stored in the template context
Capture markup{capture assign='intro'}…{/capture}{% set intro %}…{% endset %}
Append to a list{$list[] = $item}{% set list = list|merge([item]) %}
Array or map literal['width' => 800]{width: 800}
Extend a base template{extends 'includes/base.html'}{% extends 'includes/base.html' %}
Define or fill a block{block name='content'}…{/block}{% block content %}…{% endblock %}
Keep the base's content{$smarty.block.parent}, or append, prepend{{ parent() }}
Render a block only when a child fills it{block name='promo' hide}…{$smarty.block.child}…{/block}{% if block('promo') is defined %}…{% endif %}
Include with values{include 'elements/card.html' title=$t}{% include 'elements/card.html' with {title: t} %}
Include with only the values passedNone{% include 'elements/card.html' with {title: t} only %}
Reusable fragment{function name=badge label=''}…{/function}, then {badge label='New'}{% macro badge(label) %}…{% endmacro %}, then {% import … as ui %}{{ ui.badge('New') }}
Wrapper the caller fillsCapture the parts, pass them to an include{% embed 'elements/panel.html' %}{% block body %}…{% endblock %}{% endembed %}
Comment{* … *}{# … #}
Print braces literally{literal}…{/literal}; a brace followed by a space is literal anyway{% verbatim %}…{% endverbatim %}
Remove whitespace{strip}…{/strip}{%- … -%}, {{- … -}}
Is it set?{if isset($x)}{% if x is defined %} (always true for a record's attributes)
Is it empty?{if empty($x)}{% if x is empty %}; a record is never empty
Convert a type|intval, |strval|integer, |float, |string, |boolean, which fail on null
A PHP helperCalled from the policy's list: {trim($x, '/')}A filter: {{ x|trim('/') }}
Translate a key{'cart.add'|_:'site-shop'}{{ __.translate('cart.add', {}, 'site-shop') }}, usually through a macro
A date in the site's format{$page->getPublished()|format:'date'}{{ page.getPublished()|date(_config.get('Globals.i18n.dateFormat')) }}
JSON written out as text{json}…{/json}Build a map and use |json_encode
Dump values on development{debug_vars page=$page}{{ debug_vars(page) }}
Stop the render with an error{error message='…'}None
A query-string parameter{$_request->getParameter('q')}{{ _request.getParameter('q') }}
Store for the rest of the render{$_templateContext->set('key', $value)}{% do _templateContext.set('key', value) %}
Call a method for its effect{$_response->preventCaching()} (prints nothing){% do _response.preventCaching() %}

What only Smarty has

  • Registered translation tags: {_}, |_ and {__}. In Twig, a macro over __.translate() (Translating in Twig).
  • |format: a date in the site's configured format. In Twig, |date with the format read from _config, as in the table, kept in one macro.
  • The {json} block. In Twig, build a map with set and |json_encode, which cannot produce invalid JSON.
  • {error}. Twig has no way to stop a render on purpose; render nothing, or a visible message on development environments.
  • {break} and {continue}. In Twig, filter or slice the list before the loop.
  • The function list as a safety net: a call outside the policy does not compile. Twig has no sandbox here; review takes its place (Twig security).
  • A loud failure for a method a record does not have. In Twig the same call prints nothing; check the method against the record's list (Response models).

What only Twig has

  • embed. In Smarty, capture each part with {capture} and pass the captures to an include.
  • Macros imported from a library. Smarty's {function} is the nearest equivalent; a function defined in another file is available only after that file has been included.
  • Isolated includes, only. In Smarty, pass every value the partial needs and name partial variables so they cannot collide.
  • Strict variables, used by the platform only for Mailer emails. Smarty never raises on an undefined variable; it logs a warning.
  • The string extension: |u and |slug. In Smarty, |truncate, |lower and |regex_replace cover most uses, and $_string->slugify() makes slugs.
  • Typed coercion: |boolean, |integer, |float, |string. Smarty's |intval and |strval do not fail on null.
  • ignore missing on an include. In Smarty, keep the possible paths to a fixed list.