Here is the whole config. Not a snippet, the whole thing:
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withComposerBased(
laravel: true,
drupal: true,
doctrine: true,
symfony: true,
phpunit: true
);
No version numbers. No "add the 10.0 set, remove the 9.0 set". You upgrade the package, you run Rector, you are up to date. The version lives in exactly one place - your composer.json - and Rector reads it from there.
Sounds like magic. It is not. It is three small mechanisms, layered from coarse to fine. Let's take them apart.
Where the version comes from
First, the boring-but-important bit: where does Rector look?
It reads the installed version from vendor/composer/installed.json - the lock-accurate truth of what is actually sitting in your /vendor. If that is not available, it falls back to the require and require-dev sections of your composer.json.
So a project with laravel/framework: 11.4.2 installed gets the Laravel 11 rules. Bump to 12, run composer update, and the very same rector.php now gets the Laravel 12 rules too. The config never changed. The installed version did.
Level 1: a whole set, bound to a package
The coarsest level: load an entire set file only when a package is present.
This is the classic "set provider" model. An extension hands Rector a list of (package, version, set file) triples, and Rector loads the file whose version matches. Drupal still works this way today:
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withComposerBased(drupal: true);
It works, and it shipped Drupal support. But it has a rough edge: one set file per version means dozens of files to maintain, and a rule that is valid from version 8 onward has to be copy-pasted into the 8, 9, 10, and 11 files. Miss one, and the rule silently stops running for that version.
That is why SetProviderInterface is now deprecated. The better idea was to stop describing sets as objects, and let each rule declare its own version.
Level 2: a rule that knows its own version
This is where it gets nice. Instead of grouping rules into per-version files, a rule states the exact package version its target API showed up in - and applies from there upward.
A non-configurable rule just implements one interface:
use Rector\Rector\AbstractRector;
use Rector\VersionBonding\Contract\ComposerPackageConstraintInterface;
use Rector\VersionBonding\ValueObject\ComposerPackageConstraint;
final class Redirect301ToPermanentRedirectRector extends AbstractRector implements ComposerPackageConstraintInterface
{
public function provideComposerPackageConstraint(): ComposerPackageConstraint
{
return new ComposerPackageConstraint('laravel/framework', '>=5.7');
}
// getNodeTypes(), refactor(), getRuleDefinition() as usual
}
That is the whole trick. On a project running Laravel 5.6, this rule is filtered out before the first file is even parsed. On 5.7 and everything above it, the rule runs. One declaration, correct forever, no per-version duplication.
The constraint is a plain composer/semver string, so anything you would write in composer.json works: >=5.7, >=10.0 <13.0, ^7.4.
Level 3: configurable rule per version
Sometimes the rule itself is version-agnostic, but its configuration is not. RenameClassRector renames a class - but which class moved, and when, depends on the version.
For that there is ruleWithConfigurationComposerVersionBound(). Same rule class, registered once per version range, all in a single set file:
use Rector\Config\RectorConfig;
use Rector\Renaming\Rector\Class_\RenameClassRector;
return static function (RectorConfig $rectorConfig): void {
// Laravel 5.0
$rectorConfig->ruleWithConfigurationComposerVersionBound(RenameClassRector::class, [
'Illuminate\Cache\CacheManager' => 'Illuminate\Contracts\Cache\Repository',
], 'laravel/framework', '>=5.0');
// Laravel 11.0
$rectorConfig->ruleWithConfigurationComposerVersionBound(RenameClassRector::class, [
'Illuminate\Support\Reflector' => 'Illuminate\Reflection\Reflector',
], 'laravel/framework', '>=11.0');
};
Because every rule and every config carries its own version, one file covers every version of the package. A direct jump across three majors is handled by the same file that handles a single-minor bump. This is how Laravel's entire upgrade path now lives in one composer-based.php, instead of a folder full of laravel5.php, laravel6.php, laravel7.php.
See exactly what runs
The best part of version-bound rules: nothing disappears silently. You can ask Rector what it loaded and why:
vendor/bin/rector composer-based
Composer package bound rules
============================
-------------------------------------------- ----------------- ---------- ----------- --------
Rule Package Requires Installed Active
-------------------------------------------- ----------------- ---------- ----------- --------
Redirect301ToPermanentRedirectRector laravel/framework >=5.7 11.4.2 yes
AnnotationWithValueToAttributeRector phpunit/phpunit >=10.0 13.2.6 yes
RenameClassRector laravel/framework >=12.0 11.4.2 no
-------------------------------------------- ----------------- ---------- ----------- --------
An inactive rule is listed too, with the version that would switch it on. No more "why didn't Rector change this?" mysteries - the table tells you the installed version did not satisfy the constraint.
Laravel and Drupal, same rails
So where do the two frameworks stand?
- Laravel has moved fully to levels 2 and 3. Every rule bonds to a
laravel/frameworkversion, and the whole upgrade path is one set file.withComposerBased(laravel: true)and you are done. - Drupal still ships the level-1 set provider today, with over 80% automated deprecation coverage tested against nearly 10,000 contrib modules. It is being migrated to the bonded-rule model, and the config you write does not change when it lands.
Same feature, same withComposerBased() call, two frameworks at different points on the same road. Symfony, Doctrine, Twig, and PHPUnit are already there.
Try it on one upgrade
Pick your next framework bump. Add the one line to rector.php, run vendor/bin/rector process --dry-run, and read the diff. Then run vendor/bin/rector composer-based and watch it explain itself.
Your composer.json already knows which version you are on. Now Rector reads it too.