topthink/think-template
A lightweight PHP template engine for ThinkPHP that compiles templates into plain PHP. It supports template inheritance, includes, variable output, custom tags and caching, making view rendering fast, simple and easy to integrate into existing applications.
think-template is the standalone extraction of ThinkPHP's view engine. If you have ever used {$var}, {volist}, or {extend} in a ThinkPHP app, this is the code that compiles it. It has no framework dependency, so it drops into small apps, CLI renderers, and email builders.
composer require topthink/think-template
Minimum viable setup — point it at a template directory and a writable compile directory, then render:
require __DIR__ . '/vendor/autoload.php';
$tpl = new \think\Template([
'view_path' => __DIR__ . '/view/',
'cache_path' => __DIR__ . '/runtime/tpl/',
'view_suffix' => 'html',
]);
$tpl->assign('title', 'Invoices');
$tpl->assign('user', ['name' => 'Ada']);
$tpl->assign('items', [
['id' => 1, 'total' => 99.5],
['id' => 2, 'total' => 12.0],
]);
$tpl->display('invoice/index'); // echoes
// $html = $tpl->fetch('invoice/index'); // returns a string instead
view/invoice/index.html:
<h1>{$title}</h1>
<p>Hello, {$user.name}</p>
{volist name="items" id="item" empty="No line items"}
<tr>
<td>{$item.id}</td>
<td>{$item.total|number_format=2}</td>
</tr>
{/volist}
Three things to look at first:
think\Template — the public surface (assign, fetch, display, config array). Everything else is compilation.think\template\taglib\Cx — the built-in tag library. This file is the documentation for the exact attributes each tag accepts (offset, length, mod, empty, key, etc.).cache_path. It is plain PHP named after md5() of the template path. Reading it is the fastest way to understand what your template actually does.First realistic use case: a controller-ish function that assigns data and renders one layout-based page, then reuses the same engine instance to fetch() an email body. That covers 90% of day-to-day usage.
display() echoes; fetch() returns a string. For anything that is not a direct HTTP response — emails, PDF bodies, caching, test assertions — always use fetch(). Passing vars directly as the second argument skips the assign dance:
$html = $tpl->fetch('mail/welcome', ['user' => $user, 'link' => $url]);
assign() merges into a persistent variable bag on the instance, so long-lived processes (queue workers, Swoole) must be careful: variables leak between renders. Either re-instantiate per render or overwrite every key you depend on.
Global layout — applies to every render, controlled by config:
'layout_on' => true,
'layout_name' => 'layout',
'layout_item' => '{__CONTENT__}',
Per-template inheritance — more explicit and easier to reason about:
<!-- view/page/about.html -->
{extend name="layout/base" /}
{block name="title"}About us{/block}
{block name="content"}
<p>Body goes here.</p>
{/block}
<!-- view/layout/base.html -->
<html>
<head><title>{block name="title"}Default{/block}</title></head>
<body>{block name="content"}{/block}</body>
</html>
Use one mechanism per template tree. Mixing global layout_on with {extend} produces confusing nested output.
{include file="public/header" /} (note the self-closing slash) pulls in another template at compile time — the file must exist when the template compiles, not when it renders. Include files inherit the current variable scope, which is convenient but makes it easy to accidentally depend on parent state. For partials rendered with different data, call fetch() from PHP and inject the result as a variable instead.
{volist name="orders" id="order" key="i"}
<tr class="{if $i % 2 == 0}even{else}odd{/if}">
<td>{$i}</td>
<td>{$order.customer}</td>
</tr>
{/volist}
{foreach name="tags" item="tag" key="k"}
<span>{$k}:{$tag}</span>
{/foreach}
{volist} is the workhorse (offset, length, mod, empty). {foreach} is thinner and closer to PHP semantics. Nested {volist} blocks must use distinct id values, or the inner loop overwrites the outer variable.
Comparison tags keep complex conditions out of attribute strings:
{notempty name="user.avatar"}
<img src="{$user.avatar}">
{else/}
<img src="/img/placeholder.png">
{/notempty}
{eq name="order.status" value="paid"}Paid{else/}Pending{/eq}
Available shorthands include eq, neq, gt, lt, egt, elt, heq, nheq, in, notin, between, notbetween, empty, notempty, defined, notdefined, even, odd, present, notpresent.
Filters pipe the value through a PHP callable, left to right:
{$user.name|default='Guest'|htmlspecialchars}
{$price|number_format=2}
{$body|nl2br}
{$created_at|substr=0,10}
String arguments need quotes; arguments are comma-separated with no spaces. {:func($arg)} echoes an arbitrary function result — useful for {:csrf_field()}-style helpers, but the function must exist at runtime or you get a fatal error, not a template error.
Rather than hardcoding CDN paths, define them once:
'tpl_replace_string' => [
'__STATIC__' => '/assets',
'__CDN__' => 'https://cdn.example.com',
],
Then {load href="__STATIC__/app.css" /}, {js href="__STATIC__/app.js" /}, {css href="..." /}, or plain __STATIC__/logo.png in attributes.
think\template\Taglib, mirror Cx, and register it via the taglib_pre_load / taglib_build_in config keys.Template::extend('tagname', callable) maps a tag to your own compiler function.tpl_begin/tpl_end and taglib_begin/taglib_end are config, not hardcoded. Change them if { clashes with your front-end stack.Because fetch() is pure, template tests are trivial: assign fixtures, assert str_contains($html, 'Ada'), and assert the escaping behaviour explicitly. Add a test that renders a malicious string (<script>) and proves it is escaped — this catches accidental removal of the default filter during refactors.
{$user.name} compiles to a raw echo. If the value contains user input, it is an XSS hole. Set 'default_filter' => 'htmlentities' globally and use {$html|raw} for the rare trusted fragment. Do not rely on reviewers catching every unescaped variable.{ collides with JavaScript and CSS. {literal}...{/literal} protects a block, but changing tpl_begin/tpl_end to something like {{/}} is usually less painful in modern projects. Also beware inline <script> with object literals.{$user.name} compiles to $user['name']. Objects need {$user->name} or a conversion to array first. On PHP 8, accessing a key on null is a warning, so guard optional data with {$x|default=''} or {notempty}.{php} is disabled by default. tpl_deny_php defaults to true, and tpl_deny_func_list blocks dangerous callables. If a {php} block silently vanishes, this is why. Do not enable it because a template "needs logic" — move the logic into the data you assign.'tpl_cache' => false or delete cache_path after edits. The classic symptom of a stale cache is editing a template and seeing nothing change. If mtimes are unreliable (NFS, containers, git checkouts preserving times), set tpl_cache => false or touch the file.cache_path must exist and be writable. A permission failure surfaces as a confusing include error rather than a clear "cache not writable" message. Create the directory with the correct owner at deploy time.TemplateNotFoundException, not a PHP warning. The name is resolved relative to view_path, dots become directory separators, and view_suffix is appended — so pass 'invoice/index', not 'invoice/index.html'. Verify the resolved path before assuming a parser bug.cache_path and read the generated code. Ninety percent of "the template engine is broken" reports are a filter argument or a condition string the parser interpreted differently than expected.condition attributes use ThinkPHP operators. {if condition="$a eq 1"} works, {if condition="$a === 1"} may not survive parsing intact. Prefer the comparison tags or plain equality for anything non-trivial.{$Think.get.id}, {:url()}, {:lang()}, and the {load}/{js}/{css} URL resolution all expect ThinkPHP's helpers or __STATIC__ replacements. In standalone use, define these as plain functions or via tpl_replace_string, otherwise you get a fatal "call to undefined function".strip_space is not free. It reduces payload size but will happily collapse whitespace inside <pre>, inline-block layouts, and text between inline elements. Test rendered output before enabling it.view_path outside the document root, and never build template names from request parameters without an allow-list.think\View / the View facade in a full ThinkPHP app. If you are inside that framework, use the facade and let it handle paths and caching; reach for think\Template directly only when you need rendering without the framework — and pin the version in composer.json, since the API is stable but not versioned for third-party extension.How can I help you explore Laravel packages today?