Doctrine Annotations and Attributes Living Together in Peace

In previous post How to Refactor Custom Doctrine Annotations to Attributes we looked on how to make the @annotation to #[Attribute] transition.

Last week, we started refactoring in my favorite long-term project, and we came to a challenging situation. When we started to move all annotations to attributes at once, we lost control over the results. It was also impossible because 3rd party annotations were not attributes ready.

We had to support annotations and attributes at the same time. Do you have plenty of custom annotations yourself? In this post, you'll learn to build a bridge with both annotations and attributes on board.

"United we stand,
Divided we fall"

Safety First

Before we start, we'll make sure we have instant feedback about code. We'll prepare a special kind of test designed for refactoring, where we need 2 mechanism to work at once - a bridge testing. In linked post we'll build a test that we use here, so if you're coding with me, be sure to read it first.

Teaching Annotation Attributes

We already know the first steps from previous post - add #[Attribute]:

/**
 * @Annotation()
 */
#[Attribute]
class AttentionPrice
{
    // ...

    public function __construct(array $values)
    {
        $this->amount = $values['amount'];
    }

    // ...
}

The problem is that annotation requires an array in the constructor - array $values. But attributes accept specific value:

    /**
     * @AttentionPrice(1000) // array with int
     */
    #[AttentionPrice(1000)] // int
    public $publicProperty;

What can we do about different construction needs?

2 Different Classes?

An annotation class with array contract:

/**
 * @Annotation()
 */
class AttentionPrice
{
    public function __construct(array $values)
    {
        // ...
    }
}

And an attribute class with int contract:

#[Attribute]
class AttentionPrice
{
    public function __construct(int $amount)
    {
        // ...
    }
}

2 Constructors?

    public function __construct($values)
    {
        // annotatoin
        if (is_array($values)) {
            $this->amount = $values['amount'];
        // attribute
        } elseif (is_int($values)) {
            $this->amount = $values;
        } else {
            // exception
        }
    }

Try both options, and you'll soon see that they both smell:

Hmm, what can we do now?


@NamedArgumentConstructor to the Rescue

We're lucky, doctrine/annotations got us covered since 1.12. I've learned this trick from Koriym - thanks!

+use Doctrine\Common\Annotations\NamedArgumentConstructor;

 /**
  * @Annotation()
+ * @NamedArgumentConstructor()
  */
+#[Attribute]
 class AttentionPrice
 {
     private int $amount;

-    public function __construct(array $values)
+    public function __construct(int $amount)
     {
         $this->amount = $amount;
     }

     // ...
 }

Thanks to the @NamedArgumentConstructor, the constructors are now the same for both annotation and attribute ✅


Big thanks to Alexander M. Turek, who contributed this feature for doctrine/annotations and Vincent who added support for default value unwrap.


Now we can use both annotation and attributes in our code with the same result:

    /**
     * @AttentionPrice(1000) // int
     */
    #[AttentionPrice(1000)] // int
    public $publicProperty;


Happy coding!




Do you learn from my contents or use open-source packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!