Skip to content

[CodeQuality] Add LoadValidatorMetadataToAttributeRector, deprecate the annotation variant - #988

Merged
TomasVotruba merged 2 commits into
mainfrom
load-validator-metadata-to-attribute
Aug 2, 2026
Merged

[CodeQuality] Add LoadValidatorMetadataToAttributeRector, deprecate the annotation variant#988
TomasVotruba merged 2 commits into
mainfrom
load-validator-metadata-to-attribute

Conversation

@TomasVotruba

@TomasVotruba TomasVotruba commented Aug 2, 2026

Copy link
Copy Markdown
Member

LoadValidatorMetadataToAnnotationRector moves loadValidatorMetadata() into a docblock. Per the vendored symfony/validator CHANGELOG:

  • 5.2.0 — "enabled the validator to load constraints from PHP attributes"
  • 6.4 — "Deprecate Doctrine annotations support in favor of native attributes", AnnotationLoaderAttributeLoader

So the rule emits a form Symfony deprecated two majors ago. This replaces it with an attribute-writing rule.

Before / after

 final class SomeClass
 {
-    private $city;
-
-    public static function loadValidatorMetadata(ClassMetadata $metadata): void
-    {
-        $metadata->addPropertyConstraint('city', new Assert\NotBlank([
-            'message' => 'City can\'t be blank.',
-        ]));
-    }
+    #[Assert\NotBlank(message: 'City can\'t be blank.')]
+    private $city;
 }

Getter constraints land on the getter, class constraints on the class, and the loadValidatorMetadata() method is dropped once every statement has moved.

Placement is checked, not assumed

Presence of #[Attribute] alone is not enough — the target flags differ per constraint, and the rule writes to three different places:

// NotBlank.php:24
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]

// Callback.php:22
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]

Only 10 of the 70 constraints in symfony/validator allow TARGET_CLASS. Writing #[Assert\NotBlank] on a class would crash the validator on load, so ConstraintAttributeTargetAnalyzer checks the flags for each placement:

// skipped - NotBlank does not target classes
$metadata->addConstraint(new Assert\NotBlank());

The same check covers custom constraints written before 5.2, which carry no #[Attribute] and would fail with "Attempting to use non-attribute class" once the loader instantiates them:

// skipped - no #[Attribute] on the constraint
$metadata->addPropertyConstraint('city', new NonAttributeConstraint());

Anything that cannot be placed stays in loadValidatorMetadata(), so a partial move keeps the method:

 final class PartialMoveKeepsClassMethod
 {
+    #[\Symfony\Component\Validator\Constraints\NotBlank]
     private $city;

     public static function loadValidatorMetadata(ClassMetadata $metadata): void
     {
-        $metadata->addPropertyConstraint('city', new Assert\NotBlank());
         $metadata->addPropertyConstraint('unknownProperty', new Assert\NotBlank());
     }
 }

Named arguments

The options array is emitted as named arguments, matching what ConstraintOptionsToNamedArgumentsRector (symfony73-validator) produces for new expressions. That rule operates on New_ and cannot reach attribute args, so without this the new rule would emit a form the repo has another rule to eliminate.

#[\Symfony\Component\Validator\Constraints\CardScheme(schemes: ['VISA', 100])]

A non-string key or a list (new Assert\Callback(['validateCity'])) keeps the array verbatim — there the array is the value, not options.

Version binding

ComposerPackageConstraintInterfacesymfony/validator >= 5.2. Registered in symfony-code-quality only, replacing the old rule. The constraint gates the rule at runtime wherever it is registered, so composer-based is left untouched.

Deprecation

LoadValidatorMetadataToAnnotationRector keeps its class and RuleDefinition, implements DeprecatedInterface, refactor() throws pointing at the new rule.

Its support classes had no other callers and are removed: ClassAnnotationAssertResolver, MethodCallAnnotationAssertResolver, PropertyAnnotationAssertResolver, DoctrineAnnotationFromNewFactory, DoctrineAnnotationKeyToValuesResolver, StringValueQuoteWrapper, ClassMethodAndAnnotation, PropertyAndAnnotation. StmtMethodCallMatcher is reused by the new resolver and moved to NodeAnalyzer/ValidatorAssert/. AnnotationOrAttributeValueResolver stays — still used by ThisRenderFactory.

…he annotation variant

The validator loads constraints from PHP attributes since Symfony 5.2,
and Doctrine annotations are deprecated since Symfony 6.4, so moving
loadValidatorMetadata() to a docblock produces a form that is on its
way out.

The new rule writes attributes instead, converting the options array to
named arguments. Each placement is checked against the constraint's
#[Attribute] target flags first, as only 10 of the 70 core constraints
allow TARGET_CLASS, and constraints written before 5.2 carry no
#[Attribute] at all. Constraints that cannot be placed stay in
loadValidatorMetadata().

The rule is bound to symfony/validator >= 5.2.
@TomasVotruba
TomasVotruba merged commit 3c7b44c into main Aug 2, 2026
7 checks passed
@TomasVotruba
TomasVotruba deleted the load-validator-metadata-to-attribute branch August 2, 2026 11:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant