TYPO3 October 30, 2026 10 min read Sascha Egerer

FluidStan 0.3: variables from ViewHelpers, strict mode, baseline

The analysis now detects variables ViewHelpers set through the variable provider, models Fluid variable scoping and narrows types through f:if. It adds an optional strict mode, a baseline and a configuration schema. In the TYPO3 package, TYPO3 is booted fully instead of in failsafe mode.

FluidStan 0.3: variables from ViewHelpers, strict mode, baseline TYPO3 · October 30, 2026

Two weeks ago, FluidStan 0.1 was released, static analysis for Fluid templates built on PHPStan. How the analysis works is described in part one. Version 0.3 has been released since.

In 0.1 every variable that did not come from f:argument, f:for or f:variable was either mixed or undefined. ViewHelpers in Fluid are allowed to set variables, though, and in TYPO3 projects they frequently do. 0.3 detects those assignments.

Variables from ViewHelpers

Fluid ships five ViewHelpers that write variables into a template: f:variable, f:alias, f:cycle, f:for and f:groupedFor. The transpiler models those itself. Any other ViewHelper can do the same through the variable provider. An example from the TYPO3 core:

<f:link.typolink parameter="{link}" partsAs="parts">
    {parts.href} / {parts.target}
</f:link.typolink>
{parts} does not come from the template, it comes from the ViewHelper. Before 0.3 the variable was undefined to FluidStan.

Since 0.3 this is detected without configuration. Before the template analysis, PHPStan runs once over the classes of the ViewHelpers used in the templates and records every add() and addAll() on a variable provider, along with the type of the value.

The variable name is not always constant, it often comes from an argument of the ViewHelper. $provider->add($this->arguments['partsAs'], ...) does not assign a variable called partsAs, it assigns the one whose name the call site passes in partsAs. The analysis traces the name back through casts, local variables and toString()-like helpers to the argument. The assignment is then named after the value of the partsAs argument at each call site, or after that argument’s default when the call omits it. A call whose argument is not a constant string assigns nothing.

If the assignment is not reached on every path, after an early return or inside a condition, the variable may remain unset. In Fluid that corresponds to null, so the type gets |null.

The pass is cached in tmpDir, per set of ViewHelper files by their content and by the FluidStan and PHPStan versions. It only runs again when one of those files or versions changed. Not tracked is a type a ViewHelper takes from another file, such as a repository’s return type. When that changes, touch the ViewHelper file or delete the cache.

Child scope

A ViewHelper can also give a variable to its children only, as f:alias and f:cycle do. Fluid offers two ways to do this, both are modelled. A ScopedVariableProvider around a StandardVariableProvider restores the previous value after the tag. The combination of add(), renderChildren() and remove() deletes the variable for good, so it is null afterwards, even if the component collection provided it beforehand.

This also fixes two older bugs: f:alias and f:cycle were treated as persistent before. f:alias additionally assigned nothing at all, because Fluid hands over the map argument wrapped in a root node, which the check for an array missed. The aliases were unknown variables inside the tag.

Declaring assignments manually

When the name is computed or the ViewHelper delegates to a service, detection cannot follow. The assignment is then declared, either in fluidstan.neon:

parameters:
    viewHelperAssignments:
        # ViewHelper class => variable => PHPDoc type
        My\Site\ViewHelpers\ClassBuilderViewHelper:
            '{target}': string
        My\Site\ViewHelpers\LoadProductViewHelper:
            product: My\Site\Domain\Model\Product
        # children only; scope "removed" when the ViewHelper deletes it afterwards
        My\Site\ViewHelpers\WithProductViewHelper:
            '{as}': { type: My\Site\Domain\Model\Product, scope: children }
{target} names the variable after the call’s target argument, or after that argument’s default.

Or in the class docblock, which for your own ViewHelpers sits closer to the code:

/**
 * @fluidstan-assigns {target} string
 * @fluidstan-assigns-to-children {as} My\Site\Domain\Model\Product
 */
final class ClassBuilderViewHelper extends AbstractViewHelper

Subclasses inherit detected and declared assignments alike. The configuration takes precedence over the tag, the tag over detection. After the call the variable is local: it has the declared type, and a read before the first call is still reported.

Variable scoping

The analysis models Fluid’s scoping. A variable assigned by f:variable keeps its local value on the paths where it was assigned and the collection’s value everywhere else, across conditions and loop iterations. Loop variables shadow a value for the loop only and release it afterwards, like Fluid’s ForViewHelper.

This is covered by an integration test that renders 23 templates in every input variant through real Fluid and checks that the analysis assumes the same values.

Type narrowing through conditions

Inside <f:if condition="{item}"> the value of item is not null, not false, not an empty string and not 0. The same applies to every operand of an && chain, inside f:then and inside f:else if="{other}" for other.

The conditions are not re-implemented. Fluid’s BooleanParser compiles PHP code for every condition, and that code is reused with the operands substituted. Grammar, precedence and comparison semantics therefore match Fluid.

Narrowing does not happen in these cases:

  • the f:else branch and a negated check !{item}: Fluid evaluates the string "false" and an empty Countable as false, PHP does not. FluidStan cannot assume the value is empty there.
  • the operands of ||
  • a condition on a property path: {item.title} says nothing about item itself
  • the loose comparisons == and !=, even against a literal. {label} == 'abc' leaves label a string|null. Fluid applies the PHP operator, and PHPStan deliberately does not narrow on loose comparisons. ===, !==, <, <=, > and >= narrow as they do in PHP.
  • comparisons with a value that can be a Countable or UnsafeHTML: Fluid compares a bool or a string in their place, so an empty ObjectStorage equals 0 there.

The last point was broken under Fluid 4. Every operand counted as a possible string there, because PHPStan was asked whether it is an UnsafeHTML, an interface Fluid 4 does not have. The answer was “maybe”, so comparisons with an object operand never narrowed. Fixed in 0.3.

Variables from component collections

When a component collection provides variables through getAdditionalVariables(), they are known without the method being executed. PHPStan infers the array shape from its return statements, as it does for closure return types. A collection that builds ['component' => ['class' => ..., 'prefix' => ...]] gives {component.class} the type string, and {component.nonexistent} is an error, without a docblock. A @return array shape on the method takes precedence, a @var in the template overrides both.

Variables that are neither arguments nor provided by the collection are still reported as undefined, now with the collection named in the message. A component of a collection counts as a component even without f:argument.

Strict mode

The default checks report what Fluid rejects or what cannot work. strict: true additionally reports what Fluid converts or tolerates at runtime without a notice:

  • fluidstan.argument.cast: a scalar value at a string, int or float argument that Fluid casts with a loss of 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 and a number for string still pass.
  • fluidstan.property.nullable: a path through a value that may be null, such as {item.parent.title} with a nullable parent. Inside <f:if condition="{item.parent}"> the value is set and nothing is reported.
  • if.alwaysTrue, if.alwaysFalse and related identifiers: a condition the types already decide, such as <f:if condition="{item}"> on an object that cannot be null. Reported by PHPStan’s own rules on the generated code. A string stays open, because "false" and "0" evaluate to false, and so does a Countable, which may be empty.
  • fluidstan.comparison.loose: a comparison of scalars of different kinds, {count} == '5' or {flag} == 1. Fluid compares with PHP’s loose operators, so the result depends on type juggling. === and !== are never reported.
  • fluidstan.output.bool: a bool rendered into the output, which prints 1 or nothing.

Without the setting the analysis runs as before, and the command points to strict mode after the results. strict: false ends the note.

Terminal output of vendor/bin/fluidstan with strict: true on the example extension: nine errors in two of three templates, among them an unresolvable property teaserText, a DateTimeImmutable without __toString(), an ObjectStorage passed to a scalar argument, an undefined variable naming the component collection, and an access through a path of type int|null.
FluidStan 0.3 · EXT:shop

The example extension from the TYPO3 package, analysed with strict: true. Line 18 names the component collection that does not provide the variable, line 5 in Badge.html is a strict mode report.

Baseline

For existing projects with many reports, the current state can be recorded as a baseline:

vendor/bin/fluidstan --generate-baseline
vendor/bin/fluidstan --generate-baseline=build/baseline.neon

Every current error is written 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 through includes in fluidstan.neon and only new errors are reported.

ignoreErrors takes what PHPStan takes: a pattern or a map with message and/or identifier, path or paths, count and reportUnmatched.

parameters:
    ignoreErrors:
        - '#^Variable "legacy" is not defined#'
        -
            identifier: fluidstan.output.bool
            paths:
                - Components/Atom/Toggle/*.html
        -
            message: '#^Cannot access "length" on int\.$#'
            path: Components/Molecule/Card/Card.html
            count: 3

The matching, the counting and the report of an entry without a match are PHPStan’s own code and behave accordingly: with a count, that many occurrences are ignored and a differing number is reported as ignore.count; an entry without a match is reported as ignore.unmatched unless its reportUnmatched: false or the global reportUnmatchedIgnoredErrors: false says otherwise.

Schema validation of the configuration

Previously an unknown parameter in fluidstan.neon was ignored, so a typo in a parameter name stayed without effect and unnoticed. Since 0.3 the file is validated against a schema, as PHPStan validates its own. An unknown or mistyped parameter stops the run with Nette’s message, such as “Unexpected item ‘nope’”.

phpstan.level was removed as well, including the -l option. The setting had no effect: FluidStan’s checks implement Fluid’s semantics and run at any level, and the generated code contains none of the constructs the levels add checks for. A configuration that sets phpstan.level is now rejected with a message.

TYPO3 boot

In 0.1, TYPO3 was booted in failsafe mode, which knows the system extensions only. Neither ext_localconf.php nor Configuration/Fluid/Namespaces.php of the project’s extensions were loaded, so globally registered namespaces had to be repeated in fluidstan.neon.

TYPO3 is now booted like a CLI command, still without a database connection. The namespaces come from TYPO3’s ViewHelperResolverFactory: every Namespaces.php (TYPO3 14), TYPO3_CONF_VARS and the ModifyNamespacesEvent, in TYPO3’s order. A project without config/system/settings.php is reported instead of being analysed with incomplete namespaces. A minimal settings.php with SYS/encryptionKey is sufficient.

The package also knows the component collections TYPO3 14 defines declaratively through Configuration/Fluid/ComponentCollections.php, without a PHP class, as well as collection classes registered through dependency injection. Their variables come from the ProvideStaticVariablesToComponentEvent at runtime and stay mixed in the analysis.

Supported are TYPO3 13.4 and 14.2 or newer. TYPO3 14.1 is not supported: it has the declarative collections, but not yet the delegate registry that hands them to the resolver.

Upgrading

composer require --dev fluidstan/typo3:^0.3
In a TYPO3 project the TYPO3 package is enough, the core comes along as a dependency. Without TYPO3: composer require --dev fluidstan/fluidstan:^0.3.

Three points to observe when upgrading:

  1. Remove phpstan.level and every other parameter FluidStan does not know from fluidstan.neon. The error message names the parameter.
  2. Set strict mode: strict: true for the additional checks, strict: false for the previous behaviour without the note.
  3. For a step by step adoption, run vendor/bin/fluidstan --generate-baseline and add includes: [fluidstan-baseline.neon].

fluidstan/typo3 0.2 keeps working with core 0.3, and 0.3 requires it. Core 0.2 declares a conflict with fluidstan/typo3 below 0.2, because the environment interface changed there: getResolverDelegates() was added, and getNamespaces() may return null for an ignored namespace.

Open points

Sections, partials and layouts are still analysed with mixed variables rather than with the types of their call sites. Type inference from assign() and assignMultiple() in PHP code is missing, a result cache as well. Open on the TYPO3 side: 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, and Extbase relations unwrapped from ObjectStorage and lazy-loading proxies.

Thanks to Simon Praetorius (@s2b) for the feedback and the ideas that went into it.

Code, releases and the example extension: fluidstan/fluidstan and fluidstan/typo3. If the analysis judges a case wrongly, please report it as an issue on GitHub.