Add Static Analysis with PHPStan to Your WordPress Plugin Development Workflow

PHPStan is a static analysis tool that reads PHP source code and detects bugs, type errors, and undefined variables without executing the code — it works by inferring types from docblocks, native PHP 8 type declarations, return type annotations, and function signatures, then checking that every call site, assignment, and return statement is consistent with those types. Running PHPStan on a WordPress plugin catches an entire class of runtime errors before code reaches staging or production: calling a method on a value that could be null or WP_Error, passing an int where a function expects a string, accessing an array key that may not exist, and using WordPress functions with incorrect argument types. PHPStan operates on “levels” 0–9 — level 0 catches obvious errors (calling methods on mixed types, undefined variables), level 8 enforces strict nullable type handling and reports all possible null dereferences. Starting at level 3–4 is recommended for existing WordPress codebases; new projects can start at level 6+. The WordPress-specific szepeviktor/phpstan-wordpress extension (also known as the PHPStan WordPress package) provides type stubs for all WordPress functions, hooks, and objects — without it, PHPStan has no information about the return types of get_post(), get_option(), or WP_Query properties, generating hundreds of false positives. The stubs declare, for example, that get_post() returns WP_Post|null, that get_option() returns mixed, and that $wpdb->get_results() returns array<object>|null — all of which are then type-checked at call sites. PHPStan integrates with CI/CD pipelines (GitHub Actions, GitLab CI) to block merges when the analysis detects errors, making type-checking a mandatory gate like unit tests. The PHPUnit testing post covers testing behavior at runtime; PHPStan catches type and logic errors statically before tests are even run.

Problem: A WordPress plugin has a recurring production bug where get_post() returns null in certain edge cases (post ID from a URL parameter that was deleted) and the code dereferences it without null checks — causing a fatal error. The bug is intermittent and difficult to reproduce in testing because it depends on the post existing in the database.

Solution: Add PHPStan with the WordPress type stubs to the project — running at level 5 it immediately flags all call sites where get_post(), get_term(), and similar nullable-return WordPress functions are used without null checks.

# Install PHPStan and WordPress type stubs via Composer
composer require --dev phpstan/phpstan
composer require --dev szepeviktor/phpstan-wordpress

# Optional: PHPStan extension installer (auto-discovers phpstan.neon in packages)
composer require --dev phpstan/extension-installer

# phpstan.neon — PHPStan configuration file in the plugin root
includes:
    - vendor/szepeviktor/phpstan-wordpress/extension.neon

parameters:
    level: 5
    paths:
        - src
        - includes
    # Bootstrap file that defines WordPress constants and loads stubs
    bootstrapFiles:
        - vendor/szepeviktor/phpstan-wordpress/bootstrap.php
    # Ignore patterns for generated or third-party files
    excludePaths:
        - vendor
        - node_modules
        - tests/fixtures
    # Treat WordPress global functions as available (reduces false positives)
    ignoreErrors:
        # Allow direct use of $wpdb global without injection
        - '#Variable \$wpdb might not be defined#'

// ── Before: PHPStan detects these as errors at level 5 ────────────────────

// ERROR: Method WP_Post::$post_title on possibly null WP_Post|null
function get_post_title( int $id ): string {
    $post = get_post( $id );
    return $post->post_title;  // PHPStan: Cannot access property on WP_Post|null
}

// ERROR: Function expects string, int|false given
function save_settings( array $data ): void {
    $value = array_search( 'active', $data );
    update_option( 'myplugin_setting', $value ); // $value is int|false, not string
}

// ── After: PHPStan-clean code with proper null and type handling ──────────

function get_post_title( int $id ): string {
    $post = get_post( $id );
    if ( null === $post ) {
        return '';  // handle null explicitly
    }
    return $post->post_title;
}

function save_settings( array $data ): void {
    $value = array_search( 'active', $data );
    if ( false === $value ) {
        return;  // key not found
    }
    update_option( 'myplugin_setting', (string) $value );
}

// ── PHPStan-aware docblock for hook callbacks (complex return types) ───────

/**
 * @param array $query_args
 * @return array
 */
function myplugin_filter_query( array $query_args ): array {
    if ( isset( $query_args['post_type'] ) && 'product' === $query_args['post_type'] ) {
        $query_args['meta_key'] = '_featured';
    }
    return $query_args;
}

NOTE: PHPStan’s WordPress stubs declare get_option() as returning mixed — this means every get_option() call site requires an explicit type cast or a PHPDoc annotation to use the value in a type-safe way, which generates many errors on first run. Use a baseline file (vendor/bin/phpstan analyse --generate-baseline) to record existing errors as accepted technical debt and suppress them, then enforce that no new errors are introduced. Fix the baselined errors incrementally over time rather than trying to make the entire codebase PHPStan-clean before getting value from the tool — even running PHPStan with a baseline catches all new errors in new code, which is the primary goal for improving codebase quality over time.