fluidstan / fluidstan
Static analysis for TYPO3 Fluid templates and components, powered by PHPStan
Requires
- php: ^8.2
- composer-runtime-api: ^2.2
- ext-ctype: *
- nette/di: ^3.2
- nette/neon: ^3.4
- nette/schema: ^1.3
- nikic/php-parser: ^5.0
- phpstan/phpstan: ^2.1.30
- psr/container: ^1.0 || ^2.0
- symfony/console: ^7.0
- symfony/process: ^7.0
- typo3fluid/fluid: ^4.4 || ^5.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.54
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.32 || ^0.35
- php-parallel-lint/php-parallel-lint: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.1
- phpstan/phpstan-strict-rules: ^2.1
- phpunit/phpunit: ^11.5
- rector/rector: ^2.7
- shipmonk/composer-dependency-analyser: ^1.8
Suggests
None
Provides
None
Conflicts
- fluidstan/typo3: <0.2
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 21:52:23 UTC
README
Static analysis for TYPO3 Fluid templates and components, powered by PHPStan. Developed and maintained by Flowd GmbH.
FluidStan knows the PHP types behind your template variables and reports what Fluid would only reveal at
runtime. The screenshot shows the analysis of docs/example, a product card component
with a typo in a property path, a wrong argument type, an object rendered without __toString(), a loop
over a string, an undefined variable and an unknown slot. Run it yourself with composer fluidstan:example.
It reports:
- property paths that cannot be resolved (
{item.nonexistent}), following Fluid's lookup order: array key,ArrayAccess, PSR container, getter, isser, hasser, public property - values that cannot be rendered (
{item.money}whereMoneyhas no__toString(), arrays) f:forover something that is not iterable- ViewHelper and component arguments whose type does not satisfy the argument definition, with the
same rules Fluid's
StrictArgumentProcessorapplies at runtime. Note that Fluid casts scalars before validating: a string passed to anintargument, or a float to astring, is accepted at runtime and therefore not reported; objects, arrays andnullfor required arguments are. SeeStrictArgumentProcessor::process()and its use inViewHelperInvoker. - non-numeric operands in
{a + b} - undefined variables in components (their scope is closed)
- all of that with the knowledge that a value checked by
f:ifis not null, false or empty inside the condition (see "Conditions" below for what narrows and what does not) - unknown ViewHelpers, unknown or missing arguments, syntax errors (Fluid's parser), unknown component
slots,
f:renderof undefined sections, deprecated ViewHelper usage
Fluid components (Fluid 4.3+, TYPO3 13.4+) declare their argument types with f:argument, so they are
analysed out of the box. Plain templates have mixed variables unless you declare them (see below).
Requires PHP 8.2+, Fluid 4.4 or 5.x, PHPStan 2. Argument checks always follow Fluid 5's strict validation; on Fluid 4, whose runtime is lenient, they report what would fail after an upgrade.
Status: early. The pipeline is complete and the design is settled; the rule set grows release by release, see "How it works" and "Roadmap".
Roadmap
- Layouts, partials and sections analysed with the types of their call sites (sections are currently
analysed with
mixedvariables) - Type inference from
assign()/assignMultiple()calls in PHP code - Result cache, more output formats
- Upstream proposal: positions on Fluid syntax tree nodes
Installation
composer require --dev fluidstan/fluidstan
# TYPO3 projects:
composer require --dev fluidstan/typo3
Usage
vendor/bin/fluidstan # uses ./fluidstan.neon vendor/bin/fluidstan path/to/Components # explicit paths vendor/bin/fluidstan --error-format=json vendor/bin/fluidstan --generate-baseline # writes the current errors to fluidstan-baseline.neon vendor/bin/fluidstan --debug # prints the directory of the generated PHP code
fluidstan.neon:
includes: - fluidstan-baseline.neon # optional, see "Baseline and ignored errors" parameters: paths: - packages/my_site/Resources/Private/Components - packages/my_site/Resources/Private/Templates suffixes: [.html] namespaces: # identifier => PHP namespaces or component collection classes; "f" defaults to TYPO3Fluid\Fluid\ViewHelpers my: My\Site\Components\ComponentCollection bootstrapFiles: - vendor/autoload.php tmpDir: var/cache/fluidstan strict: true # also report what Fluid tolerates silently, see "Strict mode" ignoreErrors: [] # as in PHPStan, see "Baseline and ignored errors" phpstan: includes: [] # extra PHPStan config files for the generated code parameters: {} # extra PHPStan parameters
Argument values are checked the way Fluid validates them: a scalar passed to a string, int,
float or bool argument is cast to that type first, so {item.title} passes as an int
argument and becomes 0 at runtime. Union types such as string|int are never cast and are
checked member by member, as Fluid does. Strict mode reports the casts.
PHPStan's rule level is not configurable: FluidStan's checks implement Fluid's semantics and run
regardless of the level, and the generated code contains none of the constructs the levels add checks for.
The file is read by Nette's configuration loader, extended the way PHPStan extends it, and validated
against a schema, so includes work as in PHPStan and an unknown or mistyped parameter is an error.
TYPO3 projects use the adapter, which boots TYPO3 without a database, registers the namespaces from
$GLOBALS['TYPO3_CONF_VARS']['SYS']['fluid']['namespaces'] and resolves EXT: paths:
parameters: environment: FluidStan\TYPO3\Typo3Environment bootstrapFiles: - vendor/autoload.php paths: - EXT:my_site/Resources/Private/Components
Declaring variable types
Components: <f:argument name="item" type="My\Model\Item" />. Fluid's type grammar applies:
scalars, mixed, object, iterable, class names, Item[], unions.
Plain templates (and overrides in components): a @var annotation in a comment, PHPDoc types allowed.
<f:comment> @var My\Model\Item item @var list<My\Model\Item> items </f:comment>
Variables a component collection provides through getAdditionalVariables() are known as well, without
running the method: PHPStan infers the array shape from its return statements, the way it infers closure
return types, so a collection that builds ['component' => ['class' => ..., 'prefix' => ...]] gives
{component.class} the type string. A @return array shape in the method's PHPDoc takes precedence,
and @var in the template overrides both.
Variables assigned by ViewHelpers
f:variable, f:alias, f:cycle, f:for and f:groupedFor are the ViewHelpers Fluid ships that put
variables into a template, and FluidStan knows them. A ViewHelper of your own or of an extension may do
the same through the variable provider. FluidStan finds that out by itself: before the templates are
analysed, PHPStan runs once over the classes of the ViewHelpers they use and records every add() and
addAll() on a variable provider, with the type of the value. A constant name is taken as is; a name
that comes from an argument of the ViewHelper, through casts, local variables or a toString()-like
helper, becomes that argument, so $provider->add($this->arguments['target'], ...) assigns the
variable the call's target argument names, or the argument's default. An assignment that is not
reached on every path, after an early return or inside a condition, may leave the variable unassigned,
which in Fluid means null, so its type gets |null. The pass is cached in tmpDir per set of
ViewHelper files, by their content and the FluidStan and PHPStan versions, and only runs again when one
of them changed. A type a ViewHelper takes from another file, such as a repository's return type, is not
tracked: after such a change, touch the ViewHelper or delete the cache.
A ViewHelper may also give a variable to its children only, the way f:alias and f:cycle do. Both
forms Fluid offers are detected and modelled as Fluid behaves: a ScopedVariableProvider around a
StandardVariableProvider restores the previous value after the tag, while add(), renderChildren()
and remove() deletes the variable for good, so it is null afterwards even if the collection provided it.
Where detection cannot follow the code, the name is computed or the ViewHelper delegates to a service,
or where it is more cautious than you know to be true, declare the assignment in fluidstan.neon:
parameters: viewHelperAssignments: # ViewHelper class => variable => PHPDoc type; {target} names the variable after the "target" argument My\Site\ViewHelpers\ClassBuilderViewHelper: '{target}': string My\Site\ViewHelpers\LoadProductViewHelper: product: My\Site\Domain\Model\Product # a variable the children see only; scope "removed" when the ViewHelper deletes it afterwards My\Site\ViewHelpers\WithProductViewHelper: '{as}': { type: My\Site\Domain\Model\Product, scope: children }
For your own ViewHelpers the same can live in the class docblock, where it is kept next to the code:
/** * @fluidstan-assigns {target} string * @fluidstan-assigns-to-children {as} My\Site\Domain\Model\Product */ final class ClassBuilderViewHelper extends AbstractViewHelper
{target} resolves to the call's target argument, or to the argument's default when the call omits
it; a call whose argument is not a constant string assigns nothing. Subclasses inherit detected and
declared assignments alike. The configuration wins over a tag, and a tag wins over detection. After the
call the variable is local, with everything that follows from that: its type is the declared one, and a
read before the first call is still reported.
Strict mode
Fluid tolerates a lot at runtime without a word. The default checks report what Fluid rejects or what
cannot work; strict: true adds what Fluid does silently and what is probably not meant. Until the
setting is in the configuration the analysis runs without it and says so after the results; strict: false keeps it off and ends the note.
| Identifier | Reported |
|---|---|
fluidstan.argument.cast |
A scalar passed to a string, int or float argument that Fluid casts, losing information: a string to int becomes 0, a bool to string becomes 1 or an empty string. An integer or an integer-like numeric string for int, a number for string, still passes. |
fluidstan.property.nullable |
A path through a value that may be null, such as {item.parent.title} with a nullable parent: Fluid yields null. Inside <f:if condition="{item.parent}"> the value is known to be set and nothing is reported. |
if.alwaysTrue, if.alwaysFalse, booleanAnd.*, booleanOr.*, booleanNot.*, ternary.* |
A condition the types decide, such as <f:if condition="{item}"> on an object that cannot be null or a bare 'false'; reported by PHPStan's own rules on the generated code. A string stays open because "false" and "0" are false, and so does a Countable, which may be empty. |
fluidstan.comparison.loose |
A comparison of scalars of different kinds, such as {count} == '5' or {flag} == 1: Fluid compares with PHP's loose operators, so the outcome depends on type juggling. === and !== are never reported; a Countable compares as a bool. |
fluidstan.output.bool |
A bool rendered into the output, which prints 1 or nothing. |
Baseline and ignored errors
vendor/bin/fluidstan --generate-baseline # fluidstan-baseline.neon next to the configuration
vendor/bin/fluidstan --generate-baseline=build/baseline.neon
The baseline records every current error as an ignoreErrors entry, in PHPStan's format with the
message as a pattern, the identifier, the count and the template path relative to the baseline file.
Include it from fluidstan.neon and only new errors are reported; regenerate it to take the current
state as the new reference, the entries of the file being written do not hide anything while it is
generated.
ignoreErrors takes what PHPStan takes, a pattern or a map with message and/or identifier, path
or paths relative to the file that defines the entry, count and reportUnmatched:
parameters: ignoreErrors: # a pattern, anywhere - '#^Variable "legacy" is not defined#' # an identifier in some templates - identifier: fluidstan.output.bool paths: - Components/Atom/Toggle/*.html # exactly this many occurrences in one template; a fourth one, or only two, is reported - message: '#^Cannot access "length" on int\.$#' path: Components/Molecule/Card/Card.html count: 3 # an entry that may match nothing without being reported - identifier: fluidstan.property.nullable path: Templates/Legacy.html reportUnmatched: false # or for every entry at once reportUnmatchedIgnoredErrors: false
The matching, the counting and the report of an entry that no longer matches anything are PHPStan's
own code, so they behave as in PHPStan: with a count, that many occurrences are ignored and a
differing number is reported as ignore.count; an entry that matches nothing is reported as
ignore.unmatched unless its reportUnmatched: false or the global reportUnmatchedIgnoredErrors: false says otherwise; and nothing is reported about unmatched entries when single files are analysed
rather than directories.
Conditions
Inside <f:if condition="{item}"> the type of item loses null, false, empty strings and
zero, so passing it to an argument typed Item is fine there. The same holds for every operand of
an && chain, inside f:then and inside f:else if="{other}" for other.
What does not narrow, on purpose:
- the
f:elsebranch and a negated check!{item}: Fluid treats the string"false"and an emptyCountableas false while PHP treats them as true, so FluidStan cannot claim the value is empty there - the operands of
|| - a condition on a property path,
{item.title}, says nothing aboutitemitself - the loose comparisons
==and!=, even against a literal:{label} == 'abc'leaveslabelasstring|null. Fluid applies the PHP operator, and PHPStan is deliberately cautious with loose comparisons.===,!==,<,<=,>and>=narrow the way they do in PHP - comparisons involving a value that may be a
CountableorUnsafeHTML: Fluid compares a bool or a string in their place, so an emptyObjectStoragedoes equal0there, and PHPStan's reasoning about the object would be wrong
Conditions are not re-implemented: the PHP code Fluid's own BooleanParser compiles for them is
reused with the operands substituted, so grammar, precedence and comparison semantics are Fluid's.
How it works
- Templates are parsed with Fluid's own
TemplateParser, configured with the real ViewHelper namespaces so argument definitions are real. Fluid's nodes carry no positions, so a parser subclass records one per node. Parser errors are collected instead of aborting. - Each template is transpiled to a PHP function: component arguments become parameters,
f:forbecomesforeach,f:variablean assignment,f:ifanif. Everything dynamic becomes a call onFluidStan\Runtime\F, e.g.{item.title}→F::get($item, 'title'). - PHPStan analyses the generated code with the FluidStan extension (
extension.neon): return type extensions resolve the facade calls, rules implement Fluid's runtime semantics. - Errors are mapped back to template lines and printed in PHPStan's style.
Fluid's compiled template cache is not used: it resolves every variable through one opaque variable provider, which erases the scope information PHPStan needs.
What comes from Fluid, what is mirrored
FluidStan reuses Fluid wherever Fluid exposes the behaviour: the parser and syntax tree, namespace
detection, ViewHelper resolution and argument definitions, the parser's own argument validation
(unknown and missing arguments), component definitions and slots, the component list of a collection
(Fluid 5), section registration,
template discovery (TemplateFinder, Fluid 5), deprecation reporting (as TemplateValidator does) and
the unwrapping of wrapped parse exceptions.
Some runtime behaviour has no static counterpart in Fluid and is mirrored in the PHPStan extension or the transpiler. When Fluid changes one of these, FluidStan must follow:
| Behaviour | Source of truth in Fluid |
|---|---|
| Property path lookup order | StandardVariableProvider::getByPath() |
| Argument type validation | StrictArgumentProcessor (process() then isValid()) |
| Casting of output and of multiple children | AbstractNode::evaluateChildNodes() / castToString() |
| Ternary, math and casting expression syntax | TernaryExpressionNode, MathExpressionNode, CastingExpressionNode |
| Fragment to slot mapping | ComponentAdapter (private helpers) |
| Argument parsing with non-fatal validation | TemplateParser::parseArguments() and abortIfRequiredArgumentsAreMissing() |
| Iteration variable keys | ForViewHelper |
| Truthiness of conditions | BooleanNode::convertToBoolean() |
| Operand conversion in conditions | BooleanParser::convertNodeToBoolean() |
| Condition grammar, precedence and comparisons | BooleanParser::compile(), whose PHP code is reused with the operands substituted |
| Component names from template files (Fluid 4 only) | AbstractComponentCollection::getAvailableComponents() (Fluid 5) |
Candidates for upstream contributions that would remove mirrored code: source positions on syntax
tree nodes, a public fragment extraction on ComponentAdapter, and a reusable type grammar helper
for argument definitions.
Changes
See CHANGELOG.md.
Development
composer install composer check # everything below composer lint # syntax, composer.json, php-cs-fixer, PHPStan (max, strict), Rector, dependency analysis composer fix # apply Rector, php-cs-fixer and composer normalize composer test # PHPUnit composer test:coverage # PHPUnit with coverage (Xdebug or pcov) composer test:mutation # Infection, full run, minimum MSI 70 % (CI runs it on main and weekly; pull requests only mutate changed lines) composer fluidstan # analyse the fixture templates
The runtime facade src/Runtime/F.php is excluded from Rector: its stubs' signatures are the contract with
the generated code. Fluid's parser and syntax tree are @internal; PHPStan's internal-usage checks are
disabled for the TYPO3Fluid namespace only. CI runs the suite on PHP 8.2 to 8.5 against Fluid 4 and 5,
with lowest and highest dependencies.