Your composer.json Already Knows Which Rules to Run

This feature is available since Rector 2.6.

You upgrade Laravel or Drupal, open rector.php, and start hunting for the right sets to add. Laravel 9 set? 10? 11? Did you remove the old ones? Every upgrade, the same guessing game.

What if Rector just read the version you already declared in composer.json and picked the rules for you? That is exactly what composer-based sets do. Here is how they work under the hood, and why Laravel and Drupal now ride on the same rails.

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/framework version, 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.