I maintain phpstan-typo3, the PHPStan extension for TYPO3. Static analysis checks code without executing it and finds errors that would otherwise surface in production. For the PHP part of a TYPO3 project this has been standard for years. For Fluid templates there was no equivalent, even though the same class of errors lives there.
At Flowd we build frontends out of components, first with sitegeist/fluid-components, since Fluid 4.3 and TYPO3 13.4 with the native Fluid components. A component declares its arguments through f:argument, including their type. Those declarations used to be evaluated at runtime only. FluidStan evaluates them statically and checks every call site against them.
What happens at runtime
When a lookup in Fluid fails, the outcome depends on the case.
{product.teaserText} on a model whose getter is getTeaser() yields an empty string. The page renders, the teaser is missing, and there is neither an exception nor a log entry. An object without __toString() in the output throws an exception instead, but only on the pages where the variable is set. A f:for over a string aborts. An argument with the wrong type behaves differently per Fluid version: Fluid 4 converts silently, Fluid 5 rejects it, so the error only appears during the upgrade.
None of these cases are visible to PHPStan, because templates are not PHP.
What FluidStan reports
FluidStan parses templates without rendering them and knows the PHP types behind the variables. It reports:
- property paths that cannot be resolved, in Fluid’s lookup order: array key,
ArrayAccess, PSR container, getter, isser, hasser, public property - values that cannot be rendered, such as an object without
__toString()or an array f:forover non-iterable values- ViewHelper and component arguments whose type does not satisfy the argument definition, following the rules of Fluid’s
StrictArgumentProcessor - non-numeric operands in
{a + b} - undefined variables in components, whose scope is closed
- unknown ViewHelpers, unknown or missing arguments, unknown component slots,
f:renderof non-existent sections, deprecated ViewHelpers and syntax errors
The output follows PHPStan’s format, with template path and template line:
The example extension shipped with the TYPO3 package, analysed with vendor/bin/fluidstan.
Every error carries an identifier (fluidstan.property.unresolvable, fluidstan.argument.type) and can be ignored individually, as in PHPStan.
Typed components
In a template without type declarations every variable is mixed. FluidStan still reports syntax errors, unknown ViewHelpers and missing arguments there, but it checks no property paths.
Components declare their arguments:
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<f:argument name="amount" type="float" />
<f:argument name="currency" type="string" optional="{true}" default="€" />
<span class="a-price"><f:format.currency currencySign="{currency}" decimals="2">{amount}</f:format.currency></span>
</html>
Every use of shop:atom.price is checked against this declaration.
Where a PHP class backs an argument, the property accesses on it are checked as well:
<f:argument name="product" type="Vendor\Shop\Domain\Model\Product" />
<time>{product.releasedAt}</time>
<shop:atom.price amount="{product.price}" />
<shop:atom.price amount="{product.image}" />
Line 2 outputs a DateTimeImmutable without formatting. Line 4 passes a FileReference|null to an argument of type float. Both are reported.
The value of the analysis therefore scales with the degree of typing: the more completely the arguments are declared, the more is checked.
Templates, partials and layouts without a component definition, and variables assigned by a controller, can be typed through an annotation:
<f:comment>
@var Vendor\Shop\Domain\Model\Product product
@var list<Vendor\Shop\Domain\Model\Product> products
@var array<string, mixed> data
</f:comment>
PHPDoc types are allowed, so generics and unions work.
FluidStan in TYPO3
The core package only knows Fluid. Namespaces, extension paths and the EXT: notation come from TYPO3, which is what fluidstan/typo3 provides:
- Boot in failsafe mode, without a database, so the package manager, extension paths and configuration are available while parsing.
- Registration of the Fluid namespaces:
fincludesTYPO3\CMS\Fluid\ViewHelpers, plus everything in$GLOBALS['TYPO3_CONF_VARS']['SYS']['fluid']['namespaces']. Globally registered ViewHelper namespaces do not have to be listed again. - Resolution of
EXT:paths in the configuration.
Configuration lives in one file in the project root:
parameters:
environment: FluidStan\TYPO3\Typo3Environment
bootstrapFiles:
- vendor/autoload.php
paths:
- EXT:my_site/Resources/Private/Components
- EXT:my_site/Resources/Private/Templates
namespaces:
my: My\Site\Components\ComponentCollection
tmpDir: var/cache/fluidstan
phpstan:
level: 8
fluidstan.neon. namespaces is only needed for component collections and namespaces not registered globally in TYPO3.
vendor/bin/fluidstan
vendor/bin/fluidstan --error-format=json
The boot reads config/system/settings.php like any TYPO3 CLI command, so the run belongs in the PHP container. No database connection is opened.
How the analysis works
Parsing is done by Fluid’s own TemplateParser, configured with the real ViewHelper namespaces so the argument definitions are the real ones. Fluid’s syntax tree carries no source positions, so a parser subclass records one per node. Parse errors are collected instead of aborting the run.
Transpiling turns every template into a PHP function. Component arguments become typed parameters, f:for becomes foreach, f:variable an assignment, f:if an if. Dynamic accesses become calls on a runtime facade:
<f:argument name="product" type="Vendor\Shop\Domain\Model\Product" />
<h2>{product.title}</h2>
function (Product $product): void {
F::output(F::get($product, 'title'));
}
Simplified. At this point the type information is a PHP signature.
Analysis of that generated code is PHPStan’s job, with the FluidStan extension: return type extensions resolve the facade calls, custom rules model Fluid’s runtime behaviour, such as the lookup order and the argument validation.
Mapping back then returns each error to its template file and template line.
Fluid’s own compiled template cache is not used. It resolves every variable through a single variable provider and thereby loses the scope information PHPStan depends on.
Installation and status
composer require --dev fluidstan/fluidstan
composer require --dev fluidstan/typo3
The second package pulls the first one in as a dependency.
Requirements are PHP 8.2 or newer, Fluid 4.4 or 5.x and PHPStan 2; the TYPO3 package supports TYPO3 13.4 and 14 in Composer mode. Both packages are MIT-licensed.
FluidStan is at an early stage. The pipeline is complete, the rule set is still small, and sections are currently analysed with mixed variables. Planned: TYPO3’s own variables ({settings}, {data}, {page}, {site}) typed out of the box, Content Blocks field definitions as the type of {data}, partials and layouts resolved through site sets and TypoScript, Extbase relations unwrapped from ObjectStorage and lazy-loading proxies, plus a baseline file and a result cache.
Argument checks always follow Fluid 5’s strict validation. Under Fluid 4, FluidStan therefore also reports what an upgrade would complain about later.
Code, issues and the examples from this article: fluidstan/fluidstan and fluidstan/typo3.