Development

How to Upgrade Legacy PHP With Rector One Version at a Time

Rector can remove a large amount of mechanical work from a legacy PHP upgrade. It cannot decide whether the application still behaves correctly. The safe pattern is to upgrade one PHP version boundary at a time, review every transformation, and use tests plus static analysis to verify what automation cannot know.

Do not begin by enabling every modernization rule. First establish the runtime path, dependency constraints, and a rollback plan.

Inventory the real upgrade boundary

Record these facts before changing code:

  • Current production PHP version and enabled extensions
  • Target PHP version supported by the hosting platform
  • Composer platform constraints and locked dependencies
  • Framework, CMS, and plugin compatibility
  • Deprecated or removed extensions and configuration
  • Critical HTTP, CLI, queue, cron, and integration flows
  • Existing tests, static analysis, and production monitoring

Separate three kinds of work: making the code parse on the target PHP version, upgrading dependencies, and improving design. They may support one another, but combining all of them obscures the cause when behavior changes.

Use the official PHP supported-versions table when selecting the target. Verify hosting and dependency support too. A language release being maintained does not guarantee that every library in the application supports it.

Establish a safety net first

At minimum, create smoke tests around authentication, writes, payments, permissions, exports, and scheduled jobs relevant to the code being upgraded. Add static analysis at a level the project can sustain. Capture a sanitized fixture database and a repeatable local runtime.

Commit that safety work independently. If Rector later changes hundreds of lines, reviewers should not have to separate newly added tests from automated transformations in the same diff.

Install and configure Rector

Install Rector as a development dependency:

composer require --dev rector/rector
vendor/bin/rector --version

Create rector.php with the paths your team owns. The current configuration API supports a fluent setup:

<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/app',
        __DIR__ . '/public',
        __DIR__ . '/tests',
    ])
    ->withSkip([
        __DIR__ . '/tests/fixtures',
        __DIR__ . '/var',
    ]);

Keep generated code, caches, fixtures, and third-party copies outside the analysis paths. Review the official configuration guide for the installed release because the configuration API evolves.

Before enabling a Rector rule or set, verify PHP syntax with the runtime that owns the preparatory artifact:

find app public tests -type f -name '*.php' \
  -exec php -l {} \;

This lint pass checks PHP parsing only. It does not prove that Rector discovered the configured paths, because Rector requires at least one rule or set before it can process them. If the preparatory artifact must remain deployable on PHP 7.4, run this command with the PHP 7.4 binary or container. Commit the paths and exclusions before applying transformations.

Move one version boundary at a time

If the application runs on PHP 7.4 and the destination is PHP 8.3, do not treat that as one opaque jump. Make the code compatible with PHP 8.0, verify it, then continue through later boundaries.

Compatibility with the next runtime is not the same as continued compatibility with the old one. A full PHP 8.0 Rector set may introduce PHP 8-only syntax or APIs, so its output is not automatically deployable to PHP 7.4. Separate the work into two artifacts:

  1. A preparatory artifact that still parses and runs on PHP 7.4. It contains tests, dependency constraints, deprecation fixes, and only transformations explicitly verified as 7.4-compatible.
  2. A cutover artifact that applies the PHP 8.0 target set and is built and deployed only with the PHP 8.0 runtime.

If production must remain able to receive every intermediate commit, keep the full target-only transform on a cutover branch until the runtime switch. Do not promise that withPhpSets(php80: true) preserves the old minimum.

A configuration for a specific step can use the applicable PHP set. For example:

<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/app',
        __DIR__ . '/public',
        __DIR__ . '/tests',
    ])
    ->withPhpSets(php80: true);

Use the set matching the boundary you are actually preparing. The Rector set-list documentation is the source of truth for available sets in your installed version.

Preview the change:

vendor/bin/rector process --dry-run

This is the first Rector dry run in the sequence. A PHP set is now loaded, so Rector can initialize processing, discover the configured paths, parse the files, and report proposed transformations.

Then apply it only from a clean branch:

vendor/bin/rector process

Rector edits files. A clean working tree makes the diff reviewable and makes discarding a bad experiment straightforward.

Review transformations by behavior

Do not approve an automated diff because the syntax is valid. Review changes in groups:

  • Function and method signature changes
  • Null and false handling
  • String and numeric coercion
  • Array access and iteration
  • Exception and error behavior
  • Date, locale, and serialization behavior
  • Calls into extensions or framework APIs

Run the formatter after Rector so formatting noise is isolated and consistent. Then run unit tests, integration tests, static analysis, and application smoke tests.

composer test
vendor/bin/phpstan analyse
vendor/bin/rector process --dry-run

The final dry run should report no remaining configured changes. That proves the committed source is in the normalized state for this step.

Keep the commits small enough to reverse

A useful commit sequence is:

  1. Add or improve characterization tests.
  2. Add Rector with paths and exclusions.
  3. Apply one PHP compatibility set.
  4. Apply necessary manual compatibility fixes.
  5. Upgrade one constrained dependency group.
  6. Change the test matrix to include the new runtime.
  7. Change production only after the candidate runtime passes.

Do not mix optional style transformations into the compatibility commit. Rename and architecture work can follow after the application runs reliably on the supported runtime.

For a large result, narrow the configured paths to one module, complete the loop, then expand. A sequence of reviewed module changes is easier to diagnose than a repository-wide diff that changes every file at once.

Test each artifact on its intended runtime

Run the preparatory artifact on both PHP 7.4 and PHP 8.0. That proves the currently deployable code still works in production and is ready for the next runtime. After enabling the full PHP 8.0 set, run the cutover artifact on PHP 8.0; do not execute or deploy that artifact on PHP 7.4 if it contains target-only syntax.

Repeat the distinction at later boundaries. A PHP 8.1-targeted artifact belongs on PHP 8.1 or newer, while any branch advertised as compatible with PHP 8.0 must be checked independently on PHP 8.0.

Test more than HTTP page loads:

  • Composer installation from the lockfile
  • Database migrations and rollback compatibility
  • CLI and worker commands
  • Scheduled jobs
  • Cache and session behavior
  • Image and document processing extensions
  • External API integrations
  • Plugin or extension activation paths

If a dependency forces a minimum PHP version higher than production, keep it out of the preparatory artifact and install it at the matching cutover. Composer's configured platform and CI matrix should reflect the runtime intended for each artifact, not an aspirational combined range.

What Rector cannot decide

Rector cannot know whether a loose comparison is an intentional business rule, whether an exception should be shown to a user, or whether a deprecated framework method has application-specific side effects. It also cannot prove that a production extension is installed or that a payment provider will accept the resulting request.

Treat manual findings as first-class work. Document anything that needs domain review instead of forcing an automated rewrite to make the checklist green.

Deploy with an explicit rollback

Before switching production:

  • Build the release artifact on the target PHP version.
  • Rehearse the deployment in a production-like environment.
  • Keep database changes backward compatible where possible.
  • Record the previous runtime and artifact versions.
  • Define health and business metrics for the observation window.
  • Know exactly how to restore the previous runtime and code.

The rollback unit is the matching pair of runtime and artifact. Restoring the PHP 7.4 runtime while leaving PHP 8-only source deployed is not a rollback. Keep the previous 7.4 artifact available and rehearse restoring both together.

Avoid irreversible data migrations in the same release as the runtime switch. If rollback requires restoring a database backup, the operational risk is much higher.

The safe use of automation

Rector is most valuable when it performs boring, deterministic transformations and lets developers spend review time on business behavior. Use it as part of a controlled feedback loop:

Configure, dry-run, apply, review, test, analyze, commit, and observe.

Complete that loop for one PHP version step before starting the next. The upgrade may involve more commits, but each one is easier to verify and reverse. That is a better trade than a fast automated rewrite whose failures cannot be explained.