Skip to content

Authoring a custom Rector downgrade rule

The NextPDF backport pipeline downgrades the PHP 8.4 source of nextpdf/nextpdf so it can run on PHP 8.1 and, for the core, PHP 7.4. It uses Rector’s built-in downgrade sets for most language features, plus a small set of custom rules for features Rector does not cover.

This guide shows you how to add a new custom rule. It follows the same structure as the three rules already in the repository: DowngradeAsymmetricVisibilityRector, DowngradeCloneWithRector, and DowngradeTraitConstantsRector. All three live in rector/rules/, are registered in rector/config/, and have fixture-based tests in tests/Rector/.

Use this guide when a NextPDF feature uses PHP syntax that the build target does not support and Rector has no built-in downgrade for it. Before you start, confirm that Rector truly lacks a rule. The built-in withDowngradeSets() chain already handles readonly classes, typed class constants, the pipe operator, and many other features.

You write and run custom rules inside the nextpdf-backport repository. Its composer.json declares the development tools.

  1. Clone the backport repository and install its dependencies.
  2. Confirm Rector and PHPUnit resolve.
Terminal window
git clone https://github.com/nextpdf-labs/backport.git
cd backport
composer install
Terminal window
vendor/bin/rector --version
vendor/bin/phpunit --version

The repository requires PHP 8.4 to run because the rules manipulate 8.4 syntax trees, even though the build output targets PHP 8.1 or 7.4. The composer.jsonrequire pins php to >=8.4 <9.0. Custom rules autoload under the NextPDF\Backport\ namespace, which maps to rector/rules/; tests autoload under NextPDF\Backport\Tests\, which maps to tests/.

Every custom rule extends Rector\Rector\AbstractRector and implements three methods. The contract is the same for all three existing rules.

MemberTypePurpose
getRuleDefinition()RuleDefinitionA human-readable description and before/after CodeSample pairs for the rule documentation generator.
getNodeTypes()array<class-string<Node>>The Abstract Syntax Tree (AST) node classes the rule visits. Rector calls refactor() only for these classes.
refactor(Node $node)?Node or `Stmt[]null`

The node-type declaration drives the rule. DowngradeAsymmetricVisibilityRector targets [Property::class, Param::class] because asymmetric visibility (public private(set)) can appear on both a class property and a promoted constructor parameter. DowngradeTraitConstantsRector targets [Trait_::class] because it rewrites the whole trait body in one pass. DowngradeCloneWithRector targets [FileNode::class, Return_::class, Expression::class] because clone-with (clone($obj, [...])) can appear in both return and assignment positions; it uses the FileNode visit to reset a per-file counter.

A rule that returns null from refactor() signals “no change”. A rule that returns a node signals a replacement. A rule that returns Stmt[], a list of statements, expands one statement into several. That is how DowngradeCloneWithRector turns a single return clone($this, [...]); into a clone assignment, one property assignment per override, and a final return.

The two pipeline configs call ->withDowngradeSets(php81: true) and ->withDowngradeSets(php74: true). Those sets chain every built-in downgrade rule for the target. Custom rules exist only for the gaps: PHP 8.4 asymmetric visibility, PHP 8.5 clone-with, and PHP 8.2 trait constants. Rector does not downgrade any of these on its own. Write a custom rule only after you confirm the same gap.

This procedure adds a new rule. The running example mirrors the existing rules; substitute your own feature.

  1. Create the rule class in rector/rules/. Name it Downgrade<Feature>Rector and place it in the NextPDF\Backport namespace so the existing autoload mapping picks it up.
  2. Extend Rector\Rector\AbstractRector and mark the class final.
  3. Implement getNodeTypes() to return the narrowest set of AST node classes the rule needs. A narrower set makes Rector visit fewer nodes.
  4. Implement refactor(). Assert that the node is one of the declared types, guard for the exact syntax you target, transform it, and return the new node, Stmt[], or null for no change.
  5. Implement getRuleDefinition() with one before/after CodeSample for each distinct case the rule handles.
  6. Keep the file at PHPStan Level 10: type every parameter, return, and property, and use PHPDoc generics to describe array shapes.

The asymmetric-visibility rule is the smallest complete example. It removes the set-visibility flags and makes sure a base read visibility remains:

rector/rules/DowngradeAsymmetricVisibilityRector.php
<?php
declare(strict_types=1);
namespace NextPDF\Backport;
use PhpParser\Modifiers;
use PhpParser\Node;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt\Property;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
final class DowngradeAsymmetricVisibilityRector extends AbstractRector
{
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
'Remove asymmetric visibility modifiers (public private(set) -> public)',
[
new CodeSample(
'public private(set) float $x = 0.0;',
'public float $x = 0.0;',
),
],
);
}
/**
* @return array<class-string<Node>>
*/
public function getNodeTypes(): array
{
return [Property::class, Param::class];
}
public function refactor(Node $node): ?Node
{
\assert($node instanceof Property || $node instanceof Param);
if (($node->flags & Modifiers::VISIBILITY_SET_MASK) === 0) {
return null;
}
$node->flags &= ~Modifiers::VISIBILITY_SET_MASK;
if (($node->flags & Modifiers::VISIBILITY_MASK) === 0) {
$node->flags |= Modifiers::PUBLIC;
}
return $node;
}
}

The guard on the first if is critical. When the property has no set-visibility flag, the rule returns null and leaves the node unchanged. A rule that transforms unconditionally would rewrite code it should leave alone.

Each rule has a fixture-based test that runs the rule against .php.inc files and asserts that the output matches. The harness comes from Rector’s Rector\Testing\PHPUnit\AbstractRectorTestCase.

A test case is small and consistent across the three existing rules:

tests/Rector/DowngradeTraitConstantsRectorTest.php
<?php
declare(strict_types=1);
namespace NextPDF\Backport\Tests\Rector;
use Iterator;
use PHPUnit\Framework\Attributes\DataProvider;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
final class DowngradeTraitConstantsRectorTest extends AbstractRectorTestCase
{
#[DataProvider('provideData')]
public function test(string $filePath): void
{
$this->doTestFile($filePath);
}
public static function provideData(): Iterator
{
return self::yieldFilesFromDirectory(__DIR__ . '/Fixtures/DowngradeTraitConstants');
}
public function provideConfigFilePath(): string
{
return __DIR__ . '/config/downgrade_trait_constants.php';
}
}

The test points to a per-rule config file in tests/Rector/config/ that registers only the rule under test, so each fixture exercises one rule in isolation:

tests/Rector/config/downgrade_trait_constants.php
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeTraitConstantsRector;
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withRules([
DowngradeTraitConstantsRector::class,
]);

A fixture is a .php.inc file with the input, a ----- separator, and the expected output. When the rule makes no change, omit the separator and the second block. A transforming fixture for the trait-constants rule looks like this:

tests/Rector/Fixtures/DowngradeTraitConstants/private_constant.php.inc
<?php
trait HasLimit
{
private const MAX_SIZE = 1024;
public function getLimit(): int
{
return self::MAX_SIZE;
}
}
?>
-----
<?php
trait HasLimit
{
private static $MAX_SIZE = 1024;
public function getLimit(): int
{
return self::$MAX_SIZE;
}
}
?>

To author a new rule’s tests:

  1. Create tests/Rector/Fixtures/Downgrade<Feature>/ and add one .php.inc per case.
  2. Cover both transforming cases with a ----- separator and skip cases with no separator, such as a property with no set visibility that must pass through unchanged.
  3. Add a tests/Rector/config/downgrade_<feature>.php that registers only your rule.
  4. Add a tests/Rector/Downgrade<Feature>RectorTest.php that yields the fixture directory and points at the config.
  5. Run the suite.
Terminal window
composer test

The repository also includes RectorRulesBehaviorTest and RectorRulesMetadataTest, which assert cross-rule behavior and confirm that each rule’s getRuleDefinition() is well formed. Run the full composer test so those gates see your new rule.

A rule is not active in the build until you register it in the pipeline configs. There are two build targets, each with its own config in rector/config/.

  1. Open rector/config/rector-php81.php and add your rule class to the ->withRules([...]) list.
  2. If the feature must also be downgraded for the PHP 7.4 core build, add the same class to rector/config/rector-php74.php.
  3. Add a comment naming the PHP version that introduced the feature, matching the existing entries.
rector/config/rector-php81.php
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeAsymmetricVisibilityRector;
use NextPDF\Backport\DowngradeCloneWithRector;
use NextPDF\Backport\DowngradeTraitConstantsRector;
use Rector\Config\RectorConfig;
use Rector\DowngradePhp81\Rector\Property\DowngradeReadonlyPropertyRector;
return RectorConfig::configure()
->withDowngradeSets(php81: true)
->withRules([
// PHP 8.4 — asymmetric visibility (not covered by built-in sets)
DowngradeAsymmetricVisibilityRector::class,
// PHP 8.4 — clone-with syntax (not covered by built-in sets)
DowngradeCloneWithRector::class,
// PHP 8.2 — trait constants (not covered by built-in sets)
DowngradeTraitConstantsRector::class,
// PHP 8.1 — readonly property removal (for clone-with expansion safety)
DowngradeReadonlyPropertyRector::class,
]);

The build orchestrator (scripts/build.php) merges the source repositories, runs Rector with these configs, adjusts the generated composer.json, and runs a php -l syntax check over the output. Verify your rule with PHPStan and the full build before you rely on it.

Terminal window
composer analyse
composer build:dry
  • Registration order does not matter, but rule order does conceptually. Rector’s multi-pass mechanism re-traverses until no rule makes another change, so you do not hand-order rules in the config. Even so, document any ordering dependency in the class docblock, as DowngradeCloneWithRector does: its expansion produces $clone->prop = $val, which would fail on a readonly property, so DowngradeReadonlyPropertyRector must run for the same target.
  • Pass empty attributes when building a replacement node from a different node kind. DowngradeTraitConstantsRector builds a Property from a ClassConst and passes [] for attributes instead of the source node’s attributes. If you carry over the original attributes, you leave an origNode pointer to the wrong node kind and trip an assertion in the format-preserving printer.
  • Reset per-file state on the FileNode visit. DowngradeCloneWithRector declares FileNode::class in getNodeTypes() only to reset its temporary-variable counter at the start of each file, so generated variable names do not collide across files.
  • Guard precisely, then return null. A clone-with transform must confirm that the call name is clone and that the second argument is an array literal before acting; a plain clone $obj never reaches the rule as a function call, and a two-argument call whose second argument is not an array is left alone.
  • Strip modifiers the target cannot express. When the trait-constants rule turns a constant into a static property, it preserves visibility and adds static, but it must not carry a final modifier because PHP 8.1 properties cannot be final.
  • Keep the rule at PHPStan Level 10. The repository runs composer analyse at level 10 over rector/rules and scripts. Type every signature and annotate array shapes; a rule that would not survive the analyser is a defect, not a draft.