What PHPStan Catches in a WordPress Plugin, and What It Misses
PHPStan finds undefined variables and wrong argument counts in plugin code. It cannot see a wrong capability name, a filter arity bug or a SQL injection.
Tomas Lindqvist · @tlindqvist
Hardening and operations
- Topic
- TOOL
- Section
- Tooling & Workflow
- Published
- 04 SEP 2026
- Reading time
- 8 MIN
- Words
- 1864W
The first full PHPStan run against a long-lived plugin is loud. On a 38,000-line plugin codebase running PHP 8.2, with the WordPress stubs installed and no baseline, a run at level 5 on a two-core CI runner reported 611 errors in about 40 seconds. None of the 611 was the bug that had taken the site down the day before.
That is the honest shape of static analysis in WordPress. The tool reads your code as code: it builds a model of what every variable, argument and return value can be, then reports where that model contradicts itself. WordPress spends a lot of effort making sure the model cannot be built. Hooks accept an arbitrary number of arguments of arbitrary type. Filters return whatever the last callback decided. Functions live in a global table and are looked up when they are called. The database layer hands back rows whose columns are known only to the string you wrote.
Teams react in one of two ways. They switch the tool off, or they add a baseline, reach zero and start treating a green check as evidence that the code is correct. The second reaction costs more, because it changes how people read their own diffs.
WordPress resolves types at runtime, where a static checker cannot follow
The mechanism behind most false positives is late binding. When you write
apply_filters( 'myplugin_totals', $totals ), the return value depends on which callbacks are
registered on that site at that moment. Core documents apply_filters() as returning
mixed, and that is not laziness: a filter is an extension point, so any type assigned to it
is a guess about every plugin that will ever sit next to yours.
What that does downstream is the part people miss. The mixed value flows into your local
variables. At level 5 the analyser reports array_sum( $totals ) as an error, because
mixed is not an array. That report is correct about the types and useless about the code,
since on every site where the filter has a sane callback it works. You silence it with a cast or an
@var annotation, and silence is all you get: the cast is your assertion, not the analyser's
finding.
Global functions behave the same way for a different reason. PHP resolves
myplugin_helper() through the global function table at call time, so a function declared
inside a function_exists() guard, or shipped by a companion plugin, is invisible unless
something tells the analyser it exists. That is what the stub packages are for:
php-stubs/wordpress-stubs supplies signatures for core, and
szepeviktor/phpstan-wordpress wires them in. Neither can know about the function in the
plugin next door.
Then there is $wpdb. A row returned by $wpdb->get_results() is an object
as far as the analyser is concerned, with no columns attached. Reading
$row->order_total is either an error about an undefined property or an assertion you wrote
into a docblock to silence it. The query string is the only place the schema exists, and it is not
typed.
Level 5 gets the useful half of the rules without the noise
PHPStan's levels run from 0 to 9, with a max alias that turns on everything. Each level
adds rules rather than replacing them, and the ones that matter to plugin work arrive in an order worth
knowing. Level 4 turns on dead code checks. Level 5 checks the types of arguments passed to functions and
methods. Level 6 demands type declarations on everything. Levels 7 to 9 tighten unions, nullability and
mixed.
Starting at 9 on an existing plugin is where the tool earns its reputation for being unusable. On untyped code, most of the extra reports are about the absence of annotations rather than a defect: missing parameter types, missing return types, mixed values inside arrays. Those are real findings, and they are not the ones you can act on this quarter.
Level 5 is the point where the report is mostly about mistakes. The setup is one config file, one Composer package for the stubs, and a bootstrap file if your plugin reads constants defined elsewhere.
# phpstan.neon
includes:
- vendor/szepeviktor/phpstan-wordpress/extension.neon
- phpstan-baseline.neon
parameters:
level: 5
paths:
- includes
- admin
- my-plugin.php
bootstrapFiles:
- tests/phpstan-bootstrap.php
excludePaths:
- tests/fixtures
- node_modules
reportUnmatchedIgnoredErrors: false
Two details in there trip people up. The baseline is an includes entry rather than a
parameter, so it is loaded and not configured. And bootstrapFiles is where you define
constants and load the small amount of code the analyser cannot infer. Leave it out and you get a page
of reports about undefined constants that say nothing about your plugin.
Pin the stubs to your oldest supported core
The stub package tracks WordPress releases. Installing the newest version while your plugin header
still says Requires at least: 6.2 means calls to functions added in later releases pass
analysis and then fail on real sites. Constrain the package to the oldest core you support.
A baseline freezes the past so the diff is what gets reviewed
A baseline records every error the codebase produces today in a generated file, and tells the analyser to ignore exactly those. It stays honest as long as the file is checked in and the count only goes down. What it buys is a rule you can enforce immediately: no new code adds a new error.
Generating the baseline is a one-off. Analysing only the files a branch touches is the recurring job, and passing an explicit file list does not disable the baseline: the ignore list still applies, which is what you want.
# Once, on the commit you are freezing. Writes phpstan-baseline.neon.
vendor/bin/phpstan analyse --generate-baseline
git add phpstan-baseline.neon
# On every pull request: analyse the files the branch changes, not the tree.
git diff --name-only --diff-filter=ACMR origin/main...HEAD -- '*.php' \
| xargs -r vendor/bin/phpstan analyse --error-format=github --no-progress
Two things go wrong with that in practice. The three-dot diff only resolves if the runner's clone
contains the base commit, so fetch origin/main with enough history first. And a deleted file
still appears in the diff, which is why the filter keeps added, copied, modified and renamed paths and
drops the rest.
The payoff shows up in review attention rather than in runtime. A green run over a six-file diff says something a run over 38,000 lines cannot: that nothing in this change contradicts what the rest of the codebase asserts. I keep the full-tree run nightly, where its runtime does not matter, and read a growing baseline as a sign that debt is accumulating rather than being paid.
A level 5 report reliably finds a narrow set of mistakes
The reports worth acting on share one property: the analyser can see the contradiction without
knowing anything about runtime. Undefined variables are the clearest case, and a rename that misses one
branch of an if produces them in batches. Wrong argument counts on core functions are
reported from level 0, since the signature is known, and they cluster in code written against an older
core release. Calls to methods and properties that do not exist on a typed object are caught whenever the
class is known, which is why the code you own is analysed better than the code you consume.
Impossible comparisons are reported from level 4 upward: an integer variable checked against a
string literal with ===, an instanceof that can never be true. Dead code
lands in the same level, covering unreachable branches after a return, private methods
nobody calls, and conditions that are always true given the declared types.
Missing return types appear only at level 6, and that is worth planning as a second milestone instead of smuggling into the first one. Turning it on usually produces more reports than the initial level 5 run did, because it treats every untyped function as a defect. It is also the rule that improves the rest of the report over time, since a typed function gives the analyser something to check its callers against.
A clean run is evidence of internal consistency, not of correctness
Now the half that does not work. A capability check with a misspelled name is still a string, and every
string is a valid string. The analyser will not tell you that manage_optinos is not a
capability, because PHP has no opinion about it. WordPress resolves it at runtime against a list it
builds from roles, and an unknown capability quietly returns false, which locks an administrator out of a
screen or lets a role through a check it was never meant to pass.
Filter arity fails from the other direction. If a hook passes two values and your callback declares
three required parameters, PHP throws an ArgumentCountError the first time that hook fires,
which may be months after release on a path nobody tested. The accepted-arguments count you pass to
add_filter() is a number the analyser cannot compare against the argument list the hook
supplies at runtime. A validator that receives the wrong value in the wrong position is worse: no error,
just the wrong result.
SQL is the third blind spot and the most expensive one. $wpdb->prepare() returns a
string, and a string concatenated into a query is still a string. A query built by interpolating a
variable analyses cleanly and reads like an ordinary line of code, because you used the API exactly as
declared.
Logic errors are the fourth. A loop that starts at index 1 and skips the first row. An inverted condition that is still a valid boolean comparison. A cache key that omits the user ID. Types satisfied, behaviour wrong.
| Defect | What the analyser sees | Caught at level 5 |
|---|---|---|
| Undefined variable | A name with no assignment on some path | Yes |
| Wrong argument count | A call to a function with a known signature | Yes |
| Impossible comparison | Two types with no overlap under === | Yes |
| Unused private method | A declaration with no reachable caller | Yes |
| Missing return type | An untyped declaration | No, needs level 6 |
| Wrong capability name | A valid string | No |
| Filter arity mismatch | A valid call to add_filter() | No |
| SQL built by interpolation | A valid call to prepare() | No |
| Logic error | Types that agree with each other | No |
Which leaves the sentence to put in the description of the CI step, where somebody might read it: a clean run is evidence of internal consistency, not of correctness. Those are different claims, and a team that collapses them stops reading its own diffs, because the tool appears to have read them already.
What to do on Monday
- Install
szepeviktor/phpstan-wordpressas a dev dependency and constrain the stub version to the oldest core release in your plugin header. - Write a
phpstan.neonat level 5, run it once, and fix nothing yet. - Generate the baseline and commit it in a pull request that changes nothing else. That review is a decision about which errors you are accepting.
- Add the diff-scoped step to CI and make it required on the main branch.
- Fix the ten most frequent error types first. Frequency means the pattern repeats, so the fix usually lands in one helper.
- Leave level 6 for the next quarter, and write the current baseline count somewhere a person will see it.
Tomas Lindqvist · @tlindqvist
Hardening and operations
Threat models plugins, audits capability and nonce handling, and builds the observability that tells you an integration broke before a customer does.
Continue