Defining PHP Annotations in XML

Annotations have become a popular mechanism in PHP to add metadata to your source code in a simple fashion. Their benefits are clear: They are easy to write and simple to understand. Editors offer increasing support for auto-completing and auto-importing annotations. But there are also various counter-arguments: Annotations are written in documentation blocks, which may be removed from packaged code. Also, they are coupled to the source code. Whenever an annotation is changed, the project needs to be rebuilt. This is desirable in some, but not in other cases.

For these reasons, Symfony always committed to supporting annotations, XML and YAML at the same time – and with the same capabilities – to let our users choose whichever format is appropriate to configure the metadata of their projects. But could we take this one step further? Could we build, for example, XML support directly into the Doctrine annotation library?

Let's start with a simple example of an annotated class:

namespace Acme\CRM;

use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\Entity;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotNull;

/**
 * @Entity
 */
class Address
{
    /**
     * @Column
     * @NotNull
     * @Length(min=3)
     */
    private $street;

    /**
     * @Column(name="zip-code")
     * @NotNull
     */
    private $zipCode;
}

Right now, if toolkits (such as Doctrine ORM or Symfony Validation) want to support annotations and XML schemas, they have to write separate parsers that duplicate a lot of common code. Wouldn't it be nice if they could use a generic parser instead?

Let's try to map the annotations to a generic XML file:

<?xml version="1.0" encoding="UTF-8"?>
<class-mapping xmlns="http://doctrine-project.org/schemas/annotations/class-mapping"
    xmlns:orm="http://doctrine-project.org/schemas/orm"
    xmlns:val="http://symfony.com/schema/dic/validation/constraint-mapping"
    xmlns:prop="http://symfony.com/schema/dic/property-access/property-mapping">

<class name="Acme\CRM\Address">
    <orm:entity />
    <property name="street">
        <orm:column />
        <val:not-null />
        <val:length min="3" />
    </property>
    <property name="zipCode">
        <orm:column name="zip-code" />
        <val:not-null />
    </property>
    <method name="activate">
        <prop:setter name="active" />
    </method>
</class>

</class-mapping>

As you can see, this is more or less an abstraction of Doctrine's XML Mapping. The base set of elements – <class-mapping>, <class>, <property> and <method> – is provided by the "http://doctrine-project.org/schemas/annotations/class-mapping" namespace and processed by AnnotationReader. The other namespaces are user-defined and processed by custom tag parsers. These turn tags into annotations for the currently processed element. Let's load the annotations:

// analogous to the existing AnnotationRegistry::registerAutoloadNamespace()
AnnotationRegistry::registerXmlMappings('/path/to/xml-mappings');
AnnotationRegistry::registerXmlNamespace('http://doctrine-project.org/schemas/orm', function () {
    return new OrmTagParser();
});
// ...

$reader = new AnnotationReader();

// Inspects doc blocks and registered XML files
$annotations = $reader->getClassAnnotations(new \ReflectionClass('Acme\CRM\Address'));
// => array(object(Doctrine\ORM\Mapping\Entity))

Due to XML's namespaces it's possible to combine all the mappings in one file or spread them across multiple files, if desired. So, one file could contain the ORM mapping only:

<!-- ORM mapping -->
<?xml version="1.0" encoding="UTF-8"?>
<map:class-mapping xmlns="http://doctrine-project.org/schemas/orm"
    xmlns:map="http://doctrine-project.org/schemas/annotations/class-mapping">

<map:class name="Acme\CRM\Address">
    <entity />
    <map:property name="street">
        <column />
    </map:property>
    <map:property name="zipCode">
        <column name="zip-code" />
    </map:property>
</map:class>

</map:class-mapping>

And another one the validation constraint mapping:

<!-- Constraint mapping -->
<?xml version="1.0" encoding="UTF-8"?>
<map:class-mapping xmlns="http://symfony.com/schema/dic/2.7/validation/constraint-mapping"
    xmlns:map="http://doctrine-project.org/schemas/annotations/class-mapping">

<map:use class="Acme\CRM\Validation\ZipCode" />

<map:class name="Acme\CRM\Address">
    <map:property name="street">
        <not-null />
        <length min="3" />
    </map:property>
    <map:property name="zipCode">
        <not-null />
        <map:annotation class="ZipCode">
            <map:parameter name="strict">true</map:parameter>
        </map:annotation>
    </map:property>
</map:class>

</map:class-mapping>

The disadvantage is that custom tag parsers (such as OrmTagParser above) need to be registered before loading annotations. The last example, however, shows a generic (although verbose) way of using custom annotations without writing a custom XML schema and parser.

The advantages are clear: The mapping files are very concise, can be validated against their XML schemas and can be separated from the PHP code. If you want to use annotations, but your users demand support for XML, it's very easy to write an XML schema and a tag parser for your annotations and plug it in. And at last, the class metadata configuration of different toolkits (Symfony and Doctrine in the above example) can be combined in just one file for small projects.

The above concept certainly has room for improvement: As it is right now, all XML files need to be located and parsed even when the annotations of just one class are loaded. Then again, I think that annotations shouldn't be parsed on every request anyway. If a toolkit parses annotations with the annotation reader, it should, in my opinion, cache the result somewhere or generate optimized PHP code to speed up subsequent page loads.

It would also be nice to provide a similar, unified annotation definition language for the YAML format. Since YAML doesn't natively support namespaces – as XML does – this is a bit more tricky.

What do you think? Are you interested in using or implementing such a feature?

Discussion