Replacing Constants with Enums Using Rector
Rector is a powerful tool with a large set of rules to update your codebase to modern standards. Over time, I've come across the same situation more than once. In codebases that predate PHP enums, this was often managed using a set of public constants as a poor manās enum.
When the codebase eventually moves to a PHP version with enum support, the constants are usually mirrored into a new enum type and deprecated. Replacing all the usages of the constants is postponed as tech debt for a later sprint.
Seeing this another time around, I used ChatGPT to quickly draft a proof of concept. It quickly became clear that Rector was the right tool for the job and the LLM helped with the first version, replacing the before code in the example below with the after version.
1// Before2$value = Suit::HEARTS;3 4// After5$value = SuitEnum::Hearts->value;
After iteratingāadding configurability, handling self-references, and ensuring deterministic outputāI ended up with a rule that does all the lifting, produces deterministic results and helps us get rid of these deprecated constants.
My takeaway from the process was that Rector provides a very good developer experience and making your own rule is not
that difficult. The refactor() function of the rule lets you focus directly on the cases you want to transform and
simply return null when you want to skip the current node that is being inspected.
An example of the configuration and final rule I now use to migrate code away from deprecated constants looks as follows:
1# ./rector.php 2return RectorConfig::configure() 3 ->withPaths([__DIR__ . '/src']) 4 ->withSkipPath('./vendor') 5 ->withConfiguredRule(\App\Rector\ReplaceClassConstantWithEnumCaseRule::class, [ 6 'constClass' => Suit::class, 7 'enumClass' => SuitEnum::class, 8 'valueReplacements' => [ 9 'HEARTS' => 'Hearts',10 'DIAMONDS' => 'Diamonds',11 'CLUBS' => 'Clubs',12 'SPADES' => 'Spades',13 ]14 ])15 ->withSkip([16 // Skip the default set list to avoid unrelated changes.17 SetList::TYPE_DECLARATION,18 ]);
1# ./src/Rector/ReplaceClassConstantWithEnumCaseRule.php 2<?php 3 4declare(strict_types=1); 5 6namespace App\Rector; 7 8use PhpParser\Node; 9use PhpParser\Node\Expr\ClassConstFetch;10use Rector\CodingStyle\Node\NameImporter;11use Rector\Contract\Rector\ConfigurableRectorInterface;12use Rector\Rector\AbstractRector;13use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;14use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;15use Webmozart\Assert\Assert;16 17class ReplaceClassConstantWithEnumCaseRule extends AbstractRector implements ConfigurableRectorInterface18{19 private string $constClass = '';20 private string $enumClass = '';21 /** @var array<string, string> */22 private array $valueReplacements = [];23 24 public function __construct(25 private readonly NameImporter $nameImporter,26 ) {}27 28 29 public function getRuleDefinition(): RuleDefinition30 {31 return new RuleDefinition(sprintf('Replace %s::* constants with %s enum cases', $this->constClass, $this->enumClass), [32 new CodeSample(33 sprintf('$value = %s::%s;', $this->constClass, array_key_first($this->valueReplacements)),34 sprintf('$value = %s::%s->value;', $this->enumClass, array_first($this->valueReplacements))35 ),36 ]);37 }38 39 public function getNodeTypes(): array40 {41 return [ClassConstFetch::class];42 }43 44 public function refactor(Node $node): ?Node45 {46 // No refactor, if the node is not a class constant fetch47 if (! $node instanceof ClassConstFetch) {48 return null;49 }50 51 // No refactor, if the constant does not belong to the configured class or is not a self reference52 if (! $this->isName($node->class, $this->constClass) && $this->getName($node->class) !== 'self') {53 return null;54 }55 56 // No refactor, if the self is in a scope different from the const class we are refactoring.57 if ($this->getName($node->class) === 'self' && $node->getAttribute('scope')?->getClassReflection()->getName() !== $this->constClass) {58 return null;59 }60 61 // No refactor, if no replacement was registered62 $constName = $this->getName($node->name);63 $replacement = $this->valueReplacements[$constName] ?? null;64 if ($replacement === null) {65 return null;66 }67 68 // Create short name and trigger import69 $enumClass = new Node\Name\FullyQualified($this->enumClass);70 $shortName = $this->nameImporter->importName($enumClass, $this->file, []);71 if ($shortName === null) {72 return null;73 }74 75 return new Node\Expr\PropertyFetch(76 new ClassConstFetch(new Node\Name($shortName), $replacement),77 'value'78 );79 }80 81 /** @param array<array-key, mixed> $configuration */82 public function configure(array $configuration): void83 {84 Assert::keyExists($configuration, 'constClass');85 Assert::keyExists($configuration, 'enumClass');86 Assert::keyExists($configuration, 'valueReplacements');87 Assert::isArray($configuration['valueReplacements']);88 Assert::allString($configuration['valueReplacements']);89 Assert::allString(array_keys($configuration['valueReplacements']));90 91 $this->constClass = $configuration['constClass'];92 $this->enumClass = $configuration['enumClass'];93 $this->valueReplacements = $configuration['valueReplacements'];94 }95}