dragonbytetech / phpstan-xenforo
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
Requires
- php: ^8.1
- phpstan/phpstan: ^2.1.26
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.89
- xenforo-ltd/xf-cs-fixer: ^1.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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()andsetupClassAliases()only.XF::start()unregisters thepharstream 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 tomixed, 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.