PHPStan extension for Respect/Fluent builders.
Provides method resolution, parameter validation, tuple-typed getNodes(), and
type narrowing through assertion methods — without generated code.
Fluent builders use __call to resolve method names to class instances. Since
those methods don't exist as real declarations, PHPStan reports errors and can't
validate arguments. This extension teaches PHPStan what each method does: its
parameters, return type, and the exact tuple of accumulated nodes.
$stack = Middleware::cors('*')->rateLimit(100);
// PHPStan knows:
// cors() accepts (string $origin = '*')
// rateLimit() accepts (int $maxRequests = 60)
// getNodes() returns array{Cors, RateLimit}
// $stack is Middleware<array{Cors, RateLimit}>
$stack->getNodes()[0]; // PHPStan infers: Cors
$stack->typo(); // PHPStan error: method not found
$stack->cors(42); // PHPStan error: int given, string expectedcomposer require --dev respect/fluent-analysisRequires PHP 8.5+ and PHPStan 2.1+.
vendor/bin/fluent-analysis generateThis scans your project for builder classes with #[FluentNamespace], reads the
factory configuration from the attribute, and writes a fluent.neon file mapping
method names to target classes.
includes:
- vendor/respect/fluent-analysis/extension.neon
- fluent.neonThe extension loads automatically via Composer's PHPStan plugin mechanism.
The fluent.neon file provides the method map for your specific builders.
Run vendor/bin/fluent-analysis generate again after adding, removing, or
renaming classes in your fluent namespaces. The command detects unchanged output
and skips the write if nothing changed.
# Custom output path
vendor/bin/fluent-analysis generate -o phpstan/fluent.neonEvery method on your builder is resolved to its target class. PHPStan reports unknown methods as errors: typos are caught at analysis time.
Method parameters come from the target class constructor. If Cors has
__construct(string $origin = '*'), then ->cors() accepts the same
signature. Type mismatches are reported.
The extension tracks which node types are accumulated through the chain.
getNodes() returns a precise tuple instead of array<int, object>:
$builder = new MiddlewareStack();
$chain = $builder->cors('*')->rateLimit(100)->auth('bearer');
// PHPStan knows: array{Cors, RateLimit, Auth}
$nodes = $chain->getNodes();
// Individual elements are typed
$nodes[0]; // Cors
$nodes[1]; // RateLimit
$nodes[2]; // AuthTuple tracking works through variable assignments and static calls:
$a = MiddlewareStack::cors('*');
$b = $a->rateLimit(100);
$b->getNodes(); // array{Cors, RateLimit}If a target class is marked @deprecated, the fluent method inherits the
deprecation. PHPStan reports it wherever the method is called.
For builders using Respect/Fluent's composable prefixes (like Validation's
notEmail(), nullOrStringType()), the extension resolves composed methods
with correct parameter signatures.
Builders can narrow the type of a variable through assertion methods. Node
classes declare their assurance via the #[Assurance] attribute, assertion
methods are marked with #[AssuranceAssertion], and #[AssuranceParameter]
identifies the validated parameter and constructor parameters used for type
resolution.
Void assertion methods narrow unconditionally:
$builder->intNode()->doAssert($x);
// PHPStan now knows $x is intBool assertion methods work as type guards:
if ($builder->intNode()->isOk($x)) {
// $x is int here
}
// $x is not int hereChained nodes intersect their assurances:
$builder->intNode()->numericNode()->doAssert($x);
// int ∩ (int|float|numeric-string) = intThe extension supports several assurance modes through the #[Assurance]
attribute:
type— a fixed type string (e.g.int,float|int|numeric-string)#[AssuranceParameter]— the type is taken from a constructor parameter annotated with the attribute (e.g. a class-string parameter)from: value— narrows to the argument's literal typefrom: member— narrows to the iterable value type of the argumentfrom: elements— narrows to an array of the inner assurance typecompose: union|intersect— combines assurances from multiple builder arguments
The extension registers three PHPStan hooks:
-
FluentMethodsExtension(MethodsClassReflectionExtension) — tells PHPStan which methods exist on each builder, with parameters extracted from the target class constructor. -
FluentDynamicReturnTypeExtension(DynamicMethodReturnTypeExtension+DynamicStaticMethodReturnTypeExtension) — intercepts each method call to track accumulated node types as aGenericObjectTypewrapping aConstantArrayTypetuple. WhengetNodes()is called, the tuple is returned directly. Also accumulates assurance types through the chain. -
FluentTypeSpecifyingExtension(MethodTypeSpecifyingExtension) — enables type narrowing in control flow. When a builder's assertion method is called, accumulated assurances are applied to narrow the input variable's type. Supports void assertions (unconditional) and bool guards (conditional).
The extensions share a MethodMap for method resolution and an AssuranceMap
for type narrowing configuration, both with parent-class fallback for builder
inheritance.
The generate command reads the #[FluentNamespace] attribute from each
builder, extracts the factory's resolver and namespaces, discovers classes,
and uses FluentResolver::unresolve() to derive method names from class names.
Another similar project is FluentGen.
Both are complementary, offering IDE support and type inference as separate packages.
| FluentAnalysis | FluentGen | |
|---|---|---|
| Generated files | None (one small neon cache) | Interface files per builder + prefix |
| Return type | Builder<array{A, B, C}> |
Builder (via @mixin) |
getNodes() type |
array{A, B, C} (exact tuple) |
array<int, Node> (generic) |
| Element access | $nodes[0] typed as A |
mixed |
| Deprecation | Forwarded automatically | Must regenerate |
| Composable prefixes | Resolved from cache | Full method signatures |
| Type narrowing | Assertion methods narrow input types | Not supported |
| IDE support | PHPStan-powered (PhpStorm, VS Code) | Direct IDE autocomplete |
| Maintenance | Re-run generate on class changes |
Manual/generated |