Static analysis with PHPStan
Propulsion’s generated Query classes lean on ModelCriteria::__call() for a large part of their surface: filterByTitle(), orderByCreatedAt(), findByIsbn(), joinWithAuthor(), and friends are dispatched at runtime rather than declared as real methods. PHPStan doesn’t trust an arbitrary __call() implementation, so out of the box it reports every one of those — the single most common way you’ll build a query — as “Call to an undefined method”.
Propulsion ships a PHPStan extension that fixes this. Register it in your own phpstan.neon:
services: - class: Propulsion\PHPStan\ModelCriteriaMagicMethodsExtension tags: - phpstan.broker.methodsClassReflectionExtensionThe extension class ships in Propulsion’s normal autoload, so it’s available as soon as you composer require quioteframework/propulsion — you only need PHPStan itself installed:
composer require --dev phpstan/phpstanWhat it recognizes
Section titled “What it recognizes”Only on classes that are (or extend) Propulsion\Query\ModelCriteria:
| Method shape | Return type it reports |
|---|---|
filterBy<Column>() | static |
orderBy<Column>() | static |
groupBy<Column>() | static |
findBy<Column>() | mixed |
findOneBy<Column>() | mixed |
leftJoin(), rightJoin(), innerJoin() | static |
joinWith<Relation>(), leftJoinWith<Relation>(), rightJoinWith<Relation>(), innerJoinWith<Relation>() | static |
Returning static for the chainable ones is what keeps a long fluent chain typed all the way through, rather than degrading to mixed after the first magic call. findBy*()/findOneBy*() report mixed because that’s what the real findBy()/findOneBy() they dispatch to are declared as.
What it deliberately doesn’t check
Section titled “What it deliberately doesn’t check”The extension recognizes the shape of a method name — the same prefixes and suffixes __call() itself switches on — not whether the named column or relation actually exists on the model being queried. $bookQuery->filterByNoSuchColumn('x') passes analysis and fails at runtime.
Checking that would need per-model TableMap/RelationMap introspection at analysis time, which is a materially bigger undertaking than closing the false-positive gap. It’s noted as a follow-up in the extension’s own docblock.
Prefer the closure query forms
Section titled “Prefer the closure query forms”Two things help static analysis independently of the extension:
- Use
with<Relation>Query()/withTypedQuery()rather thanuseQuery()/endUse().endUse()is typed to return the baseModelCriteria, which collapses the type of everything chained after it; the closure forms returnstatic. Propulsion ships a Rector rule that rewrites the old form for you. - Prefer the generated, real methods over the magic ones where both exist — a generated Query class declares
filterByTitle()for real, and only falls back to__call()for shapes the generator didn’t emit.
Generated code at level 9
Section titled “Generated code at level 9”Since 3.0, generated object, query, and node code is a trait your stub class uses rather than a base class it extends — the change that let PHPStan see $this inside generated code as your actual model class instead of a shared base, closing out the last of Propulsion’s generated-code level 9 findings. See Upgrading from 2.x to 3.0 for the shape of the change and the Rector rule (StubBaseClassToGeneratedTraitRector) that migrates existing stubs.