Composer Based Sets

Projects like Symfony, Doctrine, Twig or Laravel have lots of versions. Instead of adding dozens of sets for each of those, you can make use of composer-based set resolution:

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true);
  • Rector should look into installed composer.json version of twig/twig, doctrine/*, phpunit/phpunit and symfony/*
  • then it picks all sets that are relevant to your specific installed versions
  • and run those

If you upgrade to Doctrine 4, Twig 4, or Symfony 10 later, Rector will pick up sets for you.

Currently supported groups are twig, doctrine, phpunit, symfony, netteUtils, laravel and drupal:

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withComposerBased(
        twig: true,
        doctrine: true,
        phpunit: true,
        symfony: true,
        netteUtils: true,
        laravel: true,
        drupal: true,
    );

How it works

Rector reads the installed version from vendor/composer/installed.json, and falls back to the require and require-dev sections of your composer.json. There are 3 levels where the version decides what runs:

1. Set level - a whole set file is loaded only if a package version is installed. This is handled by a set provider, see Custom Set Provider.

2. Rule level - a single rule declares the package version it needs. The rule is skipped everywhere else.

3. Rule configuration level - a rule is registered with a configuration valid only since (or until) a specific package version. The very same rule can be registered multiple times with different configuration for different version ranges.

PHPUnit and Symfony sets already use levels 2 and 3, so a single set file covers every supported version of those packages. Other packages are being migrated to this approach.

Show what runs: the composer-based command

Since Rector 2.6, you can see exactly which composer-bound rules are loaded and which of them are active on your project:

vendor/bin/rector composer-based
Composer package bound rules
============================

 ------------------------------------------------- ----------------- ---------- ----------- --------
  Rule                                              Package           Requires   Installed   Active
 ------------------------------------------------- ----------------- ---------- ----------- --------
  AnnotationWithValueToAttributeRector              phpunit/phpunit   >=10.0     13.2.6.0    yes
  BareCreateMockAssignToDirectUseRector             phpunit/phpunit   >=11.0     13.2.6.0    yes
  CreateStubOverCreateMockArgRector                 phpunit/phpunit   >=11.0     13.2.6.0    yes
  RemoveOverrideFinalConstructTestCaseRector        phpunit/phpunit   >=12.0.3   13.2.6.0    yes
 ------------------------------------------------- ----------------- ---------- ----------- --------

The rules registered with a version-bound configuration are listed in a second table, with the configuration printed below each row:

Composer package bound rule configuration
=========================================

 -------------------------------------- ----------------- -------------- ----------- --------
  Rule                                   Package           Requires       Installed   Active
 -------------------------------------- ----------------- -------------- ----------- --------
  AnnotationToAttributeRector            phpunit/phpunit   >=10.0 <13.0   13.2.6.0    no
  AnnotationToAttribute(runClassInSeparateProcess,
  PHPUnit\Framework\Attributes\RunClassInSeparateProcess, [], false)
 -------------------------------------- ----------------- -------------- ----------- --------
  RenameMethodRector                     phpunit/phpunit   >=8.3          13.2.6.0    yes
  MethodCallRename(PHPUnit\Framework\MockObject\MockBuilder, setMethods, onlyMethods)
 -------------------------------------- ----------------- -------------- ----------- --------

 ! [NOTE] 32 of 33 composer package bound items are active

Inactive items are listed too, so you can see why a rule does not change your code. In the example above, runClassInSeparateProcess is not converted to an attribute, because that attribute was removed in PHPUnit 13 and the project runs PHPUnit 13.2.

Run only composer-based rules

Upgraded a package and want to see just what that upgrade brings? Narrow the run to composer-bound rules only:

vendor/bin/rector process --composer-based

It keeps only the rules that declare a composer package constraint themselves, or that were registered with a version-bound configuration. Everything else - your levels, prepared sets and custom rules - is skipped for that run, the same way --only narrows it to a single rule.

Handy right after a composer update:

composer update phpunit/phpunit
vendor/bin/rector process --composer-based --dry-run

Make a rule bound to a package version

Let's say a rule should only run on PHPUnit 11 and above, because createStub() did not exist before. Implement ComposerPackageConstraintInterface:

use PhpParser\Node;
use Rector\Rector\AbstractRector;
use Rector\VersionBonding\Contract\ComposerPackageConstraintInterface;
use Rector\VersionBonding\ValueObject\ComposerPackageConstraint;

final class CreateStubOverCreateMockArgRector extends AbstractRector implements ComposerPackageConstraintInterface
{
    public function provideComposerPackageConstraint(): ComposerPackageConstraint
    {
        return new ComposerPackageConstraint('phpunit/phpunit', '>=11.0');
    }

    // getRuleDefinition(), getNodeTypes(), refactor() as usual
}

The constraint is a plain composer version constraint, so anything composer/semver understands works - >=11.0, >=10.0 <13.0, ^7.4.

That's it. The rule can now be registered in any set. On a project with PHPUnit 9 it is filtered out before the first file is parsed, and vendor/bin/rector composer-based reports it as not active.

Test it against a version you don't have installed

Your own test suite runs on a single PHPUnit version, but the rule must be tested against the version it targets. Override provideComposerJsonFilePath() in the test case and point it to a standalone composer.json. The versions are then read from its require and require-dev sections, instead of from the installed packages:

use Rector\Testing\PHPUnit\AbstractRectorTestCase;

final class CreateStubOverCreateMockArgRectorTest extends AbstractRectorTestCase
{
    protected function provideComposerJsonFilePath(): ?string
    {
        return __DIR__ . '/composer.json';
    }

    // ...
}
{
    "require-dev": {
        "phpunit/phpunit": "^11.0"
    }
}

Bind a rule configuration to a package version

Sometimes the rule itself is version-agnostic, but its configuration is not. A method rename only makes sense once the new method exists, and an annotation-to-attribute conversion only makes sense while that attribute exists.

Use ruleWithConfigurationComposerVersionBound() in your set file - the same rule class, registered once per version range:

use Rector\Config\RectorConfig;
use Rector\Renaming\Rector\MethodCall\RenameMethodRector;
use Rector\Renaming\ValueObject\MethodCallRename;

return static function (RectorConfig $rectorConfig): void {
    // MockBuilder::onlyMethods() was added in PHPUnit 8.3
    $rectorConfig->ruleWithConfigurationComposerVersionBound(RenameMethodRector::class, [
        new MethodCallRename('PHPUnit\Framework\MockObject\MockBuilder', 'setMethods', 'onlyMethods'),
    ], 'phpunit/phpunit', '>=8.3');

    // expectExceptionMessageIsOrContains() was added in PHPUnit 13.2
    $rectorConfig->ruleWithConfigurationComposerVersionBound(RenameMethodRector::class, [
        new MethodCallRename(
            'PHPUnit\Framework\TestCase',
            'expectExceptionMessage',
            'expectExceptionMessageIsOrContains'
        ),
    ], 'phpunit/phpunit', '>=13.2');
};

The arguments are the rule class, its configuration, the package name and the version constraint.

If the installed version does not satisfy the constraint, the configuration is not registered at all - but it is still reported by vendor/bin/rector composer-based as inactive, so nothing silently disappears.

Upper bounds work as well, for configuration that must stop being applied:

use Rector\Php80\Rector\Class_\AnnotationToAttributeRector;
use Rector\Php80\ValueObject\AnnotationToAttribute;

// the RunClassInSeparateProcess attribute was added in PHPUnit 10.0 and removed in PHPUnit 13.0
$rectorConfig->ruleWithConfigurationComposerVersionBound(AnnotationToAttributeRector::class, [
    new AnnotationToAttribute(
        'runClassInSeparateProcess',
        'PHPUnit\Framework\Attributes\RunClassInSeparateProcess'
    ),
], 'phpunit/phpunit', '>=10.0 <13.0');

That way a single set file handles every version of the package, and your rector.php stays a one-liner - even 3 major versions later.