Automated PHP Code Upgrades with Rector for WordPress Plugins and Themes

Rector is a PHP code transformation tool that applies AST (Abstract Syntax Tree) rewrites to source code using configurable rule sets — where PHPStan finds type errors and reports them, Rector fixes them by rewriting the code. It operates on PHP files using the nikic/php-parser AST library, applies one or more transformation rules to each node in the syntax tree, and writes the modified files back to disk. The transformation output is deterministic — the same input always produces the same output — making it safe to run in CI pipelines and commit the results as reviewed pull requests. Rector ships with hundreds of built-in rules organized into sets: LevelSetList::UP_TO_PHP_81 applies all transformations to make code valid PHP 8.1 syntax (typed properties, readonly, enum, never return type, intersection types, fibers); SetList::CODE_QUALITY simplifies redundant ternaries, removes dead assignments, and replaces strpos() !== false with str_contains(); SetList::TYPE_DECLARATION infers property types from constructor assignments and adds void return types to methods that have no return statement. For WordPress plugin development, Rector’s most practical use cases are: (1) modernising legacy plugins still using PHP 5.6–era patterns (array syntax, string functions, ereg_* calls) for PHP 8.0+ compatibility; (2) adding typed properties and return types to plugin classes so PHPStan can analyse them at level 6+; (3) batch-renaming hook callbacks after a plugin API refactor. The rector.php configuration file at the project root specifies paths to process, PHP version targets, rule sets, individual rules, and skip patterns — it is committed to version control so every developer and CI run uses the same configuration. Rector’s --dry-run flag prints a coloured diff of all changes without writing files, enabling safe review before applying. The PHPStan static analysis post covered finding type errors; Rector closes the loop by automatically fixing the errors PHPStan reports.

Problem: A WordPress plugin written in 2017 uses PHP 5.6 syntax throughout: untyped properties, missing return types, array() syntax, strpos() !== false conditionals, and bare is_null() calls. PHPStan at level 5 reports 230 errors. Manually fixing all of them is estimated at 3–4 hours of mechanical changes with high risk of introducing typos.

Solution: Configure Rector with PHP 8.1 level sets and code-quality rules, run a --dry-run to review the diff, apply the changes, then re-run PHPStan to confirm the error count drops from 230 to the residual business-logic errors Rector cannot infer automatically.

// rector.php — configuration at the project root
// Install: composer require rector/rector --dev
// Run:     vendor/bin/rector process

use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;
use Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector;
use Rector\TypeDeclaration\Rector\Property\TypedPropertyFromAssignsRector;
use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector;

return RectorConfig::configure()
    // Paths Rector will scan (relative to project root)
    ->withPaths([
        __DIR__ . '/wp-content/plugins/my-plugin/src',
        __DIR__ . '/wp-content/themes/my-theme/inc',
    ])

    // Bootstrap WordPress so Rector can resolve WP function signatures
    ->withBootstrapFiles([
        __DIR__ . '/wp-load.php',
    ])

    // Upgrade to PHP 8.1 language features
    ->withPhpSets(php81: true)

    // Individual rulesets (add only what you intend to apply)
    ->withSets([
        SetList::CODE_QUALITY,      // simplify ternaries, remove dead code, etc.
        SetList::TYPE_DECLARATION,  // add typed properties and return types
    ])

    // Individual rules
    ->withRules([
        AddVoidReturnTypeWhereNoReturnRector::class,
        TypedPropertyFromAssignsRector::class,
    ])

    // Skip specific files, directories, or rule+path combinations
    ->withSkip([
        // Skip third-party vendor code
        __DIR__ . '/wp-content/plugins/my-plugin/vendor',

        // Skip a specific rule in generated/legacy files
        NullToStrictStringFuncCallArgRector::class => [
            __DIR__ . '/wp-content/plugins/my-plugin/src/Legacy',
        ],
    ])

    // Rector will process files but NOT write changes — use for dry-run review
    // ->withDryRun()

    // Show diff of each change in the output
    ->withImportNames(removeUnusedImports: true);

# Install Rector as a dev dependency
composer require rector/rector --dev

# Dry-run: preview all changes without writing files
vendor/bin/rector process --dry-run

# Apply all changes
vendor/bin/rector process

# Process only a single file during development
vendor/bin/rector process wp-content/plugins/my-plugin/src/MyClass.php

# Show which rules triggered changes (verbose)
vendor/bin/rector process --debug

# CI pipeline: fail if Rector would make changes (code must be already modernised)
vendor/bin/rector process --dry-run --no-progress-bar
# exit code 1 if changes needed, 0 if clean

# After applying Rector changes, re-run PHPStan to check remaining errors
vendor/bin/phpstan analyse wp-content/plugins/my-plugin/src --level=6

// ── Example: before and after Rector transforms ───────────────────────────────

// BEFORE (PHP 5.6 style)
class My_Widget extends WP_Widget {
    private $cache_key;
    private $timeout;

    public function __construct() {
        $this->cache_key = 'my_widget';
        $this->timeout   = 3600;
        parent::__construct( 'my_widget', 'My Widget', array() );
    }

    public function widget( $args, $instance ) {
        $title = apply_filters( 'widget_title', $instance['title'] );
        if ( strpos( $title, 'Hello' ) !== false ) {
            echo $args['before_widget'];
        }
        if ( is_null( $instance['count'] ) ) {
            return;
        }
    }
}

// AFTER (Rector applied php81, code_quality, type_declaration sets)
class My_Widget extends WP_Widget {
    private string $cache_key;  // typed property inferred from assignment
    private int    $timeout;

    public function __construct() {
        $this->cache_key = 'my_widget';
        $this->timeout   = 3600;
        parent::__construct( 'my_widget', 'My Widget', [] );  // array() -> []
    }

    public function widget( $args, $instance ): void {  // void return type added
        $title = apply_filters( 'widget_title', $instance['title'] );
        if ( str_contains( $title, 'Hello' ) ) {  // strpos !== false -> str_contains
            echo $args['before_widget'];
        }
        if ( $instance['count'] === null ) {  // is_null() -> === null
            return;
        }
    }
}

NOTE: Rector rewrites files in-place — always commit your code to Git before running Rector so you can review the diff with git diff and revert individual changes with git checkout if a transformation is incorrect. Some transformations require human review: Rector may infer a property type as string from an assignment in the constructor, but if another method later assigns null to it, the inferred type should be string|null — PHPStan at level 6 will catch this after Rector runs. The recommended workflow is: run Rector → run PHPStan → fix remaining PHPStan errors manually → commit. Rector is also available as a GitHub Action (rectorphp/rector-action) that can open a pull request with all automated upgrades applied, enabling code-review of modernisation changes through the normal PR process.