Skip to content
System nominalUTC --:--:--Articles 041

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
Technical cover graphic for What PHPStan Catches in a WordPress Plugin, and What It Misses
Generated schematic — TOOL series, REV 2.6.

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.

DefectWhat the analyser seesCaught at level 5
Undefined variableA name with no assignment on some pathYes
Wrong argument countA call to a function with a known signatureYes
Impossible comparisonTwo types with no overlap under ===Yes
Unused private methodA declaration with no reachable callerYes
Missing return typeAn untyped declarationNo, needs level 6
Wrong capability nameA valid stringNo
Filter arity mismatchA valid call to add_filter()No
SQL built by interpolationA valid call to prepare()No
Logic errorTypes that agree with each otherNo

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

  1. Install szepeviktor/phpstan-wordpress as a dev dependency and constrain the stub version to the oldest core release in your plugin header.
  2. Write a phpstan.neon at level 5, run it once, and fix nothing yet.
  3. 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.
  4. Add the diff-scoped step to CI and make it required on the main branch.
  5. Fix the ten most frequent error types first. Frequency means the pattern repeats, so the fix usually lands in one helper.
  6. 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

Related in Tooling & Workflow