A CLI linter for Smarty templates, powered by SmartyAST.
Parses each template into a typed AST and runs configurable walker-based rules. Supports project-wide analysis for unused code.
- PHP 8.4+
- Composer (only required for source / Composer installs)
Download the latest pre-built binary from GitHub Releases:
curl -L https://github.com/3n9/SmartyLint/releases/latest/download/smartylint.phar \
-o smartylint.phar
chmod +x smartylint.phar
sudo mv smartylint.phar /usr/local/bin/smartylintVerify the install:
smartylint --versioncomposer require 3n9/smarty-lint
vendor/bin/smarty-lint --versiongit clone https://github.com/3n9/SmartyLint.git
cd SmartyLint
composer install
php bin/smarty-lint --versionphp bin/smarty-lint [options] <file|dir> [...]
| Flag | Description |
|---|---|
--recursive, -r |
Recursively scan directory for .tpl files (unreadable subdirectories are skipped) |
--format <fmt> |
Output format: text (default), json, sarif, checkstyle |
--json |
Alias for --format json |
--find-unused |
Run project-wide unused-code analysis after per-file linting |
--errors-only |
Suppress warnings; only report ERROR severity issues |
--exclude <pattern> |
Exclude files matching a glob pattern (repeatable) |
--template-root <path> |
Base directory for resolving {include} / {extends} paths |
--max-depth <n> |
Maximum recursion depth for --recursive directory scans (0 = root directory only) |
--enable <rule> |
Enable an opt-in rule (repeatable), e.g. --enable UnescapedVariable |
--version, -V |
Print version and exit |
| Code | Meaning |
|---|---|
0 |
No issues found (or all suppressed by --errors-only) |
1 |
Issues found, or a fatal error occurred |
Lint a single file:
php bin/smarty-lint templates/page.tplLint an entire directory:
php bin/smarty-lint --recursive templates/JSON output (for CI/editor integration):
php bin/smarty-lint --json --recursive templates/SARIF output (GitHub Actions / VS Code):
php bin/smarty-lint --format sarif --recursive templates/ > results.sarifCheckstyle XML output (Jenkins / PHPStorm):
php bin/smarty-lint --format checkstyle --recursive templates/Errors only (fail fast, no warnings):
php bin/smarty-lint --errors-only --recursive templates/Exclude generated or vendor templates:
php bin/smarty-lint --recursive --exclude '*/vendor/*' --exclude '*/generated/*' templates/Resolve includes relative to a shared template root:
php bin/smarty-lint --template-root /var/www/app/templates --recursive templates/Project-wide unused code analysis:
php bin/smarty-lint --find-unused --recursive templates/Create lua/plugins/lint.lua in your Neovim config:
return {
{
"mfussenegger/nvim-lint",
opts = function(_, opts)
-- Register the smartylint linter
local lint = require("lint")
lint.linters.smartylint = {
name = "smartylint",
cmd = "smartylint",
args = { "--json" },
stdin = false,
append_fname = true,
stream = "stdout",
ignore_exitcode = true,
parser = function(output)
local issues = vim.json.decode(output) or {}
local diagnostics = {}
for _, issue in ipairs(issues) do
table.insert(diagnostics, {
lnum = issue.line - 1,
col = issue.col - 1,
message = issue.message,
severity = issue.severity == "ERROR"
and vim.diagnostic.severity.ERROR
or vim.diagnostic.severity.WARN,
source = "smartylint",
})
end
return diagnostics
end,
}
-- Attach to Smarty files
opts.linters_by_ft = opts.linters_by_ft or {}
opts.linters_by_ft.smarty = { "smartylint" }
end,
},
}Neovim doesn't have a built-in smarty filetype. Add this to detect .tpl files:
vim.filetype.add({ extension = { tpl = "smarty" } })Install the diagnostic-languageserver extension, then add this to your workspace or user settings.json:
{
"diagnostic-languageserver.linters": {
"smartylint": {
"command": "smartylint",
"args": ["--json", "%filepath"],
"sourceName": "SmartyLint",
"parseJson": {
"errorsRoot": "",
"line": "line",
"column": "col",
"message": "${message}",
"security": "severity"
},
"securities": {
"ERROR": "error",
"WARNING": "warning"
}
}
},
"diagnostic-languageserver.filetypes": {
"smarty": "smartylint"
}
}VS Code doesn't recognise .tpl as Smarty by default. Install the Smarty Template Support extension, or add a manual association:
{
"files.associations": {
"*.tpl": "smarty"
}
}Text (default):
templates/page.tpl:12:5: [ERROR] {php} tag is deprecated
templates/partials/item.tpl:3:1: [WARNING] Missing required parameter 'title' when including 'partials/card.tpl'
JSON (--json or --format json):
[
{
"path": "templates/page.tpl",
"line": 12,
"col": 5,
"severity": "ERROR",
"message": "{php} tag is deprecated"
}
]SARIF 2.1.0 (--format sarif) — for GitHub Actions, VS Code SARIF Viewer:
{
"$schema": "https://raw.githubusercontent.com/.../sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [{ "tool": { "driver": { "name": "SmartyLint", ... } }, "results": [...] }]
}Checkstyle XML (--format checkstyle) — for Jenkins, PHPStorm:
<?xml version="1.0" encoding="UTF-8"?>
<checkstyle version="8.0">
<file name="templates/page.tpl">
<error line="12" column="5" severity="error" message="{php} tag is deprecated" source="smartylint"/>
</file>
</checkstyle>Create .smartylintrc.json in the directory where you run smarty-lint (usually the project root):
{
"templateRoot": "templates",
"maxNestingDepth": 4,
"maxScanDepth": 3,
"disabledRules": ["DeprecatedTag"],
"excludePatterns": ["*/vendor/*", "*/generated/*"]
}| Key | Type | Description |
|---|---|---|
templateRoot |
string | Base directory for resolving {include} / {extends} paths |
maxNestingDepth |
int | Maximum block nesting depth before DeepNestingWalker warns (default 5) |
maxScanDepth |
int | Maximum depth for recursive directory scans (0 = root directory only, omitted = unlimited) |
disabledRules |
string[] | Walker names to disable (case-insensitive): DeprecatedTag, RelativePath, BlockStructure, IncludeParameter, UnusedCapture, EmptyBlock, DeepNesting, DuplicateBlockName, UnusedAssign |
strictRules |
string[] | Opt-in rules to enable (off by default): UnescapedVariable |
excludePatterns |
string[] | Glob patterns for files to skip |
CLI flags take precedence over the config file.
| Rule | Key (for config) | On by default | Needs --template-root |
Description |
|---|---|---|---|---|
IncludeCycleDetector |
(always on) | ✅ always | ✅ required | Circular {include} / {extends} dependency chains |
BlockStructureWalker |
blockstructure |
✅ | — | Structural errors in {if}/{else}/{elseif} blocks |
DeprecatedTagWalker |
deprecatedtag |
✅ | — | Use of deprecated tags ({php}, {insert}) |
RelativePathWalker |
relativepath |
✅ | — | Relative paths in {include} / {extends} |
IncludeParameterWalker |
includeparameter |
✅ | ✅ required | Missing required @param-annotated parameters in {include} |
UnusedCaptureWalker |
unusedcapture |
✅ | — | {capture} blocks whose variable is never read |
EmptyBlockWalker |
emptyblock |
✅ | — | {if}, {foreach} etc. blocks with only whitespace content |
DeepNestingWalker |
deepnesting |
✅ | — | Block nesting exceeding maxNestingDepth (default 5) |
DuplicateBlockNameWalker |
duplicateblockname |
✅ | — | {block} names declared more than once in the same file |
UnusedAssignWalker |
unusedassign |
✅ | — | {assign} variables never read in the same template |
SuperglobalAccessWalker |
superglobalaccess |
✅ | — | Direct access to $smarty.get.*, $smarty.post.*, etc. |
ComplexExpressionWalker |
complexexpression |
✅ | — | Modifier chains or boolean conditions exceeding configured limits |
ModifierTypeMismatchWalker |
modifiertypemismatch |
✅ | — | Modifier used on an expression of an incompatible inferred type |
UnescapedVariableWalker |
unescapedvariable |
⚙️ opt-in | — | Variables printed without an HTML-escaping modifier |
Default-on rules can be disabled via disabledRules in .smartylintrc.json or LintConfig. Opt-in rules must be explicitly enabled via --enable <key> or strictRules in the config file. Rules marked Needs --template-root resolve included files from disk; without a template root they silently skip cross-file checks.
Reports circular dependencies between templates via {include} or {extends} tags. Both direct self-inclusion and indirect cycles across any number of templates are detected.
{* ERROR: Include cycle detected: a.tpl -> b.tpl -> a.tpl *}
{include file="b.tpl"}{* ERROR: Include cycle detected: a.tpl -> b.tpl -> a.tpl *}
{extends file="b.tpl"}This check is always active and cannot be disabled via disabledRules.
Reports use of deprecated Smarty tags.
| Tag | Message |
|---|---|
{php} |
{php} tag is deprecated |
{insert} |
{insert} tag is deprecated |
Reports relative paths in {include} and {extends} tags. Relative paths (starting with ./ or ../) are fragile because they depend on the calling template's location.
{* ERROR *}
{include file="../partials/item.tpl"}
{* OK *}
{include file="partials/item.tpl"}Reports {capture} blocks whose assigned variable is never read.
{capture assign="sidebar"}
...content...
{/capture}
{* ERROR: $sidebar captured but never used *}Supports both assign= and name= attribute forms. Detects usage of $sidebar and $smarty.capture.sidebar.
Reports missing required parameters when calling {include}. Required parameters are declared in the included template using @param annotations in a comment block.
Declaring required parameters in a template:
{* @param string $title Card title (required)
@param string $body Card body text (required) *}
<div class="card">
<h2>{$title}</h2>
<p>{$body}</p>
</div>Calling with a missing parameter:
{* WARNING: Missing required parameter 'body' *}
{include file="partials/card.tpl" title="Hello"}Notes:
- All
@paramannotations must be in a single comment block at the top of the template. - Only the first comment node in the template is scanned.
- Parameters with defaults or optional ones should not use
@param(or be excluded from the comment).
Reports structural issues in {if}/{else}/{elseif} blocks.
| Issue | Severity |
|---|---|
{else} or {/if} misaligned with opening {if} |
WARNING |
{elseif} or {else} after a previous {else} |
ERROR |
{else} tag used with a condition (should be {elseif}) |
ERROR |
Multiple {else} blocks in the same {if} |
ERROR |
Warns when {if}, {foreach}, {for}, {while}, or {section} blocks contain nothing but whitespace — almost always a bug.
{* WARNING: Empty if block has no content. *}
{if $submitted}{/if}Blocks with {else} or {elseif} branches are not reported, even if the main body is empty.
Warns when block tags are nested deeper than maxNestingDepth (default 5, configurable via .smartylintrc.json or LintConfig).
{* WARNING: Block if nesting depth 6 exceeds maximum of 5. *}
{if $a}{if $b}{if $c}{if $d}{if $e}{if $f}...{/if}{/if}{/if}{/if}{/if}{/if}Only the outermost violating block is reported (inner blocks are implied).
Warns when {block name="..."} is declared more than once in the same template file. Duplicate block names cause Smarty to silently use the first definition and ignore all subsequent ones.
{block name="header"}<h1>Title</h1>{/block}
{block name="content"}<p>Main</p>{/block}
{* WARNING: Duplicate block name 'header': already defined in this template. *}
{block name="header"}<h2>Oops</h2>{/block}Warns when {assign var="x"} (or the shorthand {assign $x = ...}) sets a variable that is never referenced anywhere in the same template — either as a print expression or as a tag argument.
{assign var="title" value="Hello"}
{assign var="unused" value="world"} {* WARNING: $unused is assigned but never used *}
<h1>{$title}</h1>Covers both the named (var=) and shorthand forms, as well as {append}.
Warns when a variable is printed without an HTML-escaping modifier. Because Smarty does not auto-escape output, {$var} renders the raw value and is a potential XSS vector.
Enable via CLI:
php bin/smarty-lint --enable UnescapedVariable --recursive templates/Enable via config file:
{
"strictRules": ["UnescapedVariable"]
}{* WARNING: printed without escaping modifier *}
{$name}
{$title|upper}
{* OK *}
{$name|escape}
{$name|escape:'html'}
{$name|escape:'htmlall'}The recognised escape modifier is escape (all escape:* type variants are covered).
Warns when a template reads HTTP input or server environment data directly via $smarty.get.*, $smarty.post.*, $smarty.request.*, $smarty.cookies.*, $smarty.server.*, $smarty.env.*, or $smarty.session.*.
Templates should receive pre-processed, validated data from the controller. Direct superglobal access makes templates hard to test and bypasses input validation, which can introduce XSS or injection vulnerabilities.
Disable via config file (if your codebase intentionally uses superglobals in templates):
{
"disabledRules": ["SuperglobalAccess"]
}{* WARNING: direct superglobal access *}
{$smarty.get.q}
{$smarty.post.name}
{if $smarty.session.user}...{/if}
{include file="partial.tpl" title=$smarty.request.title}
{* OK — variable assigned by controller *}
{$search_query}
{$current_user}The rule fires on any use of the value — print, condition, tag argument — not only unescaped output.
Enables project-wide analysis for dead code. Best used with --recursive to give the analyzer full project context.
Three sub-analyzers run after per-file linting:
Warns when a named argument passed to {include} is never referenced in the included template.
{* WARNING: Dead parameter 'color' passed to 'partials/item.tpl' is never referenced *}
{include file="partials/item.tpl" name=$product.name color="red"}Warns when a {block} defined in a parent template is never overridden by any child that {extends} it.
{* layouts/base.tpl *}
{block name="sidebar"}{/block} {* WARNING if no child overrides "sidebar" *}
{* pages/home.tpl *}
{extends file="layouts/base.tpl"}
{block name="content"}...{/block}Warns when a {function} definition is never called anywhere in the scanned files.
{* partials/helpers.tpl *}
{function name="render_badge"}...{/function} {* WARNING if never called *}Shorthand invocations ({render_badge label="x"}) are detected in addition to explicit {call name="render_badge"}.
SmartyLint caches parse results in sys_get_temp_dir() (e.g. /tmp/smartylint-<hash>.json), keyed by the directory where smartylint is run. Cached results are invalidated automatically when:
- A file's content changes
- The active configuration (disabled rules, strict rules, etc.) changes
No cache files are written into your project directory, so no .gitignore entry is needed.
Use LintEngine directly to integrate SmartyLint into editors, LSP servers, or CI scripts without parsing CLI arguments:
use SmartyLint\LintEngine;
use SmartyLint\LintConfig;
use SmartyLint\LintCache;
$config = new LintConfig(
templateRoot: '/var/www/app/templates',
disabledRules: ['DeprecatedTag'],
excludePatterns: ['*/generated/*'],
maxNestingDepth: 4,
maxScanDepth: 3,
);
$cache = new LintCache('/tmp/smartylint.cache');
$engine = new LintEngine(cache: $cache, config: $config);
// Lint a single file
$issues = $engine->lintFile('/var/www/app/templates/page.tpl');
// Lint many files (with optional project-wide analysis)
$files = glob('/var/www/app/templates/**/*.tpl');
$issues = $engine->lintFiles($files, findUnused: true);
$engine->saveCache();
foreach ($issues as $issue) {
printf("[%s] %s:%d:%d %s\n",
$issue->severity, $issue->path, $issue->line, $issue->col, $issue->message);
}LintConfig can also be loaded from a JSON file:
$config = LintConfig::fromFile('/project/.smartylintrc.json');Implement the NodeWalker interface to add your own rules. The walker's onNode() is called for every node in the AST in depth-first order.
use SmartyAst\Ast\Node;
use SmartyAst\Ast\TagLike;
use SmartyLint\IssueCollector;
use SmartyLint\Walker\NodeWalker;
final class NoHardcodedUrlWalker implements NodeWalker
{
public function onNode(Node $node, string $path, IssueCollector $issues): void
{
if (!($node instanceof TagLike)) {
return;
}
foreach ($node->resolveTag()->arguments as $arg) {
$val = $arg->value instanceof \SmartyAst\Ast\LiteralExpressionNode
? $arg->value->value : null;
if (is_string($val) && str_starts_with($val, 'http://')) {
$issues->add($path, $node->resolveTag()->span->start->line,
$node->resolveTag()->span->start->column,
'WARNING', "Hard-coded HTTP URL: {$val}");
}
}
}
}Inject the walker by constructing a custom Linter instance with it added to the walker list alongside the built-in walkers. For walkers that need to react after all children are visited (e.g., depth tracking), implement ExitAwareNodeWalker which adds an onExit() callback.
composer testBuild a standalone executable PHAR:
composer build-pharRun it directly:
php dist/smartylint.phar --help301 tests across seven test classes:
| File | Tests |
|---|---|
tests/BinTest.php |
49 |
tests/BinExtendedTest.php |
75 |
tests/BinWalkerTest.php |
43 |
tests/LinterTest.php |
20 |
tests/FindUnusedAnalysisTest.php |
34 |
tests/WalkerUnitTest.php |
77 |
tests/LintEngineTest.php |
3 |
The following Smarty constructs are not yet supported by the underlying SmartyAST parser and will produce diagnostics:
{foreach $arr as $k => $v}— key-value foreach syntax$item@first,$item@last,$item@index— foreach iteration variables$sections.0.title— numeric segment in a property path$smarty.get.*,$smarty.session.*— only$smarty.nowand$smarty.capture.*are fully supported

