Search by

dragonbytetech / phpstan-xenforo

filliph

PHPStan extension for analysing XenForo 2.3 add-ons without false positives from class extensions, entity structures and options

Package info

github.com/DragonByteTech/phpstan-xenforo

Type:phpstan-extension

pkg:composer/dragonbytetech/phpstan-xenforo

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-10-21 16:05 UTC

This package is auto-updated.

Last update: 2026-10-21 16:24:21 UTC


README

DragonByte Tech Packagist Status

A PHPStan extension that analyses XenForo 2.3 add-ons from inside a development installation, without the false positives XenForo's runtime conventions otherwise produce.

What it handles

XenForo convention Without the extension With it
XFCP_* proxies Class not found; analysis aborts Aliased to the extended class (or XenForo's fake base for missing views, finders and repositories)
Class extensions Members added by an extension are undefined on the extended class Methods and properties of every active extension of the add-on and its dependencies are visible on the extended class
Entity structures Columns, getters and relations without @property tags are undefined Typed from getStructure(), using the same rules as xf-dev:entity-class-properties
Options XF\Options::$foo is undefined Typed from _output/option_hint.php; other options are mixed
Short names finder('XF:User') returns the template bound Resolves to the named class, including Vendor:Infix:Name plugins and bare validator names
Finder chains where(), order(), etc. lose the finder subclass and entity type Return the finder they were called on
__get() bags ParameterBag, Session, App, … report undefined properties Treated as universal object crates
Core PHPDoc @param null $foo, array-only query params, Phrase titles, … Targeted ignores for the known false positives

The extension is safe to install globally. Outside a XenForo installation it does nothing, and every rule it adds is limited to XenForo classes or switched off. Project-wide settings such as treatPhpDocTypesAsCertain therefore belong in each add-on's own config (see below).

Install

phpstan/extension-installer includes extension.neon automatically. Without it, add the file to the add-on's includes yourself.

Globally

Install next to a global PHPStan, and every add-on in every XenForo installation can use it:

composer global config allow-plugins.phpstan/extension-installer true
composer global require phpstan/extension-installer dragonbytetech/phpstan-xenforo

Per add-on

Install into the add-on's own vendor to pin PHPStan and the extension in its composer.lock:

cd src/addons/Vendor/AddOn
composer config allow-plugins.phpstan/extension-installer true
composer require --dev phpstan/phpstan phpstan/extension-installer dragonbytetech/phpstan-xenforo

Run it as vendor/bin/phpstan analyse instead of phpstan analyse.

Use one or the other. Composer also runs global plugins in the add-on, and a global phpstan/extension-installer takes the place of the add-on's copy. The add-on's PHPStan then finds no extensions, and the global PHPStan is pointed at the add-on's vendor. To keep the global install, run the add-on's Composer commands with an empty Composer home, which loads no global plugins:

COMPOSER_HOME="$(mktemp -d)" composer install

If the add-on uses Composer for nothing else, composer.json has only require-dev entries, and none of it belongs in a release. Remove it all in build.json. XenForo copies the add-on to _build/upload before it runs exec, and hashes the files afterwards:

{
	"exec": [
		"rm -f _build/upload/src/addons/{addon_id}/composer.json",
		"rm -f _build/upload/src/addons/{addon_id}/composer.lock",
		"rm -f _build/upload/src/addons/{addon_id}/phpstan.neon.dist",
		"rm -rf _build/upload/src/addons/{addon_id}/vendor"
	]
}

An add-on with runtime dependencies (composer_autoload in addon.json) must keep those files. Remove only phpstan.neon.dist, and drop the development packages from the build instead:

"composer install --working-dir=_build/upload/src/addons/{addon_id}/ --no-dev --optimize-autoloader"

Use

Add a phpstan.neon.dist to the add-on root:

parameters:
	level: 5

	# XenForo PHPDoc describes intent rather than enforced types, and add-ons guard against
	# values the docblocks rule out.
	treatPhpDocTypesAsCertain: false

	paths:
		- .
	excludePaths:
		analyseAndScan:
			- vendor (?)
			- _data (?)
			- _dev (?)
			- _files (?)
			- _no_upload (?)
			- _output (?)
			- _releases (?)

(?) marks a path as optional, since not every add-on has every directory. Leave _output out of scanning: option_hint.php declares a second XF\Options that hides the real one.

Then run PHPStan from the add-on directory:

cd src/addons/Vendor/AddOn
phpstan analyse

The XenForo root and add-on ID come from the working directory. When the working directory is not inside an add-on, they come from the directory of the --configuration (-c) file instead, so this works from the XenForo root as well:

phpstan analyse -c src/addons/Vendor/AddOn/phpstan.neon.dist

To override both, set XF_ROOT (the installation root) and XF_ADDON (comma-separated add-on IDs).

Installed add-ons listed in addon.json require or require-soft are loaded as well, so members they add through class extensions resolve too. Class extensions of any other add-on are loaded when analysis reaches one of its proxies, as optional integrations do. Add-ons installed from a release, without development output, are read from _data/class_extensions.xml.

Keep the file out of release builds by adding this to the exec list in build.json:

"rm -f _build/upload/src/addons/{addon_id}/phpstan.neon.dist"

A per-add-on install has more to remove (see Per add-on).

PhpStorm

PhpStorm may run PHPStan from the project root, but it passes the configuration file on the command line. Point Settings › PHP › Quality Tools › PHPStan › Configuration file at the add-on's phpstan.neon.dist. PhpStorm takes one configuration file per project; change it when you switch add-ons.

Core option types

Core options are mixed until you generate a stub. Run this from the XenForo root:

php cmd.php xf-dev:generate-options-stub

The command writes Options.stub to the root, and the extension picks it up from there. Regenerate it when options change. For a stub somewhere else, give an absolute path:

parameters:
	xenforo:
		coreOptionsStub: /path/to/Options.stub

Tests

tests/fixtures holds assertType() cases. They need a XenForo 2.3 installation, and the add-on-specific cases use the class extensions and options of the test add-on in tests/addon. tests/run.sh links that add-on into the installation, runs the fixtures with this project's PHPStan, and removes the link again. The add-on is never installed:

composer install --no-plugins
tests/run.sh /path/to/xenforo

CI cannot run them, as it has no XenForo. It lints every file on each supported PHP version, checks code style, and analyses the extension itself at level max against the XenForo declarations in tests/stubs/xenforo.php. To run the same checks locally:

composer install --no-plugins
composer cs
composer analyse

--no-plugins matters when phpstan/extension-installer is installed globally: global plugins also run in this project and would point the global PHPStan at this project's vendor.

Limits

  • XenForo is loaded with XF::bootstrap(), startAutoloader() and setupClassAliases() only. XF::start() unregisters the phar stream wrapper PHPStan runs from. No app is set up and the database is never touched.
  • Entity structures are built at analysis time. When getStructure() throws, because it needs the app, every unknown property of that entity resolves to mixed, so nothing false is reported.
  • An argument typed as an extension class but given its base class is ignored only when the message names both classes. Unions and generics are compared textually.