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

Dependents: 1

Suggesters: 1

Stars: 0

Open Issues: 0

0.10.8 2026-11-10 07:20 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 simplest way never to forget is to run it in front of every analysis. It takes a fraction of a second (0.2 s on a forum with 963 aliases and 123 class extensions), and it writes nothing when the set already there is current, so PHPStan's result cache, which records the hash of every stub file and starts over when one changes, is left alone:

"scripts": {
    "analyse": [
        "xenforo-phpstan generate",
        "phpstan analyse"
    ]
}

The ^0.x constraint Composer writes is deliberate, and a new minor is a deliberate move. Below 1.0 a caret pins the minor, so ^0.4 means >=0.4 <0.5: a patch release arrives with composer update, and a minor does not. Each minor so far has changed what an add-on sees at its level, such as a bare Tree becoming a missing-generics report, so a minor is taken on purpose, by naming the new minor in the constraint, and read against the changelog, whose entry for each minor carries the composer require line. If composer update reports nothing to do after a release, that is the constraint, not Packagist.

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

The set is shared by every add-on on the forum, so before regenerating it under someone else's analysis, ask whether it is still current:

vendor/bin/xenforo-phpstan generate --check

It writes nothing and applies the test PHPStan applies on first use: exit 0 and the generated date when the set is current, exit 1 and the reason when it is missing, stale or written by an older release.

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. The add-on object's are typed individually, as the next section describes; these three classes have no static answer, and crates.neon opts them 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. An add-on with no entity of its own usually needs neither: XenForo's own entities carry @property docblocks, so $visitor->user_id is already typed, and a crate would only hide a mistyped name.

The three classes need not go together. Route parameters have no static type anywhere, while entity columns have one wherever a @property docblock exists, so an add-on that wants $params->user_id quiet without crating every entity lists the one class itself instead of including the file:

parameters:
    universalObjectCratesClasses:
        - XF\Mvc\ParameterBag

Options have a precise answer of their own. XenForo writes an _output/option_hint.php for every add-on in development mode, each declaring an empty XF\Options with that add-on's options as @property lines, so an install declares the class once for real and once per add-on, and the declaration PHPStan's directory scan settled on was not XenForo's: \XF::options() had none of its ArrayObject methods, and carried some add-on's options or none depending on the add-on's phpVersion. The fragment scans src/XF/Options.php by name, which PHPStan consults ahead of the directories, so the class is the real one everywhere. An add-on then types its own options by listing its hint as a stub:

parameters:
    stubFiles:
        - _output/option_hint.php

The stub's @property lines are read because the real class allows dynamic properties. Any other option, core's included, stays undefined, which is the point: a mistyped name of the add-on's own is reported. The stub replaces the class docblock, so getArrayCopy() becomes array<mixed> with it in place. An add-on that crates XF\Options instead is unaffected by any of this, and the choice is the usual one: the stub reports a mistyped name of its own and every core option it reads, so moving from the crate to the stub costs one ignore per boardTitle, or the core stub below; the crate checks neither. An add-on with no options of its own that reads one or two of core's has no hint to list, so its choice is between the generated file below and the crate, and for a boardUrl read the crate is the usual answer: there is no name of its own for the stub to check, and nothing to regenerate.

Core options can be typed the same way. XenForo's xf-dev:generate-options-stub writes a file of the same shape for any set of add-ons, core alone by default, from the option data, so it needs development mode and the database:

php cmd.php xf-dev:generate-options-stub --addon=XF --addon=Vendor/AddOn /path/to/Options.stub

Name that one file in stubFiles in place of the hint, and \XF::options()->boardTitle is string|null beside the add-on's own options, while a misspelt name is still reported. One file, because a class may be declared in one stub only, which is why core and the add-on go into the same run. It is generated from option data rather than committed source, so it is stale the moment an option changes and nothing regenerates it for you; keep it with the other generated files rather than in the add-on.

Either file writes an array option with no sub-option structure as a bare array, four of core's on 2.3.13 and one more nested inside a fifth's shape, and PHPStan reports each on the stub's class line as a missing iterable value type. That report can be neither ignored nor excluded: PHPStan refuses an ignoreErrors entry for a stub's own errors, and an excludePaths entry does not reach them, measured both ways. The remedy is to widen the bare arrays in the generated file, which is XenForo's output rather than committed source:

sed -E -i '/@property/ s/\barray\b([^<{-]|$)/array<mixed>\1/g' /path/to/Options.stub

An add-on whose own options include such an array meets the same report through its hint, and XenForo rewrites the hint whenever options change, so that add-on wants the generated stub, with the line above, rather than the hint.

A crate also decides what a class extension's added columns look like, and the answer depends on the PHPStan release. The current release asks the extensions in this package before it applies the crate, so a crated XF\Entity\User still types $visitor->vendor_addon_column from the extension's @property and still reports a write to a @property-read; PHPStan 2.1.22 applies the crate first, and on a crated entity every added column is mixed and nothing is reported. Without crates the extension answers on both.

A PHP floor below the analysing PHP

An add-on whose floor is below the PHP it is analysed on can have PHPStan hold it to the floor:

parameters:
    phpVersion:
        min: 70400
        max: 80500

That reports syntax newer than the floor, and nothing else. A match, a union type, a nullsafe call is caught; a call to str_contains() on a 7.4 floor is not, because PHPStan reads functions from the PHP it runs on. It also applies to everything analysed, and PHPUnit 12 is not readable as 7.4 code, so a suite under tests/ becomes one method.notFound per assertion. The shape that works is a second config for the shipped code alone, included from the first:

# phpstan.floor.neon
includes:
    - phpstan.neon

parameters:
    phpVersion:
        min: 70400
        max: 80500
    excludePaths:
        analyse:
            - tests

vendor/bin/phpstan analyse -c phpstan.floor.neon is then the floor check, and the plain run analyses the tests. It inherits the level as well as the paths, so it is a second full analysis of the shipped code, not a syntax check; level: 0 in the floor file makes it the syntax check alone, because a parse error is reported at every level. An add-on whose release build already lints on the floor PHP may decide that is enough and leave phpVersion out. PHPStan's own floor is 70100, so an add-on that supports XenForo 2.2's PHP 7.0 holds at 7.1, which is as low as the setting goes.

Supporting XenForo 2.2

The forum analysed against is a 2.3 install, so a guard for a 2.3 method is always true there:

if (method_exists('XF', 'accessToken')) { // function.alreadyNarrowedType at level 4 and above

The guard is right, and the report is PHPStan being told the truth about one forum. Ignore it by identifier and path, so a guard that stops being needed is still noticed:

parameters:
    ignoreErrors:
        -
            identifier: function.alreadyNarrowedType
            path: Cli/Command/Validate.php

The same applies to class_exists() on a class 2.3 added, and to any branch that runs only on 2.2.

Level 6 asks for a return type on every method, and on a class other add-ons extend the native form is a breaking change: an override written without it, as every one against the untyped original was, is an incompatible declaration, which PHP refuses to load. That covers any class the add-on registers through extendClass(), and any other it expects to be subclassed. Type those methods by docblock, which PHPStan reads the same way and PHP does not check; a class nobody extends can take the native type.

A class declared twice, inside if (\XF::$versionId >= 2030000) and its else, because 2.3 added a return type to a method the extension overrides, is a different case: read against 2.3, the untyped 2.2 declaration is an incompatible override, and method.childReturnType is one of the reports PHPStan refuses to ignore. Excluding the file loses the 2.3 branch with it. Put the two declarations in two files, required from the one the autoloader finds by the same test, and exclude the 2.2 file alone.

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, src/XF/Options.php is read as a file because every add-on's option hint declares that class too, and every add-on's vendor/ and _build/ are left out of the scan. The first 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. The second is the copy of itself a release build makes under _build/upload/, which declares every class of that add-on twice while it exists, and aborts the run if the build deletes a file as PHPStan reads 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 and properties 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 43 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.

Two things XenForo decides when it registers an extension are mirrored. An extension registered active="0" has its XFCP_ parent declared, so the class still compiles, and nothing it adds is offered, since XenForo never applies it. An extension of a class the forum does not have, as an optional integration's extension of an add-on that is not installed, gets the fake base XenForo substitutes when it requests a view, a finder or a repository by name, XF\Mvc\View, Finder or Repository, so everything below it resolves; anywhere else XenForo applies no extension at all, and the parent is left naming the class it cannot find.

The same goes for a column or a relation an extension adds in getStructure(). The extension declares it as XenForo declares its own, @property string $vendor_addon_column or @property-read Thing|null $Thing on the class, which is what xf-dev:entity-class-properties writes; inside the extension PHPStan already reads that, and through the core type, which is what \XF::visitor(), a finder result or a test helper gives you, the read was property.notFound from level 2. A second reflection extension offers every property the recorded extensions of a class declare, with the declared type and writability, so $visitor->vendor_addon_column = $x is typed and $visitor->Thing = null is reported against a @property-read. A property the core class declares itself is left to PHPStan's own reading of it.

A typed relation exposes the entity behind it. Once $visitor->Thing is a Thing rather than mixed, every column read on it is checked, so an add-on entity reached through a relation needs its own @property block or its columns are reported as undefined from level 2. One adopter's move to 0.9 surfaced six such reads on an entity that had never been typed; the docblock from xf-dev:entity-class-properties cleared them. That is the add-on's gap, found rather than caused.

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.

$app->container() with no key is the container itself, XF\Container. XenForo documents the method as Container|mixed, which PHPStan reads as mixed on both paths, so an add-on that reaches the container to register or read an entry called every method on it undefined. A keyed call stays as XenForo documents it: what an entry holds is a fact about the install.

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(), filterViewable() and mergeNodeListExtras(), Node::getBreadcrumbs() and getNodeListExtras(), AddOn\Manager::getById(), User::canFollowUser(), canViewFullProfile(), canViewBasicProfile() and hasAdminPermission(), Entity::getErrors(), ValidateAndSavableTrait::validate() and what it writes to $errors, MergeService::setSource(), setTarget() and merge(), ContinuationResult::completed(), the adapter's fetchRow(), fetchAll(), fetchPairs() and fetchAllKeyed(), Structure's $columns and $relations, the schema Column's fluent setters, User::hasPermission(), hasContentPermission() and hasNodePermission(), AddOn::getJson(), RegistrationService::_save(), Request::getUserAgent(), Templater::fnAvatar(), File::getTempFile() and its three temp-path siblings, App::applyExternalDataUrl(), Request::getBasePath(), AbstractOption's five row helpers, the entity manager's getBasicCollection() and getEmptyCollection(), the BB code Traverser's renderSubTree(), renderSubTreePlain() and renderUnparsedTag(), Renderer\Html::filterString(), Str\Formatter::getLinkClassTarget(), and getMasterPhrase() on a prefix group and a widget. 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.

Documented types are corrected rather than transcribed where XenForo's own code contradicts the docblock, and the stub says so at each one. stubs/db-schema.stub corrects the schema builder's null index name. core.stub corrects the array XenForo claims for AbstractAdapter::query()'s parameters, which core itself passes as a scalar and the statement wraps; the null|StepResult documented on AbstractSetup's install(), upgrade() and uninstall(), which a setup with a single step falls out of without returning, as most do, so the stub adds void and only core reads the result; AbstractCollection::filter()'s callback, documented as returning bool where the body is array_filter(); groupBy()'s callables, written callable(T $e): int|string, which PHPDoc reads as a callable returning int beside a plain string; Controller::noPermission()'s message, which goes to error() and so takes a list too; AbstractWidget::render(), documented string|null where every core widget returns $this->renderer(); Purchase::$purchasableId, documented string and assigned an entity id by core's own purchasable; ActivityLogRepository::log()'s values, one map as core's handlers pass it rather than a list of them; AttachmentRepository::addAttachmentsToContent(), which iterates its AbstractCollection|Entity[] and so takes an array; the activity log handler's getReplyMetricsSimple() parameters, which are positional, and its getReactionMetrics(), documented as the handler's own entity where core's thread handler passes the first post; Stats\AbstractHandler::adjustStatValue(), documented int where the attachment handler returns round(..., 2); and ModeratorLog\AbstractHandler::getContentTitle(), InlineMod\AbstractAction::getTitle() and ConnectedAccount\Provider\AbstractProvider::getTitle(), documented string where core's own implementations return a phrase, so an add-on's doing the same was an incompatible declaration; Manager::instantiateEntity(), documented nullable for an int-mask of the proxied flag, a mask that includes the default of 0, so every plain call read as nullable where the body returns null only under a non-zero option, the allow-invalid one the mask leaves out included; and getMasterPhrase() on StyleProperty and StylePropertyGroup, documented as the renderable XF\Phrase by an import where the body returns the XF\Entity\Phrase entity, so setting its text and saving it was reported from level 2. Each is held by a fixture class carrying XenForo's docblock, and every one is reported against the previous stub. 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; the schema Column's setters are declared the same way. An add-on's own finder carries an entity type only if it declares one, @extends Finder<Thing> as XenForo's finders do; xf-dev:entity-class-properties writes nothing for a finder, and one without the line fetches Entity. Three of the types are wider than an adopter asked for, because the body is: hasPermission() and its two siblings return the permission's value, a bool for a flag and an int for an integer permission such as maxMentionedUsers; getUserAgent() returns getServer()'s default of false when the request sent no agent, which a string column set from it does not survive; and getTempFile() returns tempnam(), false when the temp directory cannot be written, which XenForo does not check. 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 manager's two collection factories are documented as a bare ArrayCollection, which reads as a collection of Entity and cannot be returned where a typed collection is declared. getBasicCollection() is a collection of exactly what it was given: XenForo declares the collection's template without variance, so a collection of an extension's user is not a collection of core's, and an array built from a nullable lookup is reported before it becomes one. getEmptyCollection() is a collection of nothing, which passes wherever any typed collection is expected, the short circuit XenForo describes it for. Templater::func(), a template function called directly as a test does, is typed by an extension rather than a stub: the result is escaped, so a string, unless $escape is passed false, when it is whatever the function returned.

The add-on object AddOn\Manager::getById() returns answers $addOn->title and $addOn->version_string by magic, nine keys of its own and then any column of the installed XF\Entity\AddOn. Each is typed individually, the keys from their getters and the columns from the entity's own docblock, so a mistyped key is still reported. A key that exists only in the add-on's addon.json is not offered; read it through getJson(). XF\Job\JobResult answers $result->completed the same way, the one key its __get() has, and the stub declares it.

Plucked columns

$finder->pluckFrom('user_id') replaces each fetched entity with that column, or with what a closure returns, and XenForo declares it as returning the finder, so fetch()->toArray() after it was still an array of entities. It is typed over the plucked value instead: the entity's own type for the column, an extension's column included, the closure's return type for a closure, and mixed for a column nothing declares. fetchOne() after pluckFrom('username') is string|null.

Relations by name

$user->getRelation('Auth') and getRelationOrDefault('Auth') are typed from the receiver's own @property-read UserAuth|null $Auth, which every XenForo entity declares for each relation and an extension declares for the relations it adds, through the core type as well since the properties extension offers them there. getRelation() keeps the declared nullability; getRelationOrDefault() creates the default, so the result is the entity itself. A name the receiver does not declare leaves the call as XenForo left it.

Controller actions

Every controller action returns a reply, since the dispatcher rejects anything else, and XenForo declares none of them. An action* method with no return type of its own is typed AbstractReply, through $this-> and through parent::, so return parent::actionIndex() in a controller extension is no longer mixed. An action that declares a return type, XenForo's or the add-on's, keeps it.

Request input

$this->filter('id', 'uint') on a controller, and filter() on the request, are typed from the filter string, as XF\InputFilterer casts the value: str is a string, uint an int, bool a bool, num an int or float, datetime,obj a DateTime, array-uint an array of int, and a leading ? lets null through. An array of filters is an array shape with one entry per key, nested arrays included, so $input['username'] is a string. A filter in a variable, or a name the filterer does not have, leaves the call mixed as before.

XF\Tree and XF\SubTree are generic over the record they hold. NodeRepository::createNodeTree() returns a Tree<Node>, a tree built by hand is typed by what it was given, and a subtree's record, children() and parent() follow. That makes a bare Tree or SubTree in an add-on's own docblock a missing-generics report at level 6 and above, as a bare Finder already is; write SubTree<Node>.

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 43 add-ons with this configuration, the median add-on had 8 errors at level 2, 27 of the 43 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 and route parameters, which no static type can know. The climb finds things on the way: level 8 is where a nullable return is reported at the place it is used, and one add-on learned there that XFMG's MediaItem::getThumbnailUrl() returns null until a thumbnail exists, having rendered the null as an empty src until then.

Above level 8, most of what is left is the add-on describing itself, not the forum. PHPStan never infers a return type across a method boundary, so an untyped method of the add-on's own hands mixed to every caller, and each use of the result is a report. On one add-on, half of the level-max total came from a single untyped test helper; typing the helpers answered all of it. What remains once the add-on's own methods are typed is core methods with no declared return type that the stubs do not yet cover. An inline @var on the result narrows one in shipped code, and assertIsString() and its siblings narrow one in a test; the list above grows from exactly these reports, the last six from one add-on. Before reading a residual as XenForo's, \PHPStan\dumpType() on the value says whose type is missing. One residual that is neither: Symfony Console's getOption() and getArgument() return mixed by Symfony's own declaration, so a CLI command narrows them itself.

A custom BB code callback is a method of the add-on's own, so its parameters are the add-on's to declare, and XenForo's documented signature is wider than what arrives. The renderer parameter is documented as AbstractRenderer, but only Html::getCustomTagConfig() registers a callback and Html::renderTag() passes itself, so the renderer is always Html or one of its subclasses, and declaring it so is accurate where the abstract type leaves filterString() undefined. The parsed tag has one shape, each child a string or another tag of the same shape:

/**
 * @param list<string|array<string, mixed>> $children
 * @param string|null $option
 * @param array{tag: string, option: string|null, original: array{string, string}, children: list<string|array<string, mixed>>} $tag
 * @param array<string, mixed> $options
 */
public static function renderTagExample($children, $option, array $tag, array $options, Html $renderer)

A tag whose definition has has_option set to yes never arrives with a null option, because RuleSet::validateTag() rejects one before the callback is reached.

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 runtime check that the add-on's own extension is applied reads as always true. Inside the add-on that extends XF\Entity\User, create('XF:User') instanceof Extension\User is reported instanceof.alwaysTrue, because the shortcut returns the extension. The check is still worth making at runtime, where a disabled add-on or a broken extension chain would fail it; put the instanceof behind a method taking object, so that nothing is decided before it runs.
  • A value passed by reference inside an array literal is not tracked. After $app->fire($event, [&$collected]) PHPStan still types $collected as the empty array it started as, and every use of it is reported as unreachable or always false. That is PHPStan's own flow analysis, and nothing here reaches it; declare the variable's type with a docblock after the call.
  • Code built on another add-on's vendored library does not resolve. Only the analysed add-on's vendor/ is scanned, by design, so a class that extends a sibling add-on and calls a library the sibling vendors is class.notFound. Exclude that code from analysis; it runs in the sibling's context, not this add-on's.
  • 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. generate --check asks the same question and no more, which is why the Installation section runs generate in front of every analysis instead.
  • 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.