Earlier I wrote about how to create a custom annotation class. I used the annotation reader from Doctrine Common to check for the existence of the custom annotation inside the DocComment block. It is also possible to process the annotations on beforehand, and collect the processed data in ClassMetadata and PropertyMetadata objects. These objects are created by a MetadataFactory. The factory uses Drivers to collect the metadata.

My purpose in this article is to create a custom annotation @DefaultValue which allows me to define default values for properties of a class. It should work like this:

namespace Matthias\AnnotationBundle\Data;

use Matthias\AnnotationBundle\Annotation\DefaultValue;

class SomeClass
{
    /**
     * @DefaultValue("Matthias Noback")
     */
    private $name;
}

At the end of this post, we should be able to automatically process an instance of this class, so that the default value "Matthias Noback" will be copied to the "name" property.

A new custom annotation class: "DefaultValue"

First, we need a custom annotation class. We can either extends this class from Doctrine\Common\Annotations\Annotation or add @Annotation to the DocComment block (I chose the latter):

namespace Matthias\AnnotationBundle\Annotation;

/**
 * @Annotation
 */
class DefaultValue
{
    public $value;

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

The @DefaultValue annotation has one, unnamed argument. By default this argument is called "value" and it's value will be stored in the public property "value" (this should be clear).

Define the PropertyMetadata class

Next, we need a PropertyMetadata class, in which we can store the default value that is specified by the @DefaultValue annotation:

namespace Matthias\AnnotationBundle\Metadata;

use Metadata\PropertyMetadata as BasePropertyMetadata;

class PropertyMetadata extends BasePropertyMetadata
{
    public $defaultValue;
}

Creating the AnnotationDriver

Now we need an AnnotationDriver which knows how to convert @DefaultValue annotations into PropertyMetadata objects. It should look like this:

namespace Matthias\AnnotationBundle\Metadata\Driver;

use Metadata\Driver\DriverInterface;
use Metadata\MergeableClassMetadata;
use Doctrine\Common\Annotations\Reader;
use Matthias\AnnotationBundle\Metadata\PropertyMetadata;

class AnnotationDriver implements DriverInterface
{
    private $reader;

    public function __construct(Reader $reader)
    {
        $this->reader = $reader;
    }

    public function loadMetadataForClass(\ReflectionClass $class)
    {
        $classMetadata = new MergeableClassMetadata($class->getName());

        foreach ($class->getProperties() as $reflectionProperty) {
            $propertyMetadata = new PropertyMetadata($class->getName(), $reflectionProperty->getName());

            $annotation = $this->reader->getPropertyAnnotation(
                $reflectionProperty,
                'Matthias\\AnnotationBundle\\Annotation\\DefaultValue'
            );

            if (null !== $annotation) {
                // a "@DefaultValue" annotation was found
                $propertyMetadata->defaultValue = $annotation->value;
            }

            $classMetadata->addPropertyMetadata($propertyMetadata);
        }

        return $classMetadata;
    }
}

As you can see, it receives the Doctrine Common annotation reader as a constructor argument. When asked for a ClassMetadata object, it loops through all the properties of the given object and asks the reader for a @DefaultValue annotation. If it exists, the specified value is retrieved, and stored for later use in the PropertyMetadata object.

The MetadataFactory

Next, we need a MetadataFactory which uses the AnnotationDriver to produce a ClassMetadata object, containing a collection of PropertyMetadata objects.

We can instantiate the necessary classes and dependencies ourselves, but we'd better depend on the service container for this. So we define a "metadata_factory" service, which receives as it's only driver the AnnotationDriver.

<?xml version="1.0" ?>
<container xmlns="https://symfony.com/schema/dic/services"
           xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="https://symfony.com/schema/dic/services https://symfony.com/schema/dic/services/services-1.0.xsd">
    <parameters>
        <parameter key="matthias_annotation.metadata_factory.class">Metadata\MetadataFactory</parameter>
        <parameter key="matthias_annotation.metadata.annotation_driver.class">Matthias\AnnotationBundle\Metadata\Driver\AnnotationDriver</parameter>
    </parameters>

    <services>
        <service id="matthias_annotation.metadata.annotation_driver" class="%matthias_annotation.metadata.annotation_driver.class%" public="false">
            <argument type="service" id="annotation_reader" />
        </service>
        <service id="matthias_annotation.metadata_factory" class="%matthias_annotation.metadata_factory.class%" public="false">
            <argument type="service" id="matthias_annotation.metadata.annotation_driver" />
        </service>
    </services>
</container>

We now have a MetadataFactory and we can ask it for a ClassMetadata object for a given object. This ClassMetadata object will contain PropertyMetadata objects which hold the default values as provided using the @DefaultValue annotation. Now, let's put all this to use!

For this post, I created a DefaultValueProcessor. The processor makes use of the MetadataFactory we defined above. It sets the values of the properties to the values provided by the @DefaultValue annotation:

namespace Matthias\AnnotationBundle\Data;

use Metadata\MetadataFactoryInterface;

class DefaultValueProcessor
{
    private $metadataFactory;

    public function __construct(MetadataFactoryInterface $metadataFactory)
    {
        $this->metadataFactory = $metadataFactory;
    }

    public function fillObjectWithDefaultValues($object)
    {
        if (!is_object($object)) {
            throw new \InvalidArgumentException('No object provided');
        }

        $classMetadata = $this->metadataFactory->getMetadataForClass(get_class($object));
        /* @var $metadata \Matthias\AnnotationBundle\Metadata\ClassMetadata */

        foreach ($classMetadata->propertyMetadata as $propertyMetadata) {
            /* @var $propertyMetadata \Matthias\AnnotationBundle\Metadata\PropertyMetadata */
            if (isset($propertyMetadata->defaultValue)) {
                $propertyMetadata->setValue($object, $propertyMetadata->defaultValue);
            }
        }

        return $object;
    }
}

We should provide the processor with the necessary MetadataFactory using the following service definition:

<service id="matthias_annotation.default_value_processor" class="Matthias\AnnotationBundle\Data\DefaultValueProcessor">
    <argument type="service" id="matthias_annotation.metadata_factory" />
</service>

Let's take the class from the top of this article:

namespace Matthias\AnnotationBundle\Data;

use Matthias\AnnotationBundle\Annotation\DefaultValue;

class SomeClass
{
    /**
     * @DefaultValue("Matthias Noback")
     */
    private $name;
}

Now, if the service container is available as $this->container (for example inside a controller), try the following:

use Matthias\AnnotationBundle\Data\SomeClass;

// ...

$processor = $this->container->get('matthias_annotation.default_value_processor');
$object = new SomeClass;
$processor->fillObjectWithDefaultValues($object);

print_r($object);

This will output:

Matthias\AnnotationBundle\Data\SomeClass Object
(
    [name:Matthias\AnnotationBundle\Data\SomeClass:private] => Matthias Noback
)

In my next post I will explain how to optimize performance using a (file) cache and we will create another driver, which retrieves data from a method of the class itself.

PHP Symfony2 annotations DocComment Doctrine Common reflection
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
Hannah Carter

Thanks for this article - really helped me figure out how to use annotations within my applications. Will definitely be bookmarking your site and buying your book!

Matthias Noback

That's great!

Johannes

Good article.. More of this deeper Symfony2/Doctrine stuff written this way!
Very helpful, your articles!

Luciano Mammino

Such a wonderful article, i was looking for a clear explanation like this!
Thanks

Matthias Noback

Thank you!

Hemma731

Really nice post.
Eventhought, I can't find out where the Metadata\MergeableClassMetadata class comes from. I don't have this class in my vendor folder of symfony. Could you please light me up on this point ?

Matthias Noback

You probably need to upgrade the metadata library to version 1.1. The serializer requires this version already so I didn't mention it in the post.

Lars Strojny

@Jonathan: you could register a configurator in the container (maybe via compiler pass) and retrieve your classes from the container.

Jonathan

Good timing considering that I was just thinking about how to use a custom annotation for myself.

I wonder if there's a more elegant solution than having to call: "$processor->fillObjectWithDefaultValues($object)"