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);
composer.json version of twig/twig, doctrine/*, phpunit/phpunit and symfony/*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,
);
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.
composer-based commandSince 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.
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
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.
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"
}
}
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.