rector/rector-downgrade-php
rector/rector-downgrade-php provides Rector rules for downgrading PHP code, automatically rewriting modern syntax and features into older, compatible versions. Useful for backporting projects or libraries to legacy PHP runtimes with repeatable transformations.
What it is: a Rector ruleset that rewrites newer PHP syntax/semantics into older-PHP-compatible code. Typical use case: you want to develop using PHP 8.0+ features (named args, match, nullsafe, constructor promotion, enums, readonly, first-class callables) but still ship code that runs on PHP 7.4 (a library with a wide support matrix, a plugin targeting legacy hosts, a package distributed as source). It operates on your source, not vendor/.
Minimal install:
composer require --dev rector/rector rector/rector-downgrade-php
First config (rector-downgrade.php) — keep this separate from your normal refactoring config:
<?php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src'])
->withDowngradeSets(php74: true); // target PHP 7.4
Older Rector API uses set-list classes directly (these live in this package):
$rectorConfig->sets([
\Rector\DowngradePhp74\Set\DowngradePhp74SetList::class,
\Rector\DowngradePhp80\Set\DowngradePhp80SetList::class,
]);
First use case: downgrade one file and diff it before committing to the approach:
vendor/bin/rector process src/Foo.php --config rector-downgrade.php --dry-run
vendor/bin/rector process src/Foo.php --config rector-downgrade.php
Where to look first: the per-version Set\DowngradePhp*SetList classes — they enumerate exactly which rules run, which tells you what is and isn't covered. Then browse the rule classes themselves (DowngradeMatchToSwitchRector, DowngradeNullsafeToTernaryOperatorRector, DowngradeNamedArgumentRector, DowngradeConstructorRector, …) to see the transformation each performs. Confirm you're running a Rector version aligned with the set lists you're using.
Two-config workflow (recommended). Keep modern code in src/, keep a rector.php for ordinary refactoring, and a rector-downgrade.php used only at release time or in a dedicated CI job. Never merge downgrade sets into your everyday config, or you'll silently lose the modern syntax you're writing against.
Build-artifact downgrade, not source downgrade. If you develop in 8.0+ and ship a 7.4 tarball, run downgrade against a copy of the tree, then package:
rsync -a --exclude vendor ./ ./build/
vendor/bin/rector process build/src --config rector-downgrade.php
This keeps your working tree readable and makes the downgrade step auditable and repeatable. Commit the generated output only if the artifact is what you publish (e.g. a library distributed without a build step).
CI gate in two modes:
# 1. fail the build if any un-downgradable modern syntax slipped in
vendor/bin/rector process src --config rector-downgrade.php --dry-run
# 2. or apply and assert the diff is empty (downgrade is idempotent)
vendor/bin/rector process src --config rector-downgrade.php
git diff --exit-code
The --dry-run gate is the cheapest early warning that a new language feature in your source isn't covered by a rule.
Granularity when a set is too aggressive. Register individual rules instead of a whole set list:
return RectorConfig::configure()
->withPaths([__DIR__ . '/src'])
->withRules([
\Rector\DowngradePhp80\Rector\Match_\DowngradeMatchToSwitchRector::class,
\Rector\DowngradePhp80\Rector\NullsafeMethodCall\DowngradeNullsafeToTernaryOperatorRector::class,
]);
Useful when you only need one construct lowered, or when one rule in a set misbehaves on your codebase.
Scope control. Always constrain withPaths() to your own code (src/, app/, lib/). Downgrading vendor/ will not make a PHP 8–only dependency run on 7.4, and it wastes iterations. Pair with withSkip() to exclude generated files, stubs, and fixtures that intentionally contain modern syntax.
Stacked targets. For a multi-version support matrix, downgrade in order (8.2 → 8.1 → 8.0 → 7.4) and run each stage separately; each stage's test suite should pass on its target interpreter before moving to the next.
Verification pattern that actually catches problems: run the downgraded output's test suite on the target PHP version (via a matrix job or Docker), not just the dry-run diff. Rector changing syntax is necessary but not sufficient — semantics can drift (see below).
match → switch changes comparison semantics. match uses strict ===; switch uses loose ==. If your arms rely on strict typing (e.g. 0 vs '0' vs false), review the generated switch or add explicit casts/if chains. This is the single most common silent behavior change.$a?->b()?->c()) expand into nested temporaries; check that the generated variables don't collide and that side-effecting calls still execute once....$args, or dynamically-named parameters may be left untouched or reordered incorrectly. Inspect DowngradeNamedArgumentRector output on factory/DI code especially.ReflectionProperty) can behave differently. Re-run integration tests for anything that introspects objects.git diff --exit-code CI pattern rather than repeated in-place runs.--clear-cache) if you see stale or missing changes. When in doubt, -vvv --debug shows which rules fired per file.rector/rector repo — pin both to compatible versions (composer require --dev rector/rector:^X rector/rector-downgrade-php:^X) and upgrade deliberately. Rules and set-list namespaces shift between majors, and functionality may migrate into the core package.#[Attribute], #[\Override], DI attributes, and route attributes get commented out or dropped in the downgrade. If your framework relies on attributes at runtime on 8.0+ but you're also shipping a 7.4 build, ensure the 7.4 code path doesn't depend on them (annotations, config arrays, or conditional loading).RectorInterface rule (or extend the abstract rule base) and register it with ->withRules([...]). This is the intended escape hatch — check the existing rule naming/structure in this package before writing one, since many are small and easy to model on.How can I help you explore Laravel packages today?