Skip to content

Replacing Constants with Enums Using Rector

Posted by author in the category "rector"

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// Before
2$value = Suit::HEARTS;
3 
4// After
5$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 ConfigurableRectorInterface
18{
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(): RuleDefinition
30 {
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(): array
40 {
41 return [ClassConstFetch::class];
42 }
43 
44 public function refactor(Node $node): ?Node
45 {
46 // No refactor, if the node is not a class constant fetch
47 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 reference
52 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 registered
62 $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 import
69 $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): void
83 {
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}
End of article