rwxr--r--
/dev/blog

Bez Hermoso, Software Engineer @ Square

There seems to be quite a sizable portion of the PHP community that think that PHP annotations are a bad idea. However, there is also a sizable portion that think that PHP annotations is not evil, and maybe actually a godsend, if the wide-spread usage is of any indication.

Personally I acknowledge that using them can be pain to deal with in when used in the wrong places, but I also think that it has a place in a limited set of areas. It provides a lot of convenience with almost to zero drawbacks if used within the bounds of the domain exclusive to your app, but it can cause a lot of coupling when used to define metadata on third-party libraries/bundles that you hope to plug into your app, making it brittle. I think this is rooted in the fact that there is currently no easy way to override configuration specified via annotations, or actually to support such mechanism.

There is also the problem that the configuration will reside where the subjected class is on. This poses a huge problem when you try to add configuration on a third-party class. Going in and modifying a class shipped by a third-party library is obviously a very horrible idea. I was faced with this problem when trying to document a REST API I was writing using Swagger-PHP: since I was using FOSUserBundle and extending its User class for use in my app, I was faced with two possible approaches:

  1. Add @Swagger\* annotations within FOS\UserBundle\Model\User itself.
  2. Extend FOS\UserBundle\Model\User and re-declare the properties just for the sole purpose of annotating them.

Option 1 shouldn’t even be a consideration, and option 2 doesn’t feel right, either. If only Swagger-PHP supports configuration via YAML or XML files…

A rule of thumb that I follow is that I only ever use annotations within the “user-land”, or on application-specific classes (i.e. under src/<your app specific vendor>). Anything outside that is off-limits. I think a library that wishes to construct class metadata from annotations should also support configuration via other resource files. I think annotations should only be thought as a secondary option in defining domain metadata and not the sole option.

This post is a continuation of this. A quick recap: we wrote an TheHunt\SitemapBundle which reads YAML files to generate links for use in a site-map. In this post we will compliment the link collection logic by adding support on specifying links through PHP annotations.

The first task is creating an annotation class specific for our use:

<?php

namespace TheHunt\SitemapBundle\Annotation;

use Doctrine\Common\Annotations\Annotation;

/**
 * @Annotation
 * @Target({"METHOD"})
 */
class Link
{
    private $title;

    private $params = array();

    private $updated;

    private $sections = array();

    public function __construct(array $options)
    {
        if (empty($options['title'])) {
            throw new \Exception('You must specify a title.');
        }

        if (empty($options['sections'])) {
            throw new \Exception('You must specify at least one section.');
        }

        $this->title = $options['title'];
        $this->sections = $options['sections'];
        $this->updated = empty($options['updated']) ? strtotime('Y-m-d H:i:s') : $options['updated'];

        if (!empty($options['params'])) {
            $this->params = $options['params'];
        }

    }

    public function getTitle()
    {
        return $this->title;
    }

    public function getParameters()
    {
        return $this->params;
    }

    public function getUpdated()
    {
        return $this->updated;
    }

    public function getSections()
    {
        return $this->sections;
    }
}

Notice that we added @Annotation in the the class doc-block. This serves as some sort of a tag which signifies that we can use this class as a valid annotation. The @Target({"METHOD"}) simply declares that this annotation can only be used on class methods.

We would use it like this:

<?php

use TheHunt\SitemapBundle\Annotation\Link;

class FAQController
{
    /**
     * @Link(title="FAQs", params={"_locale" = "en"}, sections={"footer"}, updated="2014-05-01")
     */
    public function indexAction()
    {
        ...

In the case of the above example, "FAQs" will be assigned to $params['title'], array("footer") to $params['sections'], and array('_locale' => 'en'), $params['params'], etc. in our own annotation’s constructor. Straight-forward, isn’t it? You can look back at the constructor’s code and see how these values are used.

Extracting annotations is straight-forward as well:

<?php

$reader = new \Doctrine\Common\Annotations\AnnotationReader();
$refClass = new \ReflectionClass('SomeController');
$refMethod = $refClass->getMethod('someAction');
$link = $reader->getMethodAnnotation($refMethod, 'TheHunt\\SitemapBundle\\Annotation\\Link');

var_dump($link->getTitle()); // string(4) "FAQs"

AnnotationReader is available in the Doctrine Annotations library. (Available via Composer of course as doctrine/annotations)

Its important to note that the annotations library require us to actually configure an exclusive autoloader to autoload annotation classes. This is done by interfacing with the Doctrine\Common\Annotations\AnnotationRegistry class. However if you are using Symfony, this is already taken care of (you can check app/autoload.php to see how its done.)

Putting it to use…

In order to integrate this to our TheHunt\SitemapBundle, we’ll create another link collector:

<?php

use TheHunt\SitemapBundle\Sitemap;

use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Doctrine\Common\Annotations\AnnotationReader;

class AnnotationLinkCollector
{
    protected $router;

    protected $container;

    protected $reader;

    const ANNOTATION_CLASS = 'TheHunt\\SitemapBundle\\Annotation\\Link';

    public function __construct(AnnotationReader, RouterInterface $router, ContainerInterface $container)
    {
        $this->reader = $reader;
        $this->router = $router;
        $this->container = $container;
    }

    public function getLinks()
    {
        if (null === $this->links) {

            $this->links = array();

            foreach ($this->router->getRouteCollection()->all() as $name => $route) {

                $method = $this->getReflectionMethod($route->getDefault('_controller'));

                /* Extract @Link annotation */
                if (null === ($link =
                    $this->reader->getMethodAnnotation($method, static::ANNOTATION_CLASS))) {
                    continue;
                }

                $this->links[] = array(
                    'title' => $link->getTitle(),
                    'sections' => $link->getSections(),
                    'href' =>
                        $this->router->generate($name, $link->getParameters(), UrlGeneratorInterface::ABSOLUTE_URL),
                    'updated' => $link->getUpdated(),
                );
            }
        }

        return $this->links;
    }

    /**
     * Parses a controller string
     * (i.e. Foo\BarBundle\Controller\IndexController::indexAction, or service.id:indexAction)
     * and creates an appropriate \ReflectionMethod instance.
     *
     * @return \ReflectionMethod
     */
    protected function getReflectionMethod($controller)
    {
        if (strpos($controller, '::') !== false) {
            list($controller, $method) = explode('::', $controller);
            $refClass = new \ReflectionClass($controller);
            return $refClass->getMethod($method);
        } elseif (strpos($controller, ':') !== false) {
            list($service, $method) = explode(':', $controller);
            $refObject = new \ReflectionObject($this->container->get($service));
            return $refClass->getMethod($method);
        } else {
            throw new \InvalidArgumentException('Invalid controller.');
        }
    }
}

The service configuration for this class would look something like:

services:
    thehunt_sitemap.annotation_link_collector:
        class: TheHunt\SitemapBundle\Sitemap\AnnotationLinkCollector
        arguments:
            - @annotation_reader
            - @router
            - @service_container

Now we have a service that will return sitemap links based on metadata defined by the use of our own @Link annotation.

Caching

The annotation_reader service in Symfony is actually an instance of Doctrine\Common\Annotations\FileCacheReader, which already caches annotations as long as the files are not being modified. However, we still have to maintain our own cache of our underlying domain data which is the collection of links.

As it stands, our AnnotationLinkCollector doesn’t have a caching couterpart like the YAML reading LinkCollector does. We could create a CachingAnnotationLinkCollector, but that would just entail reproducing logic from the other class, thereby violating DRY. That won’t be the right approach. We’ll remedy this by some manageable amount of refactoring which I will detail on a future blog post.

Annotations on the classes, properties, and within another annotation

The @Link annotation we created only applies to methods. However, you are probably aware that annotations can be added on the class-level or on properties as well. Extracting them is just as easy. You would simply need to extract the necessary \ReflectionClass or the \ReflectionProperty and call the appropriate methods in AnnotationReader. All you need to know you would find here and here.

Another possible use of annotations is nesting them within another. So far I am have only stumbled on one library that actually uses this, which is Swagger-PHP.

comments powered by Disqus