Skip to content

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:

phpstan.neon
services:
-
class: Propulsion\PHPStan\ModelCriteriaMagicMethodsExtension
tags:
- phpstan.broker.methodsClassReflectionExtension

The 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:

Terminal window
composer require --dev phpstan/phpstan

Only on classes that are (or extend) Propulsion\Query\ModelCriteria:

Method shapeReturn 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.

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.

Two things help static analysis independently of the extension:

  • Use with<Relation>Query() / withTypedQuery() rather than useQuery()/endUse(). endUse() is typed to return the base ModelCriteria, which collapses the type of everything chained after it; the closure forms return static. 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.

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.