
The first time I ran TYPO3 Rector over a whole extension with every set enabled, I was proud for about ten minutes. Old APIs gone, tests green. Then I opened the merge request and understood my mistake: thousands of changed lines, the real migrations hidden between reordered imports and new type declarations. Nobody can review that properly. An approval of such a diff is not a review, it is a prayer.
Since then I run the tools in a different way. Same tools, same rules, but the result is a series of small commits that a human can actually read. This post shows the setup for an extension going to TYPO3 v14.
The two tools
- TYPO3 Rector (
ssch/typo3-rector) changes PHP code: removed classes, renamed methods, new signatures. It builds onrector/rector. - Fractor (
a9f/typo3-fractor) does the same for everything that is not PHP: TypoScript, FlexForms, Fluid, YAML, XLIFF and.htaccessfiles.
Both are dev dependencies:
composer require --dev ssch/typo3-rector a9f/typo3-fractorOne precondition that people skip: composer.json must already allow the target core and composer update must have worked. Rector reads the installed code to understand types. If v13 is still installed while you apply v14 rules, it guesses.
Rule one: one target version
Both tools offer cumulative level sets like Typo3LevelSetList::UP_TO_TYPO3_14, which include all rules from older versions too, and single-version sets like Typo3SetList::TYPO3_14. For an extension that already runs on v13 and should support v13 and v14 together, the level must be the lowest supported major, so 13. Rules from v14 may produce code that does not exist in v13. Only when you drop v13 do you raise it to 14. The examples below are for an extension that goes v14-only.
Rule two: one set per run, one commit per set
Instead of one config with every set, I let an environment variable choose the step. The config stays in git, and each step is reproducible:
<?php
declare(strict_types=1);
use RectorConfigRectorConfig;
use RectorSetValueObjectLevelSetList;
use RectorValueObjectPhpVersion;
use SschTYPO3RectorConfigurationTypo3Option;
use SschTYPO3RectorSetTypo3LevelSetList;
use SschTYPO3RectorSetTypo3SetList;
$sets = match (getenv('RECTOR_STEP') ?: 'typo3') {
'php' => [LevelSetList::UP_TO_PHP_82],
'typo3' => [Typo3LevelSetList::UP_TO_TYPO3_14],
'quality' => [Typo3SetList::CODE_QUALITY, Typo3SetList::GENERAL],
};
return RectorConfig::configure()
->withPaths([
__DIR__ . '/Classes',
__DIR__ . '/Configuration',
__DIR__ . '/Tests',
__DIR__ . '/ext_localconf.php',
__DIR__ . '/ext_tables.php',
])
->withPhpVersion(PhpVersion::PHP_82)
->withSets($sets)
->withPHPStanConfigs([Typo3Option::PHPSTAN_FOR_RECTOR_PATH])
->withSkip([
__DIR__ . '/.Build',
__DIR__ . '/vendor',
]);The PHPSTAN_FOR_RECTOR_PATH line teaches Rector’s PHPStan about TYPO3, for example what GeneralUtility::makeInstance() returns. Without it some rules do not fire because the type is unknown.
Fractor gets its own small config:
<?php
declare(strict_types=1);
use a9fFractorConfigurationFractorConfiguration;
use a9fTypo3FractorSetTypo3LevelSetList;
return FractorConfiguration::configure()
->withPaths([
__DIR__ . '/Configuration',
__DIR__ . '/Resources/Private',
])
->withSets([
Typo3LevelSetList::UP_TO_TYPO3_14,
]);Then the runs, each one first as a dry run:
RECTOR_STEP=typo3 vendor/bin/rector process --dry-run
RECTOR_STEP=typo3 vendor/bin/rector process
git add -A && git commit -m "[TASK] Apply TYPO3 Rector level set up to v14"
vendor/bin/fractor process --dry-run
vendor/bin/fractor process
git add -A && git commit -m "[TASK] Apply Fractor migrations for TYPO3 v14"
RECTOR_STEP=php vendor/bin/rector process
git add -A && git commit -m "[TASK] Apply PHP 8.2 level set"
vendor/bin/php-cs-fixer fix
git add -A && git commit -m "[TASK] Apply coding standards"The order is deliberate. The TYPO3 migrations come first and alone, because they are the commit a reviewer must read carefully. PHP modernisation and code style come later, because they touch many lines but carry little risk. If you mix style into the TYPO3 commit, the important changes drown again.
How I read the dry-run diff
The dry run is not a formality. I look for three things:
- Changed public API. If Rector renames or retypes a public method in
Classes/, another extension that calls it may break. That goes into the changelog of the extension. - Rules that guessed. A type taken from an old
@vardocblock, or a parameter that became nullable. The docblock was wrong before, so the new type is wrong now. - Changes in tests. They are fine, but check that the assertion still tests the same thing.
When one rule produces broken code, I do not fix its output by hand on every run. I skip that single rule in withSkip() with a comment, and write the affected place on my list of manual work. Hand edits on top of generated code get lost the next time somebody runs the tool.
What stays manual
After both tools are done, there is always a rest. For v14 projects these are the usual suspects in my work:
- Runtime behaviour in Fluid. Fluid 5 validates ViewHelper arguments more strictly. Fractor can rename things in templates, but whether a variable holds an array at runtime is not visible in the file.
- Database and data. A changelog entry like
Breaking-17406(the fieldpages.urlwas removed in v14, the page type “Link” usespages.link) needs an upgrade wizard or a migration of data. No code tool touches your rows. - Backend module names. v14 renamed main modules (
webbecamecontent, with an alias). Check yourConfiguration/Backend/Modules.phpby eye. - Behaviour changes without a new API. When a method keeps its name but returns something else, no rule can know your code relied on the old result. Only tests catch that.
To measure the rest, I run the extension scanner in the Upgrade module of the install tool, or simply search the code for the class names listed in the Breaking entries of the target version. Every hit is a task, written down before I start fixing.
Why this is worth it
Small commits are not only kind to reviewers. When something breaks three weeks later, git bisect lands on “Apply Fractor migrations” instead of “Upgrade everything”, and you know in which world to search. And when the agent or a junior colleague runs the same workflow, the result looks the same, because the config decides, not the mood of the day.
Cheers, and may your diffs stay small!