# PHP modernization with Rector, PHPStan, and Droids

> PHP modernization works best when Rector performs the mechanical upgrade, PHPStan and the test suite gate every step, and Factory Droids handle the residue and write new Rector rules. A practical setup with hooks, skills, and Droid Exec.

URL: https://factory.com/articles/php-modernization-rector
Published: 2026-11-14
Updated: 2026-11-14

PHP modernization is mostly mechanical. Moving a codebase from PHP 7.4 to 8.3, replacing docblock annotations with attributes, or stepping a Laravel or Symfony application up a major version involves thousands of small, rule-shaped edits. Rector performs those edits deterministically. Factory Droids take the part no rule covers, and PHPStan plus the test suite decide whether each step is safe to merge.

The approach below keeps the mechanical diff and the agent diff apart, so reviewers can trust the first and spend their attention on the second.

## What Rector covers in a PHP modernization

Rector is a PHP refactoring tool that parses code into an abstract syntax tree and applies rules to it. It needs PHP 7.2 or newer to run and can read code written for PHP 5.x through 8.x. A `rector.php` file defines the paths and the rules, and `vendor/bin/rector process --dry-run` prints the diff without writing anything.

The most useful feature for a legacy codebase is levels. A level is the first N rules of a set, sorted from safest to most complex. The [Rector levels documentation](https://getrector.com/documentation/levels) recommends starting `withPhpLevel(0)`, raising the number by one, reviewing a small diff, and merging before the next step. Once the level covers every rule for the PHP version in `composer.json`, the config switches to `withPhpSets()`.

Framework upgrades use [composer-based sets](https://getrector.com/documentation/composer-based-sets). `withComposerBased()` reads the installed versions of Symfony, Doctrine, Twig, PHPUnit, Laravel, or Drupal and loads only the rules relevant to them. Laravel rules come from the community package `driftingly/rector-laravel`, which must be installed as a dev dependency.

```php title="rector.php"
<?php

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
    ->withPhpLevel(3)
    ->withComposerBased(symfony: true, phpunit: true);
```

Two other deterministic tools belong in the same pipeline. Rector prints code through php-parser and can leave spacing that differs from the house style, so a coding standard tool such as PHP-CS-Fixer or PHP_CodeSniffer runs after it. PHPStan provides the types Rector depends on. Rector's own setup guide recommends reaching PHPStan level 3 or 4 without a baseline before the first Rector run, because a baseline that ignores thousands of errors leaves Rector unable to resolve types.

## Where the deterministic pass stops

Rector finishes with a diff that is correct for the rules it applied. The work that remains is the part an engineering lead actually worries about.

After a PHP level bump, PHPStan reports new errors where stricter types expose real ambiguity. A dynamic property that PHP 8.2 deprecates needs a decision about whether to declare it, move it into an array, or add `#[\AllowDynamicProperties]`. A library with a breaking release and no Rector rule needs call sites rewritten by hand. Tests that mocked a removed method need repair. Some Rector rules are safe on typed code and risky on code that passes `mixed` everywhere, and a reviewer has to decide whether to skip them for a path.

This residue is where a Droid fits. Each item is small, local, and verifiable by the same tools that found it. Droids read the PHPStan message, the surrounding code, and the tests, make the change, and rerun the check.

The second job is writing new rules. When the same manual fix appears in fifty files, the better output is one Rector rule that makes all fifty changes. A [custom Rector rule](https://getrector.com/documentation/custom-rule) extends `AbstractRector`, declares the node types it handles in `getNodeTypes()`, and returns a modified node from `refactor()`. Tests extend `AbstractRectorTestCase` and run every `.php.inc` fixture in a directory. Each fixture holds the input code, a line of five dashes, and the expected output.

```php title="utils/rector/tests/Rector/LegacyMailerRector/Fixture/send.php.inc"
<?php

$mailer->sendLegacy($to, $subject, $body);

?>
-----
<?php

$mailer->send(new Message($to, $subject, $body));

?>
```

A Droid can write the rule, the fixtures for both the change and the cases to leave alone, and the registration in `rector.php`, then run PHPUnit on the rule before running it on the codebase. From that point the change is deterministic and reusable across every repository that shares the rule package.

## The modernization loop with Factory

Factory has no PHP-specific mode or built-in Rector integration. Droids run Rector, PHPStan, PHP-CS-Fixer, and PHPUnit through the same harness features they use for any tool, and those features keep the deterministic tools in the loop at every step.

The loop below runs once per Rector level or framework set.

Rector pass</span><br/><span style='color:#8A8380 !important'>one level or composer-based set</span>"]
    B["<span style='color:#FAFAFA !important;font-weight:600'>Gate</span><br/><span style='color:#8A8380 !important'>PHPStan, PHPUnit, coding standard</span>"]
    C["<span style='color:#FAFAFA !important;font-weight:600'>Droid handles residue</span><br/><span style='color:#8A8380 !important'>type errors, tests, new Rector rules</span>"]
    D["<span style='color:#FAFAFA !important;font-weight:600'>Re-verify</span><br/><span style='color:#8A8380 !important'>rector --dry-run reports no changes</span>"]
    E["<span style='color:#FAFAFA !important;font-weight:600'>Human review</span><br/><span style='color:#8A8380 !important'>Rector commit and Droid commit</span>"]
    A --> B --> C --> D --> E
`}
/>

**Write the commands down once.** [AGENTS.md](https://docs.factory.com/harness/agents-md) holds the exact install, analysis, and test commands, including the PHPStan level the project currently enforces and the paths Rector must skip. Every session and every automated run starts from the same instructions.

**Check every edit.** A `PostToolUse` [hook](https://docs.factory.com/harness/hooks) runs after each file edit. When a hook exits with code `2`, Droid receives its stderr as corrective feedback. A short script can lint the edited file and run Rector on it in dry-run mode, so a Droid that writes pre-upgrade syntax hears about it immediately. Rector's `process` command exits with code `2` in dry-run mode when it would change code.

```bash title=".factory/hooks/php-check.sh"
#!/usr/bin/env bash
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in *.php) ;; *) exit 0 ;; esac
cd "$FACTORY_PROJECT_DIR" || exit 0
php -l "$file" >/dev/null || { echo "PHP syntax error in $file" >&2; exit 2; }
if ! vendor/bin/rector process "$file" --dry-run --no-progress-bar >/tmp/rector.out 2>&1; then
  cat /tmp/rector.out >&2
  exit 2
fi
```

Register the script under `PostToolUse` with the matcher `Create|Edit|ApplyPatch` in `.factory/hooks.json`, using `"$FACTORY_PROJECT_DIR"/.factory/hooks/php-check.sh` as the command.

**Package the playbook.** A [skill](https://docs.factory.com/harness/skills) at `.factory/skills/php-upgrade/SKILL.md` records the procedure. It says which Rector level to raise next, which rules to skip on which paths, which commands verify the step, and that Rector output and agent edits go in separate commits. Droid loads it when the task matches, and the whole team gets the same playbook from Git.

**Fan out the residue.** After a Rector step, PHPStan's JSON output lists the files with errors. The [lint autofix recipe](https://docs.factory.com/droid-exec/recipes/lint-autofix) shows the pattern for ESLint, and it transfers directly. A script runs one `droid exec --auto low` per file, then runs PHPStan on that file again and reports any file that still fails. At `--auto low`, Droid edits project files but does not install packages or write to Git, so the script remains in charge of verification.

```bash
vendor/bin/phpstan analyse --error-format=json --no-progress > phpstan.json || true
jq -r '.files | keys[]' phpstan.json | while read -r f; do
  droid exec --auto low "Fix the PHPStan errors in $f. Keep behavior the same. Do not add ignore comments or baseline entries."
  vendor/bin/phpstan analyse --no-progress "$f" >/dev/null || echo "still failing: $f"
done
```

**Run the whole upgrade as a plan.** For a multi-repository or multi-month upgrade, [Missions](https://docs.factory.com/missions/overview) break the work into milestones with validation as it goes. The Missions documentation lists brownfield migrations as a use case. Skills, hooks, and MCP integrations carry over into a mission, so the same Rector checks apply to every worker.

## Verification and review

Every gate in this workflow is deterministic. The upgrade step is done when four checks pass on the branch.

1. `vendor/bin/rector process --dry-run` exits `0`, meaning the configured rules have nothing left to change.
2. PHPStan passes at the enforced level with no new baseline entries. PHPStan has [eleven rule levels](https://phpstan.org/user-guide/rule-levels), from 0 to 10, and raising the enforced level after each PHP step turns new type information into a ratchet.
3. The PHPUnit suite passes, along with any characterization tests written before the upgrade for code paths with weak coverage.
4. The coding standard check passes, for example `vendor/bin/php-cs-fixer check`, which exits with code `8` when files need fixing.

Commit structure carries most of the review value. The first commit contains the `rector.php` change and Rector's output, and nothing else. A reviewer can rerun the same command on the parent commit and confirm the diff matches. The second commit contains the coding standard pass. The third contains Droid edits, which is where review time goes. New Rector rules land in their own pull request with their fixture tests.

Keep production behavior in view. Static analysis and unit tests do not cover configuration loaded at runtime, serialized data written by older PHP versions, or extensions that differ between environments. Run the upgraded application against a staging copy of real traffic or recorded requests before switching the runtime.

## Commands to start with

These commands are the deterministic core of the loop, and each is safe to run on a clean working tree as of November 2026.

<div className="not-prose my-6 flex flex-col gap-3 [&_button]:!w-auto">
  <div className="grid grid-cols-1 gap-y-5 md:grid-cols-[max-content_1fr] md:gap-x-4">
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Preview the Rector diff.</strong> Prints every change the configured rules would make and exits 2 if there are any.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">List framework rules.</strong> Shows which composer-bound rules are loaded and active for the installed package versions.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Type gate.</strong> Runs PHPStan at a fixed level after each Rector step.</span>
    
    <span className="text-base-300 text-sm leading-relaxed"><strong className="text-light-base-primary font-medium">Style gate.</strong> Reports formatting drift that Rector's printer introduced, without writing files.</span>
  </div>
</div>

Start with one Rector rule on one directory, merge it, and only then put Droids on the residue. The first small pull request establishes the commit structure and the gates that every later step reuses.

## Frequently asked questions

### What does Rector do in a PHP modernization?

Rector parses PHP into an abstract syntax tree and applies rules that rewrite code, such as PHP version upgrades, framework upgrades, and type declarations. The same configuration produces the same diff, so the change can be reviewed as one mechanical step and rerun after a rebase.

### Why run PHPStan alongside Rector?

Rector uses PHPStan to understand types. The Rector documentation recommends reaching PHPStan level 3 or 4 without a baseline before running Rector, because a large baseline hides type information that rules depend on. PHPStan then acts as the type gate after each Rector step.

### What does a Droid do that Rector cannot?

A Droid fixes what remains after Rector runs, such as PHPStan errors, failing tests, API changes in libraries that have no Rector rule, and code paths where a rewrite needs a human-style judgment. It can also write a new Rector rule with fixture tests so the next repository gets the change deterministically.

### Does Factory include a built-in Rector integration?

No. Factory runs Rector, PHPStan, and your test suite through the same mechanisms it uses for any tool, including project hooks, skills, AGENTS.md commands, Droid Exec scripts, and Missions. The Rector binary in your vendor directory stays the source of the mechanical change.

### How should a large PHP upgrade be split into pull requests?

Raise one Rector level at a time, commit the Rector output separately from agent edits, and keep each pull request small enough to review the same day. The Rector documentation describes this level-by-level approach for legacy projects.

## Further reading

- [Deterministic code modernization with Factory](https://factory.com/articles/deterministic-code-modernization)
- [How to run a runtime upgrade with coding agents](https://factory.com/articles/coding-agent-runtime-upgrades)
- [Enterprise software modernization beyond the demo](https://factory.com/articles/enterprise-software-modernization)
- [What the PHP vulnerability means for enterprise](https://factory.com/articles/php-vulnerability-enterprise)
