Authoring a custom Rector downgrade rule
At a glance
Section titled “At a glance”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.
Install
Section titled “Install”You write and run custom rules inside the nextpdf-backport repository. Its composer.json declares the development tools.
- Clone the backport repository and install its dependencies.
- Confirm Rector and PHPUnit resolve.
git clone https://github.com/nextpdf-labs/backport.gitcd backportcomposer installvendor/bin/rector --versionvendor/bin/phpunit --versionThe 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/.
Rule anatomy
Section titled “Rule anatomy”Every custom rule extends Rector\Rector\AbstractRector and implements three methods. The contract is the same for all three existing rules.
| Member | Type | Purpose |
|---|---|---|
getRuleDefinition() | RuleDefinition | A 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.
What the built-in sets already do
Section titled “What the built-in sets already do”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.
Step-by-step: write a rule
Section titled “Step-by-step: write a rule”This procedure adds a new rule. The running example mirrors the existing rules; substitute your own feature.
- Create the rule class in
rector/rules/. Name itDowngrade<Feature>Rectorand place it in theNextPDF\Backportnamespace so the existing autoload mapping picks it up. - Extend
Rector\Rector\AbstractRectorand mark the classfinal. - Implement
getNodeTypes()to return the narrowest set of AST node classes the rule needs. A narrower set makes Rector visit fewer nodes. - 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[], ornullfor no change. - Implement
getRuleDefinition()with one before/afterCodeSamplefor each distinct case the rule handles. - 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:
<?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.
Fixtures and testing
Section titled “Fixtures and testing”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:
<?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:
<?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:
<?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:
- Create
tests/Rector/Fixtures/Downgrade<Feature>/and add one.php.incper case. - 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. - Add a
tests/Rector/config/downgrade_<feature>.phpthat registers only your rule. - Add a
tests/Rector/Downgrade<Feature>RectorTest.phpthat yields the fixture directory and points at the config. - Run the suite.
composer testThe 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.
Wiring into the build
Section titled “Wiring into the build”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/.
- Open
rector/config/rector-php81.phpand add your rule class to the->withRules([...])list. - If the feature must also be downgraded for the PHP 7.4 core build, add the same class to
rector/config/rector-php74.php. - Add a comment naming the PHP version that introduced the feature, matching the existing entries.
<?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.
composer analysecomposer build:dryEdge cases & gotchas
Section titled “Edge cases & gotchas”- 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
DowngradeCloneWithRectordoes: its expansion produces$clone->prop = $val, which would fail on a readonly property, soDowngradeReadonlyPropertyRectormust run for the same target. - Pass empty attributes when building a replacement node from a different node kind.
DowngradeTraitConstantsRectorbuilds aPropertyfrom aClassConstand passes[]for attributes instead of the source node’s attributes. If you carry over the original attributes, you leave anorigNodepointer to the wrong node kind and trip an assertion in the format-preserving printer. - Reset per-file state on the
FileNodevisit.DowngradeCloneWithRectordeclaresFileNode::classingetNodeTypes()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 iscloneand that the second argument is an array literal before acting; a plainclone $objnever 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 afinalmodifier because PHP 8.1 properties cannot be final. - Keep the rule at PHPStan Level 10. The repository runs
composer analyseat level 10 overrector/rulesandscripts. Type every signature and annotate array shapes; a rule that would not survive the analyser is a defect, not a draft.
See also
Section titled “See also”- Backport Builder developer guide — the pipeline architecture, branch model, and release artifacts around these rules.
- Backport API reference — the published surface for the backport build tooling.
- Backport configuration — build targets and downgrade-set selection.
- Backport troubleshooting — how to diagnose failures in a generated downgrade tree.
- Rector documentation — the upstream reference for
AbstractRector,RuleDefinition, and the AST node classes used ingetNodeTypes().