hampel / xenforo-phpstan
PHPStan for XenForo add-ons - the configuration an add-on needs to be analysed against a forum, the methods class extensions add to core classes, the container shortcuts, and the legacy class names XenForo 2.3 aliases at runtime
Package info
github.com/hampel/xenforo-phpstan
Type:phpstan-extension
pkg:composer/hampel/xenforo-phpstan
Requires
- php: >=8.3
- ext-simplexml: *
- phpstan/phpstan: ^2.1.22
Requires (Dev)
- laravel/pint: ^1.30
- phpunit/phpunit: ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
By Simon Hampel
A PHPStan extension for add-ons developed inside a XenForo 2 install. It
carries what every add-on would otherwise have to work out alone: the configuration that lets
PHPStan see the forum without analysing it, the legacy class names XenForo 2.3 aliases at runtime,
the XFCP_ parent of every class extension, the methods those extensions add to core classes, and
the container shortcuts given the short Vendor\AddOn:Name identifier.
Without it, PHPStan reports a working add-on as broken. On a real add-on the count at level 2 went from 262 errors to 12 on configuration alone, and from 12 to 2 once the extensions were in place; across seven add-ons the residual fell by between a quarter and six sevenths. What is left is real.
Installation
In the add-on's directory:
composer require --dev hampel/xenforo-phpstan vendor/bin/xenforo-phpstan generate
generate reads the forum the add-on sits in and writes three files into
<forum>/internal_data/phpstan/: a stub declaring every legacy class name the forum aliases, a
stub declaring the XFCP_ parent of every class extension the install has, and the map the
extensions read. Run it again after upgrading XenForo or changing a class extension — a stale
set fails loudly, naming the two versions and this command.
The forum is found four directories above the working directory, which is where it is from an
add-on at src/addons/<Vendor>/<AddOn>. From anywhere else, name it:
vendor/bin/xenforo-phpstan generate --root=/path/to/forum
Configuration
The add-on's own phpstan.neon includes the fragment and supplies what is the add-on's: its paths,
its level, and the directories of its own that are not source.
includes: - vendor/hampel/xenforo-phpstan/extension.neon parameters: level: 2 paths: - . excludePaths: # Scanned through the Composer autoloader already; scanning the directory as well lets a # dependency's scaffold shadow the add-on's own classes of the same name. analyseAndScan: - vendor (?) # Generated by XenForo, and not the add-on's code. analyse: - _output (?) - _releases (?) - _data (?)
The (?) marks a path optional. PHPStan refuses to start when an excluded path does not exist, and
an add-on that extends nothing has no _output/, one that has never been built has no
_releases/.
If the add-on uses phpstan/extension-installer,
the includes line is not needed.
The forum root
extension.neon takes the forum to be four directories above where PHPStan runs. To analyse an
add-on from somewhere else — a checkout outside the install, a CI job with a forum unpacked
beside it — set the root, and if the generated files are elsewhere, that too:
parameters: xenforo: root: /path/to/forum generated: /path/to/forum/internal_data/phpstan
Magic properties
Entity columns, options and route parameters are magic properties, and PHPStan reports each
access as undefined. crates.neon opts the three classes into PHPStan's object-crate treatment:
includes: - vendor/hampel/xenforo-phpstan/extension.neon - vendor/hampel/xenforo-phpstan/crates.neon
It is a separate file because the cost is real: a crate accepts any property name, so a
mistyped column is silenced as readily as a correct one. The precise alternative for entities is
php cmd.php xf-dev:entity-class-properties, which writes @property docblocks into the add-on's
own entity classes — an edit to committed source, so a decision for each add-on rather than a
default.
What it does
Scans the forum, analyses the add-on
PHPStan is told where XenForo's classes are without being asked to report on them:
src/XF, src/vendor and src/addons are scanned for symbols, src/XF.php and src/utf8.php
are read as files because XenForo require()s them rather than autoloading them, and every other
add-on's vendor/ is left out of the scan. The last matters more than it looks: PHPStan consults
a scanned directory before the Composer autoloader, so without it an add-on resolves a shared
dependency from whichever sibling vendored an older release of it.
Legacy class names
XenForo 2.3 renamed its classes with type suffixes — Tools became ToolsController,
SessionActivity became SessionActivityRepository — and registered an autoloader that
class_alias()es each old name to the new one. PHPStan cannot follow a runtime alias, so an add-on
that extends or type-hints a legacy name sees every inherited member as undefined.
The generated stub declares each legacy name as a subclass of the renamed class. The mapping comes
from the forum itself, by asking \XF::getAliasForClass() for every class file under src/XF and
src/addons, so it covers all ten suffixes XenForo's table abbreviates and never invents a name
core does not alias. On 2.3.13 that is 864 declarations for core alone; 43 further aliases strip to
Abstract, a reserved word, and are skipped, because no source could have referenced them either.
An add-on supporting XenForo 2.2 cannot avoid this by renaming its extensions: the suffixed classes do not exist in 2.2, and 2.2 has no alias machinery, so the legacy name is the only one valid across the range.
Methods added by class extensions
A class extension adds methods to a core class at runtime — Vendor\AddOn\XF\Entity\User adds
canUseThing(), and every $user in the forum can call it. Statically every call site is typed to
XF\Entity\User, so PHPStan reports each such method as undefined. On a fleet of 44 add-ons this
was 396 of the 757 errors left once the configuration was right.
The reflection extension answers for those methods from the extension classes recorded at
generation time: for a call on a core class, or anything below it, it offers a method any
extension of that class declares. The map covers every add-on installed in the forum, not only
the one being analysed, because the methods a third-party add-on's extensions add are reachable
from any other add-on — and a packaged add-on ships without the _output/extension_hint.php that
would declare its XFCP_ parents, so those are generated too, from each add-on's committed
_data/class_extensions.xml.
Container shortcuts
$this->repository('Vendor\AddOn:Name'), \XF::service(), $this->em()->find('XF:User', $id)
and the rest are typed as @template T with a class-string<T> argument in 2.3, which the short
identifier defeats: the call returns the base type, and everything called on it is undefined. The
return type extensions expand the identifier as \XF::stringToClass() does, for every shortcut on
XF, XF\App, XF\Mvc\Controller, XF\Mvc\Entity\Manager, Repository, Entity,
XF\Service\AbstractService and XF\Widget\AbstractWidget — the finders, repositories,
services, entities, data classes, controller plugins, behaviours, jobs, searchers, filterers,
helpers, validators, criteria and notifiers, and a controller's assertRecordExists() family —
and the argument itself is no longer reported against the class-string annotation at level 5,
or as an unresolved template type at level 6.
Within the add-on that extends the class, the shortcut returns that extension rather than the
core class: at runtime it is what comes back, and it is the only type under which a method the
extension overrides with a different signature is called correctly. Elsewhere the core class is
returned, with every extension's added methods reachable through it. Which add-on a file belongs
to is its directory under src/addons, so the add-on's tests count as its own.
Passing ::class is typed by XenForo's own annotation without any of this, and within the add-on
that extends the class it still returns the extension. It is the better form for the add-on's own
classes. It is not safe for a renamed core class while the add-on supports 2.2:
\XF\Repository\UserRepository::class is a string whatever is installed, and on 2.2 the class it
names does not exist.
Undeclared return types
XenForo declares no return type on many of the methods every add-on calls: the controller's
view(), redirect(), error(), message() and exception(), the reply classes' parameter
getters, the finder's fluent methods, Router::buildLink(), \XF::phrase(), the database
adapter's quote(), fetchAllColumn() and getQueryCount(), Entity::save(),
AbstractCollection::keys(), AbstractDdl::addColumn(), Request::filter(),
App::findByContentType(), NodeRepository::getFullNodeList() and filterViewable(),
Node::getBreadcrumbs() and getNodeListExtras().
Below level 7 that costs nothing; at level 7 and above each result is mixed, and everything it
is passed to is reported. stubs/core.stub restates them with the type the method body returns,
read from 2.3.13, and stubs/db-schema.stub corrects the schema builder's documented null
index name. The finder's fluent methods are declared as returning static, so a chain keeps the
entity type the finder was created with, which XenForo's own @return Finder on order() lost.
Iterating a collection, or a finder, yields its entity: getIterator() is restated on both,
because a class declared in a stub loses XenForo's own docblock on that one method.
The parameters are left as XenForo declares them, mixed where it declares nothing. A
parameter type is a claim about every caller, and this package types what comes back.
Levels
Level 2 is the place to start. On a fleet of 44 add-ons with this configuration, the median add-on had 8 errors at level 2, 28 of the 44 had ten or fewer, and most of what remained was the add-on's own to fix. Level 5 is a reasonable target once the call sites are tidy; level 6 is the missing-typehint wall. With the stubs above, level max is reachable: on one small add-on it reports a third fewer than without them, and what remains is the add-on's own typing, the node tree's untyped children, and route parameters, which no static type can know.
Limitations
- A legacy name is a subclass, not an alias. PHPStan has no notion of two names for one class, so a value typed to the renamed class does not satisfy a parameter typed to the legacy name. The shortcuts return the legacy name when the add-on used it, which keeps the add-on's own types consistent; a value from XenForo's own typed API may still need a docblock.
- An overridden signature is seen only through the add-on's own shortcut. A value that arrives typed as the core class from anywhere else, XenForo's own typed API included, carries the core signature.
- A method reached through a base type stays undefined.
$entity->canUseThing()on a value typedXF\Mvc\Entity\Entitycannot be resolved by anything here, because the extension is onXF\Entity\User; that is the variable's type being too wide, and the fix is at the variable. - A stale generated set is detected only for a XenForo upgrade. A class extension added after
generation is reported as an unknown
XFCP_parent, which is loud enough; one removed is simply still offered. - PHPStan resolves symbols from the project that installed it. Run it from the add-on's own
directory with the add-on's own
vendor/bin/phpstan. A phar borrowed from another project consults that project's autoloader first, and the result quietly describes a different dependency tree.
Version support
PHP 8.3, 8.4 and 8.5, and PHPStan 2.1.22 or later. The add-on being analysed can target any XenForo 2 version; the forum it is analysed against is a 2.3 install, because the alias mapping is asked of the forum.
Licence
MIT. See LICENSE.md.