A staircase of pull requests climbs from PHP 7.4, 46 months past end of life, to PHP 8.4, with 26 months of security fixes. Each step raises withPhpLevel() in rector.php by one, starting at 0, and a CI bar runs vendor/bin/rector process --dry-run under every step.

Rector PHP migrations, one reviewed pull request at a time

Rector can move old PHP code onto a supported version without a rewrite. The work goes well when the target comes from the support calendar and every change is small enough to review.

What are Rector PHP migrations, and what does Rector change?#

Rector PHP migrations use Rector, an open-source command-line tool, to rewrite PHP source by rules so code moves from PHP 5.3 up to 8.5 in reviewable steps. In practice, Rector parses PHP into a syntax tree, applies upgrade rules to it and prints the changed files. The rectorphp/rector README, read on 2 October 2026, says Rector "now supports upgrades from PHP 5.3 to 8.5". It also covers major projects such as Symfony, PHPUnit and Doctrine.

Each rule is one small class. The Rector guide to how Rector works defines it as "1 single class that modifies 1 thing", for example a class name. Rules are grouped into sets, and sets are split into levels. As a result, you choose how many rules run at once.

So the answer to "what is Rector" is short. Rector is a refactoring tool you install with Composer, configure in one PHP file and run from vendor/bin/rector. Then it writes the change, and a person reviews it like any other pull request.

How many months of security support does each PHP 8.x target buy?#

Counted from 2 October 2026, PHP 8.2 has 2 months of security support left, PHP 8.4 has 26 and PHP 8.5 has 38. Those figures come from php.net's supported versions table, read that day, and PHP 8.3 sits between them at 14. In short, the target branch decides how long the upgraded code keeps getting security fixes, which is what the upgrade is paid for.

Show data table
Months of security support left from 2 October 2026, from The PHP Group's supported versions table, read 2 October 2026.
Item Value
PHP 8.2 2
PHP 8.3 14
PHP 8.4 26
PHP 8.5 38

PHP 8.4 buys 26 months of security support from 2 October 2026, against 2 for PHP 8.2.

Security support left Months of security support left from 2 October 2026, from The PHP Group's supported versions table, read 2 October 2026. The PHP Group (php.net), supported versions, accessed 2 October 2026

The gap matters because the loop is the same for both targets. Going to 8.4 instead of 8.2 only adds the 8.3 and 8.4 rule sets to the same process. However, the 8.2 result needs another upgrade within weeks, while the 8.4 result holds until the end of 2028.

Which PHP version should the upgrade target in October 2026?#

php.net supports each branch for two years of bug fixes and two more years of security fixes, so PHP 8.4 or 8.5 is the target that outlasts the work. Pick the newest branch your framework and hosting support, because PHP 8.2 leaves security support on 31 December 2026 and 8.3 a year later. "Each release branch of PHP is fully supported for two years from its initial stable release," says php.net. After that, a branch gets two more years of critical security fixes only.

Supported PHP branches, The PHP Group, supported versions page, read 2 October 2026. Source: The PHP Group (php.net), supported versions, accessed 2 October 2026

BranchInitial releaseActive support endsSecurity support ends
8.2202220242026
8.3202320252027
8.4202420262028
8.5202520272029

Therefore, for most teams in October 2026 the choice is 8.4 or 8.5. PHP 8.4 came out on 21 November 2024 and has active support until 31 December 2026. Meanwhile, PHP 8.5 buys one more year, but check that every Composer dependency allows it first. If your host or a key package stops at 8.3, take 8.3 now and plan the next step. For example, the PHP features guide lists what each version added, which helps when the team weighs 8.4 against 8.5.

How does Rector change PHP code without running it?#

Rector reads code and never runs it: it parses each file with php-parser, resolves types through PHPStan and rewrites only the nodes a rule matches. The Rector limitations page opens with "Rector reads code, it never runs it." Because of that, it can only change what static analysis can see.

The process has three phases. First, nikic/php-parser parses each file into nodes. Then each active rule checks the node types it cares about through getNodeTypes(), and calls refactor() on every match. Finally, Rector saves the file, or with --dry-run it stores a git-like diff instead.

How one Rector run changes a fileHow one Rector run changes a file: parse, resolve types, match rules, print or diff. From the Rector documentation, How Rector Works and Limitations, read 2 October 2026.Rector documentation, read 2 October 2026

Also, types come from PHPStan. In the limitations page's words, "Rector sees what PHPStan sees". So if a method call sits on a mixed value, Rector cannot rename it or add a return type to it. For that reason, the new project guide, read on 2 October 2026, asks for PHPStan level 3 to 4 without a baseline before the first run. Otherwise, a baseline hides unknown types from both tools.

Which Rector documentation pages should the upgrade follow, in order?#

Five pages of the Rector documentation cover the whole upgrade: install, levels, set lists, the CI job and the limits list. Following them in build order keeps your configuration on the current API, withPhpLevel() and withPhpSets(), instead of the older constants many tutorials still show:

  1. Documentation home: the install command, the first run and the --dry-run preview.
  2. Levels: withPhpLevel(0) and why a level adds one rule at a time.
  3. Set lists: withPhpSets(), which reads the target from composer.json once the level is complete.
  4. Run in CI: the dry-run job, the exit code and the cache directory.
  5. Limitations: the list of what stays manual.

What is the smallest rector.php that starts a PHP upgrade?#

The smallest working setup is one Composer command, a rector.php with withPaths and withPhpLevel(0), and a dry run before the real run. Before you start, note that the Rector docs say that Rector itself needs PHP 7.2 or newer, and it can work on PHP 5.x and 8.x code.

rector.php · php
<?php

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ])
    ->withPhpLevel(0);
bash
# 1. install Rector as a dev dependency
composer require rector/rector --dev

# 2. preview the diff, change nothing
vendor/bin/rector process --dry-run

# 3. apply the change
vendor/bin/rector process

If no rector.php exists, running vendor/bin/rector offers to generate one for you. Meanwhile, Rector reads the target from the php constraint in composer.json. The levels page says "Rector only adds rules up to the PHP version in your composer.json", so the level never runs ahead of your runtime. Finally, once the level covers every rule for that version, swap withPhpLevel() for withPhpSets() and keep it there.

How do you move a codebase one PHP level at a time?#

A codebase on PHP 7.4 has been past end of life for 46 months, so it crosses several version boundaries and each level adds one rule per reviewed pull request. So the loop is simple: raise withPhpLevel() by one, run the dry run, review the small diff, merge, and repeat. The levels page describes a level as "the first N rules of a set, sorted from the safest to the most complex". Therefore, level 0 runs one rule, level 1 runs two, and so on.

The reason is review. For instance, that same Rector new project guide, read on 2 October 2026, shows a codebase with "php": "^7.4" in composer.json. A plain withPhpSets() there would apply every set from PHP 5.3 to 7.4 at once, which the guide counts as "over 100 rules". A diff that size is hard to review well.

Here is a worked example. Say a codebase runs PHP 7.4, which reached end of life on 28 November 2022 by php.net's unsupported branches page. Since then, by 2 October 2026, it has gone 46 months without security fixes.

Show data table
Months since end of life on 2 October 2026, from The PHP Group's unsupported branches page, read 2 October 2026.
Item Value
PHP 7.2 70
PHP 7.3 57
PHP 7.4 46
PHP 8.0 34
PHP 8.1 9

PHP 7.4 has gone 46 months without security fixes, and even PHP 8.1 is 9 months past end of life.

Months past end of life Months since end of life on 2 October 2026, from The PHP Group's unsupported branches page, read 2 October 2026. The PHP Group (php.net), unsupported branches, accessed 2 October 2026

The team picks PHP 8.4 as the target, so the code moves through the 8.0, 8.1, 8.2, 8.3 and 8.4 rules. Then each pull request raises the level by one. When one rule touches too many files, run it alone with --only="RuleName", merge that, then go on. Also, the --max-changes=N option from the CLI options page also caps the first pull request at a size you choose.

Try it
Pick a PHP branch

Security support end dates from php.net, counted from 2 October 2026.

The date the branch stops receiving security fixes.

31 Dec 2028
26

PHP 8.4: 26 months left

Longer runway

3 of the 5 listed branches have a shorter runway, so an upgrade to this one outlasts them.

Worked example: whole months of security support left from 2 October 2026
PHP 7.428 Nov 2022-46
PHP 8.231 Dec 20262
PHP 8.331 Dec 202714
PHP 8.431 Dec 202826
PHP 8.531 Dec 202938
Pick a PHP branch to see the whole months of security support it has from 2 October 2026, negative once support has ended. The worked example starts on PHP 7.4, 46 months past end of life, and targets PHP 8.4, which buys 26 months. Arithmetic from The PHP Group, supported versions and unsupported branches pages, accessed 2 October 2026. Modelled, not measured.

How do you stop the upgrade from slipping back in CI?#

Run vendor/bin/rector process --dry-run on every pull request, and the job fails because Rector exits with code 1 whenever it would change a file. The Run in CI page says it directly: "The exit code is 1 when Rector would change a file". As a result, every new commit stays at the level already reached, and the next upgrade is a one-line config change.

yaml
- name: Rector Cache
  uses: actions/cache@v4
  with:
    path: /tmp/rector
    key: ${{ runner.os }}-rector-${{ github.run_id }}
    restore-keys: ${{ runner.os }}-rector-

- run: mkdir -p /tmp/rector

- name: Rector Dry Run
  run: php vendor/bin/rector process --dry-run --no-progress-bar --output-format=github

Add ->withCache(cacheDirectory: '/tmp/rector') to rector.php so the path is the same on a laptop and a runner. Without it, the docs say, each job parses all code from scratch. In particular, the github output format prints the findings as inline notes on the pull request, and gitlab does the same on GitLab.

Also run a coding standard tool in the same job. Because Rector prints code through php-parser, it may leave an extra space, so its docs recommend Easy Coding Standard right after it.

How do Laravel and Symfony upgrades fit into a Rector run?#

Framework sets come after the PHP level: withComposerBased reads the installed Symfony version, and the driftingly/rector-laravel package adds laravel: true for sets up to Laravel 13. In other words, the framework upgrade follows the same reviewed loop as the PHP one. The composer-based sets page lists the keys: twig, doctrine, phpunit, symfony, laravel and drupal. Then Rector reads the installed version from vendor/composer/installed.json.

For Symfony, the rules ship inside rector/rector, so nothing extra is installed. The rector-symfony README shows ->withComposerBased(symfony: true). However, some rules also need the dumped container XML, passed with withSymfonyContainerXml().

For Laravel, the rules live in a community package, driftingly/rector-laravel.

bash
composer require --dev driftingly/rector-laravel

Then add ->withComposerBased(laravel: true) to rector.php. The package README, read on 2 October 2026, marks the older LaravelLevelSetList constants as deprecated in favour of this line. Its version sets run up to LARAVEL_130, the move from Laravel 12 to 13. Since Rector 2.6, vendor/bin/rector composer-based prints which of these rules are active, and --composer-based runs only them. Teams on Laravel can read our Laravel for SaaS post for where the framework fits after the upgrade.

Framework upgrade sets in Rector: where the rules ship and the line that enables them. Source: rectorphp/rector-symfony and driftingly/rector-laravel READMEs, read 2 October 2026

FrameworkWhere the rules shipLine in rector.phpWhat else to know
SymfonyInside rector/rector, nothing extra installed->withComposerBased(symfony: true)Some rules also need the dumped container XML, passed with withSymfonyContainerXml()
LaravelThe community package driftingly/rector-laravel->withComposerBased(laravel: true)Version sets run up to LARAVEL_130, the move from Laravel 12 to 13

When should a team write a Rector custom rule?#

Write a custom Rector rule only after checking no shipped rule covers the change; the rule is a class extending AbstractRector with getNodeTypes and refactor. A custom rule pays off for a repeated, project-specific rename that no shipped rule makes. The custom rule guide starts with "First, make sure it's not covered by any existing Rectors." Search the find rule page before writing anything.

If nothing fits, the shape is small. First, a rule is a class that extends Rector\Rector\AbstractRector. Second, it returns the node types it wants from getNodeTypes(), for example MethodCall::class. Then refactor() returns the changed node, or null to skip it. The guide's example renames every set* call to change*, so setPassword() becomes changePassword().

Register the class with ->withRules([MyFirstRector::class]), add its namespace to autoload-dev in composer.json, and run composer dump-autoload. After that, write a test. Rector's AbstractRectorTestCase runs the rule on fixture files, each with the code before and after five dashes. As a result, the rule's behaviour is pinned before it touches real code.

Deciding on a custom Rector ruleDeciding on and shipping a custom Rector rule, from the Rector documentation, Custom Rule, read 2 October 2026.Rector documentation, Custom Rule, read 2 October 2026

What can Rector not do, and when is it the wrong tool?#

Rector is the wrong tool for runtime magic, templates and formatting: it cannot see __get or container lookups, it skips Blade and Twig, and it leaves layout to a coding standard tool. Here are the cases where you should reach for something else, as listed on the limitations page:

What Rector cannot change and what to use instead. Source: Rector documentation, Limitations, read 2 October 2026

CaseWhy Rector misses itWhat to use instead
Runtime magicRector does not boot your framework or run your container. Magic __get, __call and __callStatic without docblocks, services fetched by string name, and classes built on the fly are invisible.Cover these with tests and PHPStan, then fix them by hand.
TemplatesRector processes .php files only. It "does not understand Twig, Blade or Latte", so a renamed method is not renamed inside templates.Search templates by hand or with a framework tool.
FormattingRector does not format code.Easy Coding Standard or a similar fixer handles layout.
Public APIsMany rules only touch private and final code on purpose.If you own every caller, withTreatClassesAsFinal() lifts that limit.

The README also lists a known drawback with files that mix PHP and HTML. In short, Rector moves the typed, plain PHP. A person still owns the rest.

Where to go after the first Rector pull request?#

Once the first level is merged, the PHP features guide shows what each version added, and the PHP or Laravel pages cover help with the review. The PHP features guide maps each feature to the version that added it. For the wider question of old code, our post on modernising legacy code with AI covers where rule-based tools sit beside other ways to change code.

If you want a second pair of hands on the review, our PHP software engineers and Laravel software engineers do this work. That said, the Rector documentation is enough on its own for Rector PHP migrations. Install it, write five lines of rector.php, and open the first pull request at level 0.

Questions this post answers

What are Rector PHP migrations?
Rector PHP migrations use Rector, an open-source command-line tool, to rewrite PHP source by rules so code moves from PHP 5.3 up to 8.5 in reviewable steps. Rector is a refactoring tool you install with Composer, configure in one PHP file and run from vendor/bin/rector.
Which PHP version should a Rector upgrade target in October 2026?
Counted from 2 October 2026, PHP 8.2 has 2 months of security support left, PHP 8.4 has 26 and PHP 8.5 has 38. Therefore, for most teams in October 2026 the choice is 8.4 or 8.5.
When should you write a Rector custom rule?
Write a custom Rector rule only after checking no shipped rule covers the change; the rule is a class extending AbstractRector with getNodeTypes and refactor.
What can Rector not do?
Rector is the wrong tool for runtime magic, templates and formatting: it cannot see __get or container lookups, it skips Blade and Twig, and it leaves layout to a coding standard tool.

Keep reading