Back to Blog
Development Patterns

The Complete Guide to WordPress Static Analysis: PHPStan, PHPCS, and Automated Code Quality Pipelines

Priya Sharma
52 min read

WordPress powers over 40% of the web, but its codebase and ecosystem carry decades of PHP conventions, some predating modern type safety entirely. If you maintain a WordPress plugin, theme, or client project of any meaningful size, you have almost certainly encountered the frustration of bugs that a stricter language would have caught at compile time: wrong argument types passed to hooks, undefined variables in template files, or return type mismatches from filter callbacks.

Static analysis tools solve this category of problem by reading your code without executing it, flagging type errors, unreachable code, and violations of coding standards before you ever push a commit. Two tools dominate the PHP static analysis space for WordPress: PHPStan for type-level analysis and PHP_CodeSniffer (PHPCS) for coding standard enforcement. Combined with CI pipelines, pre-commit hooks, and IDE integrations, they form a quality gate that catches entire classes of defects automatically.

This guide covers the full setup from zero to production-grade pipeline. Every configuration shown here is drawn from real project usage, not hypothetical defaults. By the end, you will have a working static analysis pipeline for WordPress that runs locally, in your editor, in pre-commit hooks, and in GitHub Actions.

Why Static Analysis Matters for WordPress Specifically

PHP has come a long way since the days of mysql_query() and untyped function signatures. PHP 8.x offers union types, named arguments, enums, fibers, and readonly properties. Yet WordPress core still supports PHP 7.4 as its minimum, and many plugins target even older versions. This gap between modern PHP capabilities and WordPress’s backward-compatible API creates a breeding ground for subtle bugs.

Consider this common pattern in WordPress plugin development:

add_filter( 'the_content', 'my_plugin_modify_content' );

function my_plugin_modify_content( $content ) {
    if ( is_single() && get_post_type() === 'product' ) {
        $price = get_post_meta( get_the_ID(), '_product_price', true );
        $content .= '<div class="price">Price: $' . $price . '</div>';
    }
    return $content;
}

This code works in the happy path. But get_post_meta() can return an empty string, a serialized array, or false if the post ID is invalid. get_the_ID() returns int|false. Passing false to get_post_meta() as the post ID will produce unexpected behavior silently. Without static analysis, you discover this bug when a customer reports a broken product page, not when you write the code.

Static analysis catches these issues at development time. PHPStan would flag the get_the_ID() return type as int|false and warn you that passing it directly to get_post_meta() without a false check is unsafe. That single warning prevents a production bug. Multiply this across thousands of lines of plugin code and the value becomes obvious.

Setting Up PHPStan for WordPress Projects

PHPStan is a static analysis tool created by Ondrej Mirtes. It reads PHP code, builds an abstract syntax tree, infers types, and reports errors where the types do not match what the code expects. It works at multiple strictness levels (0 through 9), allowing gradual adoption.

Installation with Composer

Install PHPStan and the WordPress extension as development dependencies:

composer require --dev phpstan/phpstan szepeviktor/phpstan-wordpress php-stubs/wordpress-stubs

The szepeviktor/phpstan-wordpress package provides WordPress-specific type definitions and function stubs. Without it, PHPStan would treat every WordPress function as returning mixed, rendering the analysis nearly useless. The php-stubs/wordpress-stubs package provides function signatures for all WordPress core functions, classes, and constants.

Base Configuration File

Create a phpstan.neon file in your project root:

includes:
    - vendor/szepeviktor/phpstan-wordpress/extension.neon

parameters:
    level: 0
    paths:
        - wp-content/plugins/my-plugin/
    excludePaths:
        - wp-content/plugins/my-plugin/vendor/
        - wp-content/plugins/my-plugin/node_modules/
        - wp-content/plugins/my-plugin/tests/
    bootstrapFiles:
        - wp-content/plugins/my-plugin/vendor/autoload.php
    scanDirectories:
        - wp-content/plugins/my-plugin/
    treatPhpDocTypesAsCertain: false

The includes directive loads the WordPress extension, which registers WordPress-specific type definitions. The treatPhpDocTypesAsCertain: false setting tells PHPStan not to fully trust PHPDoc types, which is wise in WordPress where docblocks frequently lie about return types.

Running Your First Analysis

Run PHPStan from your project root:

vendor/bin/phpstan analyse --memory-limit=512M

The memory limit flag is important for WordPress projects. WordPress stubs define thousands of functions, and PHPStan needs to load all of them into memory. On a typical plugin with 50-100 PHP files, expect PHPStan to use 200-400MB of RAM.

At level 0, PHPStan checks for basic errors: undefined classes, functions called on wrong types, wrong number of arguments. Even at this minimal level, it frequently catches real bugs in WordPress projects because many WordPress functions have complex return types that developers overlook.

Progressively Increasing PHPStan Levels

PHPStan’s level system is its most practical feature for existing codebases. Each level adds more checks on top of the previous one. Here is what each level adds and what WordPress-specific issues you will encounter at each stage.

Level 0: Basic Checks

Checks for unknown classes, unknown functions, wrong number of arguments passed to functions and methods, and basic type mismatches in return statements.

// Level 0 catches this: unknown function
my_nonexistent_function(); // Error: Function my_nonexistent_function not found.

// Level 0 catches this: wrong argument count
wp_enqueue_script( 'handle', 'src', array(), '1.0', true, 'extra' );
// Error: Function wp_enqueue_script invoked with 6 parameters, 1-5 required.

Level 1: Possibly Undefined Variables

This level catches variables that might not be defined in all code paths. WordPress template files are notorious for this:

// Level 1 catches this
if ( $condition ) {
    $message = 'Success';
}
echo $message; // Error: Variable $message might not be defined.

In WordPress development, this pattern shows up constantly in template parts where variables are expected to be set by the calling file via extract() or set_query_var(). You will need to either add default values or use PHPStan’s ignore comments for template files.

Level 2: Unknown Methods on Object Types

Catches method calls on objects where PHPStan cannot verify the method exists. This is where WordPress’s $wpdb global triggers warnings if you have not loaded the stubs properly.

// Level 2 catches this
/** @var WP_Post $post */
$post = get_post( $id );
echo $post->nonexistent_method(); // Error: Call to undefined method WP_Post::nonexistent_method().

Level 3: Return Types

PHPStan starts checking that functions return values matching their declared return types. This is where WordPress plugins start producing many errors, because developers often omit return type declarations or have functions that return different types in different branches:

/**
 * @return string
 */
function get_formatted_price( int $product_id ): string {
    $price = get_post_meta( $product_id, '_price', true );
    if ( ! $price ) {
        return false; // Error: Function get_formatted_price should return string but returns false.
    }
    return '$' . number_format( (float) $price, 2 );
}

Level 4: Dead Code Detection

Identifies unreachable code, always-true or always-false conditions, and unused types in multi-type unions:

$post = get_post( $id );
if ( $post instanceof WP_Post ) {
    // do something
} elseif ( is_null( $post ) ) {
    // do something else
} else {
    // Level 4 flags: this branch is unreachable because get_post returns WP_Post|null
    echo 'This never runs';
}

Level 5: Argument Type Checks

This level checks that arguments passed to functions match the expected parameter types. For WordPress, this is where things get interesting because many WordPress functions accept multiple types:

// Level 5 catches this
$post_id = get_the_ID(); // Returns int|false
update_post_meta( $post_id, 'key', 'value' );
// Error: Parameter #1 $post_id of function update_post_meta expects int, int|false given.

The fix is straightforward but essential:

$post_id = get_the_ID();
if ( false === $post_id ) {
    return;
}
update_post_meta( $post_id, 'key', 'value' ); // Now $post_id is guaranteed to be int.

Levels 6 Through 9: Strict Mode Territory

Level 6 reports missing typehints. Level 7 reports partially wrong union types. Level 8 reports calls on nullable types. Level 9 reports mixed type usage. For most WordPress projects, reaching level 6 is an excellent achievement. Levels 7-9 are realistic only for greenfield plugins built with strict typing from day one.

A practical strategy: start at level 0, fix all errors, generate a baseline (covered later), bump to level 1, repeat. Most teams find their comfort zone between levels 4 and 6. Going beyond 6 requires adding type declarations to every function, which may not be worthwhile for a WordPress plugin that must remain compatible with PHP 7.4.

WordPress-Specific Challenges in Static Analysis

WordPress’s architecture creates several unique challenges for static analysis tools. Understanding these challenges prevents frustration when you see error counts that seem impossibly high on your first run.

Hook Callbacks and Type Inference

WordPress’s action and filter system is fundamentally dynamic. When you call add_filter( 'the_content', 'my_callback' ), PHPStan cannot infer what type $content will be when WordPress calls my_callback. The WordPress PHPStan extension handles many core hooks, but custom hooks require manual type annotations:

// Without annotations, PHPStan sees $value as mixed
add_filter( 'my_custom_filter', function ( $value ) {
    return strtoupper( $value ); // Error at level 6+: Parameter #1 of strtoupper expects string, mixed given.
} );

// Fix: add parameter types
add_filter( 'my_custom_filter', function ( string $value ): string {
    return strtoupper( $value );
} );

apply_filters Return Types

The apply_filters() function returns mixed because any filter callback can change the return type. This is correct behavior from WordPress’s perspective but creates headaches for static analysis:

// PHPStan sees this as mixed
$title = apply_filters( 'my_plugin_title', 'Default Title' );
echo strlen( $title ); // Error at level 6: Parameter #1 of strlen expects string, mixed given.

You have three options for handling this. The first is to use an inline PHPDoc assertion:

/** @var string $title */
$title = apply_filters( 'my_plugin_title', 'Default Title' );
echo strlen( $title );

The second option is to use assert() which PHPStan respects for type narrowing:

$title = apply_filters( 'my_plugin_title', 'Default Title' );
assert( is_string( $title ) );
echo strlen( $title );

The third option is to write a custom PHPStan extension that maps specific filter names to known return types, which we will cover in a later section.

Global Variables and $wpdb

WordPress relies heavily on global variables: $wpdb, $post, $wp_query, $pagenow, and dozens more. PHPStan does not automatically know about these. The WordPress extension handles the most common ones, but you will occasionally need to declare them explicitly:

function my_custom_query(): array {
    global $wpdb;
    
    // Without the WordPress extension, PHPStan would flag $wpdb as undefined
    $results = $wpdb->get_results(
        $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_type = %s", 'product' )
    );
    
    return $results ?: array();
}

If you encounter errors about $wpdb being undefined or having type mixed, ensure your phpstan.neon includes the WordPress extension and that the stubs are properly loaded. Sometimes adding wordpress-stubs explicitly to scanFiles resolves stubborn type resolution failures.

Dynamic Class Instantiation and Factory Patterns

WordPress’s widget system, REST API controllers, and WP_CLI commands all use patterns where classes are instantiated dynamically. PHPStan struggles with these:

// PHPStan cannot infer the type from a variable class name
$widget_class = 'My_Custom_Widget';
$widget = new $widget_class(); // PHPStan sees this as object, not My_Custom_Widget

// Fix: use a type assertion or refactor
/** @var My_Custom_Widget $widget */
$widget = new $widget_class();

PHP_CodeSniffer with WordPress Coding Standards

While PHPStan focuses on type correctness, PHP_CodeSniffer (PHPCS) focuses on coding style: indentation, naming conventions, documentation requirements, and security patterns. The WordPress Coding Standards (WPCS) ruleset is the official standard for WordPress core, and using it in your projects ensures consistency with the broader WordPress ecosystem.

Installing PHPCS and WPCS

Install via Composer with the Composer Installer plugin that handles standard registration automatically:

composer require --dev squizlabs/php_codesniffer wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer

The dealerdirect/phpcodesniffer-composer-installer package automatically registers the WPCS standard with PHPCS. Without it, you would need to run phpcs --config-set installed_paths manually. Verify the installation:

vendor/bin/phpcs -i
# Output: The installed coding standards are PEAR, Zend, PSR2, MySource, Squiz, PSR1, PSR12,
# WordPress, WordPress-Core, WordPress-Docs, WordPress-Extra

Configuration with phpcs.xml.dist

Create a phpcs.xml.dist file in your project root. This file defines which standards to apply, which files to scan, and any rule exclusions or modifications:

<?xml version="1.0"?>
<ruleset name="My Plugin Coding Standards">
    <description>PHPCS configuration for My WordPress Plugin.</description>

    <!-- Scan these paths -->
    <file>./wp-content/plugins/my-plugin/</file>

    <!-- Exclude paths -->
    <exclude-pattern>*/vendor/*</exclude-pattern>
    <exclude-pattern>*/node_modules/*</exclude-pattern>
    <exclude-pattern>*/tests/*</exclude-pattern>
    <exclude-pattern>*/build/*</exclude-pattern>

    <!-- Use WordPress Extra standard (includes Core + Extra checks) -->
    <rule ref="WordPress-Extra">
        <!-- Allow short array syntax -->
        <exclude name="Universal.Arrays.DisallowShortArraySyntax"/>
    </rule>

    <!-- Use WordPress Docs standard for inline documentation -->
    <rule ref="WordPress-Docs"/>

    <!-- Check for PHP cross-version compatibility -->
    <config name="testVersion" value="7.4-"/>

    <!-- Set minimum supported WordPress version -->
    <config name="minimum_wp_version" value="6.0"/>

    <!-- Text domain for i18n checks -->
    <rule ref="WordPress.WP.I18n">
        <properties>
            <property name="text_domain" type="array">
                <element value="my-plugin"/>
            </property>
        </properties>
    </rule>

    <!-- Prefixes for function/class names -->
    <rule ref="WordPress.NamingConventions.PrefixAllGlobals">
        <properties>
            <property name="prefixes" type="array">
                <element value="my_plugin"/>
                <element value="MY_PLUGIN"/>
            </property>
        </properties>
    </rule>

    <!-- Show sniff codes in all reports -->
    <arg value="sp"/>
    <arg name="colors"/>
    <arg name="extensions" value="php"/>
    <arg name="parallel" value="8"/>
</ruleset>

Key WPCS Rules You Should Know

WordPress.Security.EscapeOutput: Requires all output to be escaped. This is the single most important security rule. Every echo, print, or inline PHP output must use an escaping function like esc_html(), esc_attr(), esc_url(), or wp_kses().

// PHPCS error: All output should be run through an escaping function
echo $user_input;

// Correct
echo esc_html( $user_input );

// Also correct for URLs
echo esc_url( $redirect_url );

// For HTML that should preserve certain tags
echo wp_kses_post( $content );

WordPress.Security.NonceVerification: Requires nonce verification before processing form data from $_POST, $_GET, or $_REQUEST. This catches CSRF vulnerabilities:

// PHPCS error: Processing form data without nonce verification
if ( isset( $_POST['action'] ) ) {
    update_option( 'my_option', sanitize_text_field( wp_unslash( $_POST['value'] ) ) );
}

// Correct
if ( isset( $_POST['action'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'my_action' ) ) {
    update_option( 'my_option', sanitize_text_field( wp_unslash( $_POST['value'] ) ) );
}

WordPress.DB.PreparedSQL: Requires all database queries to use $wpdb->prepare() for any query containing variable data. This prevents SQL injection:

// PHPCS error: Use placeholders and $wpdb->prepare()
$results = $wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE post_author = {$user_id}" );

// Correct
$results = $wpdb->get_results(
    $wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_author = %d", $user_id )
);

WordPress.NamingConventions.PrefixAllGlobals: Ensures all global functions, classes, and variables use your plugin’s prefix. This prevents naming collisions in the global WordPress namespace. Configure your prefix in the phpcs.xml.dist as shown above.

Running PHPCS

With the configuration file in place, run PHPCS:

# Run with default config
vendor/bin/phpcs

# Run on specific file
vendor/bin/phpcs wp-content/plugins/my-plugin/includes/class-api.php

# Auto-fix what can be fixed
vendor/bin/phpcbf

PHPCS ships with PHPCBF (PHP Code Beautifier and Fixer) which can automatically fix many style issues: whitespace, brace placement, array formatting, and some documentation patterns. Run PHPCBF first to reduce your error count before manually fixing the remaining issues.

Combining PHPStan and PHPCS in CI

Running both tools locally is useful, but the real power comes from enforcing them in continuous integration. A CI pipeline ensures that no code reaches your main branch without passing both type checks and coding standards. This section covers the integration strategy and practical configuration.

The Two-Gate Approach

PHPStan and PHPCS serve different purposes and should run as separate CI jobs. PHPStan catches logical errors and type mismatches. PHPCS catches style violations and security anti-patterns. Keeping them separate provides clearer feedback: if PHPStan fails, the developer knows they have a type-level issue. If PHPCS fails, they know it is a style or security pattern issue.

Define both checks in a single CI workflow file but as separate jobs that run in parallel. This reduces total pipeline time since both tools can analyze the codebase simultaneously.

Composer Script Shortcuts

Add scripts to your composer.json for consistent command invocation across local development and CI:

{
    "scripts": {
        "phpstan": "phpstan analyse --memory-limit=512M",
        "phpcs": "phpcs",
        "phpcbf": "phpcbf",
        "lint": [
            "@phpstan",
            "@phpcs"
        ]
    }
}

Now both local developers and CI can run composer lint to execute both tools. Consistency matters: if the commands differ between local and CI, false negatives creep in.

GitHub Actions Configuration for Code Quality Gates

GitHub Actions is the most common CI platform for WordPress projects on GitHub. The following workflow file runs PHPStan and PHPCS on every push and pull request, with caching to speed up subsequent runs.

name: Code Quality

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  phpstan:
    name: PHPStan Static Analysis
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        php-version: ['7.4', '8.0', '8.1', '8.2']
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php-version }}
          tools: composer:v2
          coverage: none

      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

      - name: Cache Composer dependencies
        uses: actions/cache@v3
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Cache PHPStan result cache
        uses: actions/cache@v3
        with:
          path: /tmp/phpstan
          key: phpstan-${{ matrix.php-version }}-${{ github.sha }}
          restore-keys: phpstan-${{ matrix.php-version }}-

      - name: Run PHPStan
        run: composer phpstan -- --error-format=github

  phpcs:
    name: WordPress Coding Standards
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          tools: composer:v2, cs2pr
          coverage: none

      - name: Get Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

      - name: Cache Composer dependencies
        uses: actions/cache@v3
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Run PHPCS
        run: vendor/bin/phpcs --report-full --report-checkstyle=./phpcs-report.xml

      - name: Annotate PR with PHPCS results
        if: always()
        run: cs2pr ./phpcs-report.xml

Several details in this configuration deserve attention. The PHPStan job uses a matrix strategy to test across multiple PHP versions. This catches type issues that only appear in specific PHP versions, which is common when your plugin supports a range of PHP versions. The --error-format=github flag makes PHPStan output errors as GitHub annotations, so they appear directly on the pull request diff.

The PHPCS job uses cs2pr (Code Sniffer to Pull Request), a tool that converts PHPCS checkstyle XML output into GitHub pull request annotations. This means PHPCS violations appear inline on the changed files, making review faster.

Both jobs cache Composer dependencies aggressively. The PHPStan job also caches its result cache, which means subsequent runs on the same branch analyze only changed files rather than the entire codebase. This can reduce PHPStan runtime from minutes to seconds.

Branch Protection Rules

After setting up the workflow, configure branch protection rules in your GitHub repository settings. Require the phpstan and phpcs jobs to pass before merging pull requests. This creates an enforceable quality gate that cannot be bypassed by individual developers.

Go to Settings > Branches > Branch protection rules > Add rule. Select your main branch, check “Require status checks to pass before merging,” and add both job names as required checks. With this in place, no one can merge code that fails static analysis or coding standards, regardless of their repository permissions.

Pre-Commit Hooks with GrumPHP

CI catches issues after a push, but pre-commit hooks catch them before the commit is even created. GrumPHP is a PHP tool that registers git hooks and runs configurable tasks before each commit. It integrates with both PHPStan and PHPCS out of the box.

Installing GrumPHP

composer require --dev phpro/grumphp

GrumPHP automatically registers a git pre-commit hook during installation. Every time you run git commit, GrumPHP intercepts the commit, runs its configured tasks, and blocks the commit if any task fails.

Configuration with grumphp.yml

Create a grumphp.yml file in your project root:

grumphp:
    hooks_dir: ~
    hooks_preset: local
    stop_on_failure: false
    ignore_unstaged_changes: true
    process_timeout: 120
    
    tasks:
        phpstan:
            configuration: phpstan.neon
            memory_limit: 512M
            use_grumphp_paths: false
            
        phpcs:
            standard: phpcs.xml.dist
            ignore_patterns:
                - vendor/
                - node_modules/
            triggered_by:
                - php
                
        phplint:
            triggered_by:
                - php
                
        composer:
            no_check_publish: true
            no_check_lock: false

Key settings to note: ignore_unstaged_changes: true means GrumPHP only checks the files that are staged for commit, not your entire working directory. This prevents false positives from work-in-progress files. stop_on_failure: false means all tasks run even if one fails, giving you the complete picture of what needs fixing rather than fixing one tool’s issues only to discover the other tool has complaints too.

The phplint task runs a quick syntax check on all PHP files. This is a fast sanity check that catches parse errors before the heavier PHPStan analysis runs. The composer task validates that your composer.json and composer.lock are in sync, preventing the “works on my machine” problem caused by dependency drift.

Performance Optimization for Pre-Commit

Running full PHPStan analysis on every commit can be slow for large codebases. Two strategies help:

First, use PHPStan’s result cache. PHPStan stores analysis results in a cache directory (configurable via tmpDir in phpstan.neon). On subsequent runs, it only re-analyzes files that have changed. This reduces pre-commit analysis time from 30+ seconds to 2-3 seconds for typical commits.

# In phpstan.neon
parameters:
    tmpDir: .phpstan-cache

Add .phpstan-cache/ to your .gitignore file. This cache is local to each developer’s machine and should not be committed.

Second, configure GrumPHP to use use_grumphp_paths: true for PHPCS (it uses only staged files by default) but use_grumphp_paths: false for PHPStan. PHPStan needs to analyze the full project to resolve cross-file dependencies, but its result cache makes this fast. PHPCS can safely run on only the changed files since its rules are file-scoped.

Handling Pre-Commit Hook Failures Gracefully

Developers occasionally need to bypass pre-commit hooks for legitimate reasons: committing a work-in-progress to switch branches, or committing a known-broken state for a colleague to debug. Git supports this with the --no-verify flag:

git commit --no-verify -m "WIP: save state before branch switch"

This bypasses GrumPHP entirely. The CI pipeline still catches any issues, so the quality gate remains intact. Teams should agree on conventions for when --no-verify is acceptable. A common approach: it is fine for WIP commits on feature branches, never acceptable for commits directly to main or develop.

Handling Legacy Codebases: Baseline Files and Gradual Adoption

Most WordPress projects are not greenfield. You are more likely inheriting a plugin with 10,000 lines of PHP that has never seen a static analysis tool. Running PHPStan level 5 on such a codebase might produce 500+ errors. Fixing all of them before adopting the tool is impractical. This is where baseline files become essential.

PHPStan Baseline

A baseline file records all existing errors and tells PHPStan to ignore them. New code must pass, but existing violations get a pass until you fix them. Generate a baseline:

vendor/bin/phpstan analyse --generate-baseline --memory-limit=512M

This creates a phpstan-baseline.neon file containing every current error. Include it in your main configuration:

includes:
    - vendor/szepeviktor/phpstan-wordpress/extension.neon
    - phpstan-baseline.neon

parameters:
    level: 5
    paths:
        - wp-content/plugins/my-plugin/

Now vendor/bin/phpstan analyse returns zero errors. Any new code that introduces a type error will be caught, but the 500 existing errors are silently ignored. Commit the baseline file to version control so all developers and CI share the same baseline.

Reducing the Baseline Over Time

The baseline is not a permanent amnesty. Treat it as technical debt to be paid down. Strategies for reducing it:

Opportunistic fixing: When you modify a file for a feature or bug fix, also fix the PHPStan baseline errors in that file. Regenerate the baseline after fixing. Over months, the baseline shrinks naturally as files are touched during normal development.

Dedicated sprints: Schedule periodic “quality sprints” where the team focuses exclusively on reducing the baseline. Track the error count over time in a spreadsheet or dashboard. Seeing the number drop from 500 to 300 to 150 provides tangible motivation.

Ratchet mechanism: Add a CI check that verifies the baseline error count never increases. The PHPStan team provides a tool for this, or you can script it yourself:

#!/bin/bash
# count-baseline-errors.sh
CURRENT_COUNT=$(grep -c "message:" phpstan-baseline.neon)
MAX_ALLOWED=350  # Update this number downward after each sprint

if [ "$CURRENT_COUNT" -gt "$MAX_ALLOWED" ]; then
    echo "Baseline error count ($CURRENT_COUNT) exceeds maximum ($MAX_ALLOWED)"
    echo "Fix errors and regenerate the baseline."
    exit 1
fi

echo "Baseline error count: $CURRENT_COUNT (max: $MAX_ALLOWED)"

PHPCS Baseline with phpcs.xml

PHPCS does not have a native baseline feature like PHPStan, but you can achieve similar results. The simplest approach is to exclude specific files or directories from specific rules:

<rule ref="WordPress.Security.EscapeOutput">
    <exclude-pattern>*/legacy-templates/*</exclude-pattern>
</rule>

For more granular control, use inline comments to suppress specific warnings in legacy code:

// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Legacy template, scheduled for refactor in Q2
echo $legacy_output;

Always include a reason after the --. This documents why the suppression exists and when it should be removed. A suppression without a reason is technical debt that no one will ever address because no one remembers why it was added.

A more structured approach uses the diff filter built into PHPCS. You can configure PHPCS to only check lines that have changed compared to a base branch:

vendor/bin/phpcs --filter=gitmodified

This flag tells PHPCS to only report violations on lines modified in the current git working tree. It is not a true baseline, but it achieves the same practical effect: new code must comply, existing code gets a pass. Combine this with the full scan in CI (which reports but does not block on legacy violations) for best results.

Custom PHPStan Rules for Project-Specific Patterns

PHPStan’s extensibility is one of its greatest strengths. Beyond the built-in rules and the WordPress extension, you can write custom rules that enforce patterns specific to your project or organization. This turns PHPStan from a generic tool into a project-specific quality enforcer.

When Custom Rules Make Sense

Custom rules are worth the effort when you have a pattern that causes recurring bugs and can be detected through code structure. Examples from real WordPress projects:

Ensuring all REST API endpoints call permission_callback (WordPress 5.5+ requirement). Preventing direct database queries outside of a repository class. Requiring all admin notices to use the correct CSS classes. Ensuring all wp_remote_get() calls handle WP_Error returns. Preventing wp_die() in non-AJAX contexts.

Writing a Custom Rule

PHPStan rules implement the PHPStan\Rules\Rule interface. Here is a practical example: a rule that ensures every wp_remote_get() or wp_remote_post() call is followed by a is_wp_error() check.

<?php

declare(strict_types=1);

namespace MyPlugin\PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * Ensures wp_remote_get/post return values are checked with is_wp_error().
 *
 * @implements Rule<FuncCall>
 */
class WpRemoteErrorCheckRule implements Rule
{
    public function getNodeType(): string
    {
        return FuncCall::class;
    }

    /**
     * @param FuncCall $node
     * @return array<int, \PHPStan\Rules\RuleError>
     */
    public function processNode( Node $node, Scope $scope ): array
    {
        if ( ! $node->name instanceof \PhpParser\Node\Name ) {
            return [];
        }

        $function_name = $node->name->toString();
        $target_functions = [ 'wp_remote_get', 'wp_remote_post', 'wp_remote_request', 'wp_remote_head' ];

        if ( ! in_array( $function_name, $target_functions, true ) ) {
            return [];
        }

        // Check if the result is assigned to a variable
        $parent = $node->getAttribute( 'parent' );
        if ( ! $parent instanceof \PhpParser\Node\Expr\Assign ) {
            return [
                RuleErrorBuilder::message(
                    sprintf(
                        'Return value of %s() must be assigned to a variable and checked with is_wp_error().',
                        $function_name
                    )
                )->build(),
            ];
        }

        return [];
    }
}

This rule checks only that the return value is assigned. A more thorough version would use PHPStan’s control flow analysis to verify that an is_wp_error() check exists in the same scope. That level of sophistication requires working with PHPStan’s internal AST traversal, which is well-documented in the PHPStan documentation under “Custom Rules.”

Registering Custom Rules

Register your custom rule in phpstan.neon:

services:
    -
        class: MyPlugin\PHPStan\Rules\WpRemoteErrorCheckRule
        tags:
            - phpstan.rules.rule

rules:
    - MyPlugin\PHPStan\Rules\WpRemoteErrorCheckRule

Ensure the class is autoloadable via Composer. Add the namespace to your composer.json autoload-dev section:

{
    "autoload-dev": {
        "psr-4": {
            "MyPlugin\\PHPStan\\": "phpstan/"
        }
    }
}

Custom Type Extensions for WordPress Filters

One of the most powerful customizations is writing a dynamic return type extension for your own apply_filters() calls. If your plugin uses a filter where you know the expected return type, you can teach PHPStan about it:

<?php

declare(strict_types=1);

namespace MyPlugin\PHPStan\Extensions;

use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\Type;
use PHPStan\Type\StringType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ArrayType;
use PHPStan\Type\MixedType;
use PHPStan\Reflection\FunctionReflection;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;

class ApplyFiltersReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{
    /** @var array<string, Type> */
    private array $filter_types;

    public function __construct()
    {
        $this->filter_types = [
            'my_plugin_title'        => new StringType(),
            'my_plugin_item_count'   => new IntegerType(),
            'my_plugin_config'       => new ArrayType( new StringType(), new MixedType() ),
        ];
    }

    public function isFunctionSupported( FunctionReflection $function_reflection ): bool
    {
        return $function_reflection->getName() === 'apply_filters';
    }

    public function getTypeFromFunctionCall(
        FunctionReflection $function_reflection,
        FuncCall $function_call,
        Scope $scope
    ): ?Type {
        $args = $function_call->getArgs();
        if ( count( $args ) < 1 ) {
            return null;
        }

        $filter_name_type = $scope->getType( $args[0]->value );
        if ( ! $filter_name_type instanceof \PHPStan\Type\Constant\ConstantStringType ) {
            return null;
        }

        $filter_name = $filter_name_type->getValue();
        return $this->filter_types[ $filter_name ] ?? null;
    }
}

Register it in phpstan.neon:

services:
    -
        class: MyPlugin\PHPStan\Extensions\ApplyFiltersReturnTypeExtension
        tags:
            - phpstan.broker.dynamicFunctionReturnTypeExtension

Now PHPStan knows that apply_filters( 'my_plugin_title', $default ) returns a string, and will report errors if you treat it as an integer. This eliminates the need for inline @var assertions throughout your codebase.

IDE Integration: PhpStorm and VS Code

Static analysis provides the most value when feedback is immediate. Waiting for CI to report errors adds minutes to your feedback loop. Waiting for a pre-commit hook adds seconds. But seeing the error as a red underline while you type? That is instant feedback, and it changes how you write code.

PhpStorm Integration

PhpStorm has built-in support for both PHPStan and PHPCS. Configuration takes a few steps but the results are worth it.

PHPStan in PhpStorm:

Go to Settings > PHP > Quality Tools > PHPStan. Set the PHPStan path to your project’s vendor/bin/phpstan. PhpStorm will detect your phpstan.neon configuration file automatically. Enable “PHPStan validation” under Settings > Editor > Inspections > PHP > Quality Tools. Set the severity to “Error” so PHPStan violations appear with the same visual weight as syntax errors.

PhpStorm runs PHPStan incrementally on files you edit, showing results in real-time. The analysis runs in the background and results appear within a second or two of saving the file. This is faster than the command-line tool because PhpStorm caches the project’s type map and only re-analyzes the changed file.

PHPCS in PhpStorm:

Go to Settings > PHP > Quality Tools > PHP_CodeSniffer. Set the PHPCS path to vendor/bin/phpcs. Under Settings > Editor > Inspections > PHP > Quality Tools > PHP_CodeSniffer validation, enable the inspection and set the coding standard to “Custom” pointing to your phpcs.xml.dist file.

PhpStorm will now show PHPCS violations as you type. Missing escaping functions appear as warnings. Unprepared SQL queries appear as errors. Nonce verification issues appear immediately when you write form handling code. This instant feedback trains developers to write compliant code from the start, rather than fixing violations after the fact.

Tip for WordPress development in PhpStorm: Install the “WordPress” PhpStorm plugin (available in the JetBrains marketplace). It adds autocompletion for WordPress hooks, template tags, and functions. Combined with PHPStan and PHPCS inspections, PhpStorm becomes aware of both WordPress’s API and your project’s quality requirements simultaneously.

VS Code Integration

VS Code does not have built-in PHPStan or PHPCS support, but excellent extensions provide equivalent functionality.

PHPStan in VS Code:

Install the swordev.phpstan extension (PHPStan by SanderRonde) or the phpstan.phpstan-vscode official extension. Configure it in your .vscode/settings.json:

{
    "phpstan.paths.phpstan": "./vendor/bin/phpstan",
    "phpstan.configFile": "phpstan.neon",
    "phpstan.enabled": true,
    "phpstan.memoryLimit": "512M",
    "phpstan.projectRoot": "${workspaceFolder}"
}

The extension runs PHPStan on file save and shows errors inline. It uses PHPStan’s result cache for fast incremental analysis.

PHPCS in VS Code:

Install the shevaua.phpcs extension or wongjn.php-sniffer. Configure it:

{
    "phpcs.executablePath": "./vendor/bin/phpcs",
    "phpcs.standard": "./phpcs.xml.dist",
    "phpcs.autoConfigSearch": true,
    "phpcbf.executablePath": "./vendor/bin/phpcbf",
    "phpcbf.onsave": false
}

Set phpcbf.onsave to true if you want VS Code to auto-fix style issues when you save. Some developers love this; others find it disruptive. Try it for a week before deciding.

Combined experience in VS Code:

With both extensions active, VS Code shows two categories of squiggly lines: PHPStan errors (type issues, logic errors) and PHPCS warnings (style violations, security patterns). The Problems panel aggregates both, sortable by severity. This gives you a unified view of all code quality issues in the current file.

For a smoother experience, add these complementary VS Code extensions: bmewburn.vscode-intelephense-client (Intelephense for PHP language support), wordpresstoolbox.wordpress-toolbox (WordPress hooks and functions), and valeryanm.vscode-phpsab (PHP Sniffer and Beautifier). Together, they provide autocomplete, jump-to-definition, inline documentation, and automatic formatting for WordPress PHP development.

Advanced Configuration Patterns

Once you have the basic setup working, several advanced patterns improve the developer experience and catch additional categories of bugs.

PHPStan Strict Rules Extension

The phpstan/phpstan-strict-rules package adds additional checks that are not part of the standard level progression:

composer require --dev phpstan/phpstan-strict-rules
includes:
    - vendor/szepeviktor/phpstan-wordpress/extension.neon
    - vendor/phpstan/phpstan-strict-rules/rules.neon

Strict rules enforce patterns like: no empty() usage (because empty() accepts undefined variables without warning), no loose comparisons (==), no dynamic properties, and no overwriting variables with different types. These rules are opinionated, and you may want to disable specific ones. The configuration supports selective exclusion:

parameters:
    strictRules:
        disallowedLooseComparison: true
        booleansInConditions: false  # WordPress uses truthy/falsy patterns extensively
        uselessCast: true
        requireParentConstructorCall: true
        disallowedConstructs: true
        overwriteVariablesWithLoop: true
        closureUsesThis: true
        matchingInheritedMethodNames: true
        numericOperandsInArithmeticOperators: true
        strictCalls: true
        switchConditionsMatchingType: true
        noVariableVariables: true

Disabling booleansInConditions is recommended for WordPress projects because WordPress core extensively uses truthy/falsy patterns like if ( $post ) instead of if ( $post !== null ). Fighting this convention across an entire WordPress codebase is not productive.

PHPStan for WordPress Themes

Themes present unique challenges because template files use variables set by WordPress’s template loading system. The $post, $wp_query, and other globals are implicitly available in template files. Configure PHPStan to recognize these:

parameters:
    level: 5
    paths:
        - wp-content/themes/my-theme/
    excludePaths:
        - wp-content/themes/my-theme/node_modules/
        - wp-content/themes/my-theme/vendor/
    scanDirectories:
        - wp-content/themes/my-theme/
    bootstrapFiles:
        - phpstan-bootstrap.php

Create a phpstan-bootstrap.php file that declares the global variables used in templates:

<?php
/**
 * PHPStan bootstrap file for theme analysis.
 * Declares global variables available in WordPress templates.
 */

/** @var WP_Post $post */
global $post;

/** @var WP_Query $wp_query */
global $wp_query;

/** @var int $content_width */
global $content_width;

Multi-Plugin Monorepo Configuration

If your project contains multiple plugins or a theme plus plugins, use PHPStan’s multi-path configuration and PHPCS’s multiple file paths:

# phpstan.neon for monorepo
parameters:
    level: 5
    paths:
        - wp-content/plugins/plugin-a/
        - wp-content/plugins/plugin-b/
        - wp-content/themes/my-theme/
    excludePaths:
        - */vendor/*
        - */node_modules/*
        - */tests/*
    scanDirectories:
        - wp-content/plugins/plugin-a/
        - wp-content/plugins/plugin-b/
        - wp-content/themes/my-theme/

For PHPCS, list multiple paths in your phpcs.xml.dist:

<file>./wp-content/plugins/plugin-a/</file>
<file>./wp-content/plugins/plugin-b/</file>
<file>./wp-content/themes/my-theme/</file>

Each plugin can also have its own phpstan.neon and phpcs.xml.dist with plugin-specific overrides (different text domains, different prefixes). The root-level configuration handles shared settings and the plugin-level configurations handle plugin-specific ones.

Integrating Static Analysis Into an Existing Team Workflow

Technical setup is half the battle. The other half is getting your team to actually use these tools and value their output. Here is a practical adoption strategy based on what works in real WordPress development teams.

Week 1: Silent Introduction

Add PHPStan at level 0 and PHPCS to the project. Generate baselines. Configure CI to run both tools but set them as non-blocking (warnings, not failures). This gives the team visibility into the error counts without blocking anyone’s work. Share the initial error counts in a team channel: “We currently have 347 PHPStan errors and 892 PHPCS violations. Our goal is to prevent new ones.”

Week 2: IDE Setup Session

Hold a 30-minute session where everyone configures their IDE with PHPStan and PHPCS extensions. Walk through the output together. Show how a missing esc_html() appears as a warning, how a type mismatch appears as an error. Let developers see the tools in action on familiar code. This builds understanding and buy-in.

Week 3: CI Enforcement

Switch CI from warning to blocking. With baselines in place, this blocks only new violations. No existing code is affected. Add branch protection rules requiring both checks to pass. Communicate clearly: “Starting Monday, PRs must pass PHPStan and PHPCS. Existing issues are baselined. Only new code is checked.”

Week 4: Pre-Commit Hooks

Install GrumPHP. This is optional but recommended. Some teams prefer to let CI handle enforcement and keep local development friction-free. Others want the faster feedback loop of pre-commit hooks. Let the team decide. Either way, the CI gate is the authoritative check.

Ongoing: Baseline Reduction

Set a quarterly target for reducing baseline errors. Track progress visually. Celebrate milestones. “We dropped from 347 PHPStan errors to 180 this quarter” is a concrete, motivating metric. As the team internalizes the patterns, new errors become rare and the baseline shrinks steadily.

Troubleshooting Common Issues

Every WordPress project encounters specific friction points when adopting static analysis. Here are the most common issues and their solutions.

PHPStan: “Class WordPress\SomeClass not found”

This usually means the WordPress stubs are not loaded. Verify that szepeviktor/phpstan-wordpress is installed and that your phpstan.neon includes the extension file. If you are analyzing a theme or plugin in isolation (without WordPress core in the project), you may need to add php-stubs/wordpress-stubs explicitly.

PHPStan: Memory Exhaustion

WordPress stubs define thousands of functions. For large codebases, 512MB is a reasonable starting point. Some projects need 1GB. Set this in phpstan.neon to avoid passing it on every command:

parameters:
    memoryLimit: 1G

PHPCS: “Referenced sniff ‘WordPress.X.Y’ does not exist”

This occurs when your WPCS version is outdated or the Composer installer did not register the standards correctly. Run:

composer update wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
vendor/bin/phpcs -i

Verify that WordPress standards appear in the output. If they do not, manually set the installed paths:

vendor/bin/phpcs --config-set installed_paths vendor/wp-coding-standards/wpcs

GrumPHP: Hook Not Running

If GrumPHP’s pre-commit hook does not trigger, it may not have been installed. Run:

vendor/bin/grumphp git:init

This registers the hook. Also verify your .git/hooks/pre-commit file exists and references GrumPHP. If you are using a git GUI client, ensure it respects git hooks (some GUI clients skip hooks by default).

PHPCS: Too Many Errors on First Run

Run PHPCBF first to auto-fix mechanical issues:

vendor/bin/phpcbf
# Then run PHPCS to see remaining manual fixes
vendor/bin/phpcs

PHPCBF typically fixes 40-60% of violations automatically. The remaining ones require manual attention: missing documentation blocks, security patterns, naming conventions. Tackle them file by file, not all at once.

Measuring the Impact of Static Analysis

Beyond error counts, track these metrics to demonstrate the value of your static analysis pipeline to stakeholders who care about business outcomes rather than developer tooling.

Bug escape rate: Track the number of type-related bugs that reach production per month. Before static analysis, you might see 5-10 type-related issues per month (wrong return types, null pointer exceptions, missing parameter validation). After PHPStan level 5, this typically drops to 0-2.

Security audit findings: If your plugin undergoes periodic security audits, track the number of findings related to escaping, nonce verification, and SQL injection. PHPCS with WPCS catches these categories automatically. Teams that adopt WPCS typically see security audit findings drop by 60-80% for these specific categories.

Code review time: With PHPStan and PHPCS enforced in CI, code reviewers spend less time on mechanical issues (type errors, style violations) and more time on architecture, logic, and design decisions. Track the average time from PR creation to merge. Teams typically see a 20-30% reduction after the first month of enforcement.

Developer onboarding: New developers on a project with static analysis receive instant feedback on WordPress conventions. They learn faster because PHPStan and PHPCS act as automated mentors. Track the time from a new developer’s first PR to their first unassisted merge. This metric improves measurably with static analysis in place.

Putting It All Together: A Complete Configuration Reference

Here is a consolidated view of all the files needed for a full static analysis setup in a WordPress plugin project. Use this as a starting template and customize to your needs.

composer.json (dev dependencies section):

{
    "require-dev": {
        "phpstan/phpstan": "^1.10",
        "phpstan/phpstan-strict-rules": "^1.5",
        "szepeviktor/phpstan-wordpress": "^1.3",
        "php-stubs/wordpress-stubs": "^6.4",
        "squizlabs/php_codesniffer": "^3.8",
        "wp-coding-standards/wpcs": "^3.0",
        "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
        "phpro/grumphp": "^2.0"
    },
    "scripts": {
        "phpstan": "phpstan analyse --memory-limit=512M",
        "phpstan:baseline": "phpstan analyse --generate-baseline --memory-limit=512M",
        "phpcs": "phpcs",
        "phpcbf": "phpcbf",
        "lint": ["@phpstan", "@phpcs"]
    }
}

phpstan.neon:

includes:
    - vendor/szepeviktor/phpstan-wordpress/extension.neon
    - vendor/phpstan/phpstan-strict-rules/rules.neon
    - phpstan-baseline.neon

parameters:
    level: 5
    paths:
        - src/
        - includes/
    excludePaths:
        - vendor/
        - node_modules/
        - tests/
    bootstrapFiles:
        - vendor/autoload.php
    treatPhpDocTypesAsCertain: false
    tmpDir: .phpstan-cache
    strictRules:
        booleansInConditions: false

.gitignore additions:

.phpstan-cache/
phpcs-report.xml

This setup gives you type-safe WordPress development with automated enforcement at every stage: IDE, pre-commit, and CI. The baseline allows gradual adoption, the strict rules catch subtle issues, and the CI pipeline prevents regression. Start with level 0, work your way up, and watch your bug count drop.

Static analysis is not a silver bullet. It will not catch business logic errors, race conditions, or performance issues. But it eliminates entire categories of defects that have plagued PHP and WordPress development for years. The investment pays for itself within weeks, and the compound benefit grows with every line of code you write under its protection.

The tools are mature. The WordPress integrations are solid. The only remaining step is to run composer require --dev phpstan/phpstan szepeviktor/phpstan-wordpress and see what your codebase has been hiding.

Share this article

Priya Sharma

Frontend engineer specializing in Gutenberg block development and modern JavaScript in WordPress. Advocates for testing and code quality in the WordPress ecosystem.