Skip to main content

Writing Drush commands with PHP attributes

Published on

I recently did a deep dive into command authoring with Drush, which is where I discovered two amazing new features: auto-discovery of commands via autoloading and the addition of attributes for defining your commands.

What are attributes? Attributes were added in PHP 8, and the overview on the PHP website is a great resource. So, if you are new to PHP 8 and have been living on PHP 7.4, still, or haven't tried out PHP 8's coolest feature, this blog will be a great introduction! If you have ever worked with Java, they're similar to annotations. I first experienced attributes when I was learning Rust. 

First, what does an attribute look like? Let's take a given PHPDoc with annotations. Remember, annotations are prefixed with the @ symbol in a document block.

<?php declare(strict_types=1);

namespace App\Drush\Commands;

use Drush\Attributes as CLI;
use Drush\Commands\DrushCommands;

final class AppDrushCommands extends DrushCommands {

  /*
   * @command app:hello-world
   * @aliases hello-world
   */
  public function helloWorld(): void {
    $this->io()->writeln('<info>Hello world!</info>');
  }

}

A mix of the native reflection API in PHP and tooling makes it easy to parse these document blocks and associate the annotations with code or as values. For Drush, this allows specifying the command's name, aliases, arguments, options, and more without writing PHP code.

Now, let's see the code using attributes to replace the annotations in the document block:

<?php declare(strict_types=1);

namespace App\Drush\Commands;

use Drush\Commands\DrushCommands;

final class AppDrushCommands extends DrushCommands {

  #[Drush\Attributes\Command(name: 'app:hello-world', aliases: ['hello-world'])]
  public function helloWorld(): void {
    $this->io()->writeln('<info>Hello world!</info>');
  }

}

Our command and alias annotations were replaced by the Drush\Attributes\Command attribute and its name and alias parameters. An attribute is prefixed with a # symbol and wrapped in brackets []. I remember reading the RFC and the various bikeshedding over how an attribute would be referenced. In the end, I believe it was decided that using # for comments is not that common, any more over //, and code above methods or classes uses the /* */ format. For example, an RFC from 2016 wanted to use <<>> to identify attributes.

PHP 8 bonus feature! You'll notice that the attribute uses named arguments in the attribute's constructor. Named parameters in PHP are my happy place. It brings my favorite part of Go into PHP. 

#[Drush\Attributes\Command(
    name: 'app:hello-world', 
    aliases: ['hello-world']
)]

That's great; we swapped annotations for an attribute and now have a different level of magic. Instead of parsing a code documentation block, the magic is delegated to the attribute. The important part is that attributes are part of the reflection API. Annotations in code documents relied purely on third-party libraries for parsing (or making your own, which sucks.)

Before going further to dig in, check out the Drush documentation which has examples of more attributes: https://www.drush.org/latest/commands/.

Let's see what the Command attribute does (source link)

<?php

namespace Drush\Attributes;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD)]
class Command extends \Consolidation\AnnotatedCommand\Attributes\Command
{
}

Okay, so it specifies that the attribute is intended for a method – using an attribute itself. But it extends another class in the Consolidation namespace? For those who don't know, a lot of the internals in Drush were moved to the Consolidation project for shared libraries to be used with Robo, wp-cli, and Pantheon's Terminus tool. That means we need to cruise over to the Command attribute in Consolidation (source link)

<?php

namespace Consolidation\AnnotatedCommand\Attributes;

use Attribute;
use Consolidation\AnnotatedCommand\Parser\CommandInfo;

#[Attribute(Attribute::TARGET_METHOD)]
class Command
{
    /**
     * @param $name
     *  The name of the command or hook.
     * @param string[] $aliases
     *   An array of alternative names for this item.
     */
    public function __construct(
        public string $name,
        public array $aliases = [],
    ) {
    }

    public static function handle(\ReflectionAttribute $attribute, CommandInfo $commandInfo)
    {
        $args = $attribute->getArguments();
        $commandInfo->setName($args['name']);
        $commandInfo->addAnnotation('command', $args['name']);
        $commandInfo->setAliases($args['aliases'] ?? []);
    }
}

When a command is built, Drush invokes the handle method of each attribute. The attribute gets its arguments and builds the command info. If you notice, the handle method is static and passed a reflection of itself as the $attribute argument. This is due to the way Consolidation builds annotated commands, as taken from AttributesDocBlockParser (source link):

    public function parse()
    {
        $attributes = $this->reflection->getAttributes();
        foreach ($attributes as $attribute) {
            if (method_exists($attribute->getName(), 'handle')) {
                call_user_func([$attribute->getName(), 'handle'], $attribute, $this->commandInfo);
            }
        }
    }

I am excited to see this! And I cannot wait for Drupal 10, which requires PHP 8, and attributes can be leveraged more and more. For example, Symfony is leveraging attributes for defining routes on controllers with its annotation library: https://symfony.com/doc/current/routing.html#creating-routes-as-attribu….

I'm available for one-on-one consulting calls – click here to book a meeting with me 🗓️