Search by

hampel / xenforo-phpstan

hampel

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

Statistics

Installs: 19

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.3.2 2026-10-22 12:41 UTC

This package is auto-updated.

Last update: 2026-10-22 12:44:31 UTC


README

Tests Latest Version on Packagist Total Downloads Open Issues License

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 typed XF\Mvc\Entity\Entity cannot be resolved by anything here, because the extension is on XF\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.