Drupal 8 API: annotations

Pre-requisites

Using annotations to store data in comments

The Drupal coding standards have long recommended that some structure is applied to comments e.g: specifying parameters using @param; or using @ingroup to group e.g. theming-related functionality together.

However, annotations are fundamentally different: a standard syntax proposed by the Doctrine project, to embed structured data packets in comments. These data packets are intended not merely a useful note for future maintainers of the code, but to be parsed by Drupal: this effectively turns your comments into something where the syntax matters a lot more.

We encountered annotations previously, when writing a custom block; but here's a more illustrative example:

<?php
/**
 * This class has some data in an annotation.
 *
 * @CustomAnnotation(
 *   simple_string = "Some text in double quotes",
 *   an_array = {
 *     array_key = "Array value",
 *     another_key = "Another value",
 *   },
 *   nested_annotation = @AnotherAnnotation("Something else")
 * )
 */
class ThisClassIsAnnotated {
}

The above can be understood as a CustomAnnotation with three keyed properties: a simple string; an array of key/values; and a nested AnotherAnnotation with a single simple property.

Because annotations make comments sensitive to syntax errors—they're no longer just passive information for the human reader—they were quite controversial when they were first proposed for Drupal. Ultimately, though, there are no perfect solutions in PHP to the problem of providing metadata for classes or methods. Because annotations were an existing, externally supported solution, then they were adopted in most situations in Drupal where classes need metadata; for your own code, you could always provide metadata in some other way (if you were willing to do the custom development!)

How Doctrine parses annotations into separate annotation objects

One key advantage of using annotations is that the Doctrine project provides us with all the tools necessary to parse annotations. It does this by creating instances of a completely separate object, one for each annotation type. An annotation object can inherit all its behaviours from the Plugin annotation object. All that remains for a developer is to create public properties, one for each key in the annotation.

An annotation object compatible with the comment in the example above might be:

<?php
 
use Drupal\Component\Annotation\Plugin;
 
/**
 * Custom annotation object.
 */
class CustomAnnotation extends Plugin {
 
  /**
   * @var string
   */
  public $simpleString;
 
  /**
   * @var array
   */
  public $anArray;
 
  /**
   * @var AnotherAnnotation
   */
  public $nestedAnnotation;
 
}

Any properties defined are discovered by Plugin's behaviours, using reflection, so no further code is required.

To convert textual annotations in comments, into annotation objects, Doctrine must be able to discover any relevant annotation object classes, during its parsing of the comment text. In our example above, Doctrine would need to be able to find classes for both the annotations: CustomAnnotation and the nested AnotherAnnotation. Nested annotations let us specify e.g. translatable strings, and other potential nested quasi-objects that have behaviours and aren't just plain text. The namespaces on which Doctrine searches for annotation classes are stored in a registry.

When Doctrine's SimpleAnnotationReader is called upon to parse a class, the following must be provided:

  1. Set up a "file finder" to look for annotations. Because Drupal's naming conventions (discussed previously) dictate where a class file is to be found, we can provide a MockFileFinder, just to satisfy Doctrine.
  2. Set up a StaticReflectionParser to reflect upon the class file and parse its comments, giving it: the name of the class upon which there is an annotation comment; and the file finder.
  3. Register all of the namespaces where annotation classes might be found
  4. Ask the reader to return the class annotation as an annotation object, giving it: information from the parser; and the namespaced class of the "top-level" annotation (in the example above, Drupal\modulename\CustomAnnotation.)

The annotation object returned can then be interrogated to get all the structured data from the original comment.

Parsing a block annotation and extracting the untranslated admin label

We've previously written a simple block class TutorialBlock, which has an annotation in its class-level comment. Here's a reminder of what that looks like:

/**
 * Block for the tutorial.
 *
 * @Block(
 *   id = "tutorial_tutorial_block",
 *   admin_label = @Translation("Tutorial block")
 * )
 */

To parse this, we'll need to help Doctrine find its annotation classes. In normal block processing, Drupal sorts all this out for you within AnnotatedClassDiscovery::getAnnotationReader() and ::getDefinitions(). But we're going to build all this from scratch, which means we need to point Doctrine at the classes:

  • Drupal\Core\Block\Annotation\Block, for the top-level annotation object.
  • Drupal\Core\Annotation\Translation, for the embedded translatable string.

We actually just point Doctrine at the namespaces, so we drop the last component from the namespaced class names above and then register those.

Here's a class with a single method, which registers annotation class namespaces, clears the registry, sets up a parser and mock file finder, then parses the annotation on TutorialBlock and returns the untranslated string of the admin label:

<?php
 
namespace Drupal\d8api;
 
use Doctrine\Common\Annotations\AnnotationRegistry;
use Doctrine\Common\Annotations\SimpleAnnotationReader;
use Doctrine\Common\Reflection\StaticReflectionParser;
use Drupal\Component\Annotation\Reflection\MockFileFinder;
 
/**
 * Read an annotation on a hardwired class.
 */
class AnnotationReader {
 
  /**
   * Read annotation and return admin_label.
   *
   * @return string
   *   Untranslated admin_label.
   */
  public function retrieveAdminLabel() {
    // Tell Doctrine what namespace to find Block and Translation on.
    $reader = new SimpleAnnotationReader();
    $namespace = 'Drupal\Core\Block\Annotation';
    $reader->addNamespace($namespace);
    $reader->addNamespace('Drupal\Core\Annotation');
 
    // Reset the registry and load annotation classes from new namespaces.
    AnnotationRegistry::reset();
    AnnotationRegistry::registerLoader('class_exists');
 
    // File location, and class name, of the class we're going to parse.
    $dir = 'sites/all/modules/d8api/src/Plugin/Block';
    $file = "$dir/TutorialBlock.php";
    $class = 'Drupal\d8api\Plugin\Block\TutorialBlock';
 
    // Set up a parser for the reader.
    $finder = MockFileFinder::create($file);
    $parser = new StaticReflectionParser($class, $finder, TRUE);
 
    // Run the reader, with the parser and the top-level annotation class.
    $annotation = $reader->getClassAnnotation(
      $parser->getReflectionClass(), $namespace . '\Block'
    );
 
    // Dig into the annotation definition, and extract the admin label.
    $annotationDefinition = $annotation->get();
    return $annotationDefinition['admin_label']->getUntranslatedString();
  }
 
}

You can save this in the d8api module at src/AnnotationReader.php.

What you should see

In order to test this code, you should also install Drush. We don't cover that process here.

Run the following Drush command, to invoke the method on our new object:

drush php-eval 'print (new Drupal\d8api\AnnotationReader)->retrieveAdminLabel();'

(Note that, if you encounter any DocLexer parsing errors, and your annotation looks fine, it might be that you're using a nested annotation that Doctrine doesn't recognize. For example, without access to the Translation class, Doctrine fails with a pretty horrible exception: "Expected DocLexer::T_CLOSE_PARENTHESIS"!)

Otherwise, you should see the following output:

Tutorial block

If so, then congratulations! you have written code to parse an annotation on a class, into nested annotation objects.

Further reading etc.

Add new comment