From annotations to attributes: what actually changes in Drupal plugin discovery

Set opcache.save_comments=0 on a Drupal site running annotation-based plugins, rebuild caches, and you get this:

Doctrine\Common\Annotations\AnnotationException:
[Semantical Error] The class "Drupal\Core\Entity\Annotation\ContentEntityType"
is not annotated with @Annotation. Are you sure this class can be used as
annotation?

The error is real, it has been reported since Drupal 8.0, and it is one of the more misleading messages in the framework — because the class it names is fine. Nothing is wrong with ContentEntityType. What is wrong is that PHP has been told to throw away comments, and Drupal needed one.

The mechanism is worth understanding, because it is not the obvious one. Drupal does not read your plugin's docblock through reflection — StaticReflectionParser calls file_get_contents() and tokenises the file directly, precisely to avoid loading classes. That path is immune to opcache settings. But the annotation classes are a different story: to accept ContentEntityType as a valid annotation, the reader must find the @Annotation marker in that class's own docblock, and it gets there through real reflection. Comments stripped, marker gone, semantic error.

Core cares enough to guard against it. The installer checks opcache_get_status() against ini_get('opcache.save_comments') and warns you, and Doctrine's own AnnotationReader throws from its constructor rather than let you proceed. Those guards exist because the failure is otherwise very hard to attribute to its cause.

Attributes have no equivalent failure. They are not comments — they are syntax, compiled into the class, present in reflection whether or not anyone kept the docblocks. That is the argument in one sentence, and it is an argument about correctness rather than taste.

What the two things actually are

An annotation is a string. Drupal reads the raw docblock text off disk and hands it to a parser, which lexes something that resembles PHP but is not, validates it against an annotation class, and produces a definition array. PHP itself is unaware any of this is happening.

/**
 * @Block(
 *   id = "example_block",
 *   admin_label = @Translation("Example block"),
 *   category = @Translation("Custom")
 * )
 */
class ExampleBlock extends BlockBase {}

An attribute is part of the language. PHP 8.0 added the syntax, the compiler stores it in class metadata, and reflection reads it back:

use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\StringTranslation\TranslatableMarkup;

#[Block(
  id: 'example_block',
  admin_label: new TranslatableMarkup('Example block'),
  category: new TranslatableMarkup('Custom'),
)]
class ExampleBlock extends BlockBase {}

Three differences there are worth naming.

The name resolves normally. Block is the class imported at the top of the file. Rename it, move it, and your IDE and static analysis follow — none of which was possible when the name was a substring of a comment. Annotations did not use namespaces at all, which is a large part of why automated conversion is harder than it sounds.

@Translation() becomes a real object. new TranslatableMarkup(...) is an actual instantiation, permitted because PHP 8.1 allows new in initialiser positions. This is why the conversion needs 8.1 rather than 8.0.

Nothing is instantiated during discovery. ReflectionAttribute exposes getName() and getArguments(), handing back compiled data without constructing anything. Only newInstance() builds an object.

Where Drupal is in this transition

VersionWhat changed
10.2Attribute-based plugin discovery introduced
10.3 / 11.0Most core plugin instances converted
11.2All core plugin types support attributes
12.0Plugin types must provide an attribute class
13.0Annotation support removed entirely

Not providing an attribute class for your plugin type is already deprecated. Nothing is urgent, but the direction has no ambiguity in it.

There is a supply-chain reason as well as a stylistic one: doctrine/annotations is abandoned upstream, and core has forked the parts it needs in-tree as a stopgap. Contrib is moving faster than core in places — migrate_plus dropped annotation support outright in 6.0.9 — so "core still supports it" does not mean the module you depend on does.

The tooling, and what it will and will not do for you

Do not convert by hand. There is automation, but it covers less than you would hope and the order of operations matters.

Plugin types first, implementations second. This is the constraint everything else follows from. Rector converts plugin implementations — your blocks, your field types, your migrate plugins. It does not convert plugin types, the managers and definition classes that make discovery work in the first place. Converting implementations before their type supports attributes gets you code that discovers nothing.

For the type side, Module Builder's Adoption feature (Drupal Code Builder 4.5.4 and later) will import an existing module and regenerate it with attribute-based discovery. For core plugin types you are simply waiting on core, which as of 11.2 has finished.

Then Rector for the bulk work. Drupal Rector ships AnnotationToAttributeRector, configured with an explicit annotation-to-attribute class mapping:

use DrupalRector\Drupal10\Rector\Deprecation\AnnotationToAttributeRector;
use DrupalRector\Drupal10\Rector\ValueObject\AnnotationToAttributeConfiguration;
use Rector\Config\RectorConfig;

return static function (RectorConfig $rectorConfig): void {
  $rectorConfig->ruleWithConfiguration(
    AnnotationToAttributeRector::class,
    [
      new AnnotationToAttributeConfiguration(
        '10.0.0', '10.0.0', 'Block',
        \Drupal\Core\Block\Attribute\Block::class,
      ),
      new AnnotationToAttributeConfiguration(
        '10.0.0', '10.0.0', 'ContentEntityType',
        \Drupal\Core\Entity\Attribute\ContentEntityType::class,
      ),
      // ...one entry per annotation type you use
    ],
  );
};

The mapping is mostly mechanical — Drupal\foo\Annotation\Bar becomes Drupal\foo\Attribute\Bar — with Action as a known exception that lives in a different namespace.

Now the honest part: check every diff. Rector's output here is a starting point, not a result. Documented failure modes in the issue queue include:

  • Single-argument annotations convert wrongly. @ViewsField("my_field") has produced an attribute with the id argument dropped entirely — and in the reported case, pointing at the wrong attribute class as well. Silent, and it looks plausible in review.
  • Some plugin types fail out of the box, Layout Builder's SectionStorage among them.
  • Formatting needs tidying afterwards, usually with a regex pass. Cosmetic, but it is real work across a large module.

Practitioners converting non-trivial modules consistently report patching Rector, hand-fixing a subset, and reviewing everything. Budget for that rather than expecting a clean run.

Finding what needs converting. The Upgrade Status module surfaces these deprecations alongside everything else, which is the sane way to scope the job before starting it. And note there is a parallel transition in your test suite: PHPUnit's own annotations are moving to attributes, core converted its unit tests with a purpose-built Rector rule, and the two cannot be mixed within a single file — so that conversion is all-or-nothing per test class.

Measuring the difference yourself

Attributes are described everywhere as faster. That is true in the narrow sense and mostly irrelevant in the broad one, because Drupal caches plugin definitions — discovery runs on cache rebuild, not on every request. So the honest question is not "how much faster is my site" but "how much faster is drush cr", and that is worth measuring rather than assuming.

Timing a full drush cr is far too noisy — cache rebuild does a great deal besides plugin discovery. What follows is a protocol that isolates the thing we care about. It is written to be reproducible rather than impressive, so that anyone can run it against their own module and get a number that means something.

The trap: do not loop inside one process

The obvious approach is a loop that clears the definition cache and re-runs discovery ten times in a single PHP process. It produces clean, low-variance numbers, and it is wrong — in a direction that flatters attributes specifically.

Attribute discovery has to load each plugin class. Once loaded, a class stays loaded for the life of the process. So iteration one pays the class-loading cost and iterations two through ten do not, and your median reflects a state that never occurs in production, where every cache rebuild starts cold.

Annotation discovery does not have this problem, because it never loads the classes in the first place. Which means a naive in-process loop understates precisely the cost that distinguishes the two mechanisms. Measure one cold iteration per process instead, and repeat across many processes.

The script

#!/usr/bin/env bash
# discovery-bench.sh — one cold measurement per process
# usage: ./discovery-bench.sh plugin.manager.block 20

SERVICE="${1:?usage: $0 SERVICE_ID [RUNS]}"
RUNS="${2:-20}"

for _ in $(seq "$RUNS"); do
  drush php:eval "
    \$m = \Drupal::service('${SERVICE}');
    \$m->clearCachedDefinitions();
    \$t0 = microtime(TRUE);
    \$defs = \$m->getDefinitions();
    printf('%.3f %d %.2f' . PHP_EOL,
      (microtime(TRUE) - \$t0) * 1000,
      count(\$defs),
      memory_get_peak_usage(TRUE) / 1048576
    );
  "
done

Each invocation is a fresh PHP process, so each measurement is genuinely cold. Bootstrap happens outside the timed region, so its cost does not contaminate the result.

Aggregate with something simple:

./discovery-bench.sh plugin.manager.block 20 | sort -n | awk '
  { t[NR] = $1; plugins = $2; if ($3 > mx) mx = $3 }
  END {
    printf "plugins    %d\n",        plugins
    printf "runs       %d\n",        NR
    printf "median     %.2f ms\n",   t[int((NR + 1) / 2)]
    printf "min / max  %.2f / %.2f ms\n", t[1], t[NR]
    printf "peak mem   %.1f MB\n",   mx
  }'

Report the median and the spread, never the mean — one scheduler hiccup on a shared host will drag an average somewhere useless.

What to hold constant

The comparison is the same module, same site, same machine, in two states. Git branches are the easiest way to get that: convert on a branch, measure both, and swap between them rather than rebuilding anything.

Record all of this alongside the numbers, or they are not reproducible:

  • Drupal and PHP versions
  • opcache.enable_cli — off by default, and it must match between runs
  • The plugin manager service, and how many plugins it found
  • The module and commit for each state
  • Host: container or metal, and roughly what CPU

Confounds worth naming

Pick a manager that bootstrap has not already primed. Anything touched during a normal bootstrap — the entity type manager most obviously — will have warm caches and static state before your timer starts. A contrib plugin type of your own is the cleanest subject.

You cannot A/B core. Core's plugin types are already converted, so there is no annotation state to compare against. This benchmark only works on a module you control.

Plugin count drives everything. Below roughly twenty plugins the difference will be inside the noise, and reporting it as a result would be dishonest. If your module is small, say so rather than reporting a percentage.

getDefinitions() does more than discovery. Alter hooks run, definitions get processed. That baseline is identical in both states, so it does not bias the comparison, but it does compress the apparent difference — a 40% faster discovery step inside a call that is half alter hooks shows up as 20%.

The prediction

Stating this before measuring, so it can be wrong: time should go down, memory should go up.

Down, because attributes skip the docblock tokenising and the Doctrine-derived parse. Up, because attribute discovery loads every plugin class into memory and annotation discovery never did — the same property that causes the missing-trait failures described below. If that trade turns out to be significant on a large plugin type, it is a more interesting finding than the speed number, and nobody appears to have published it.

Results to follow — I do not have a suitable environment to hand as I write this, and made-up numbers are worse than none. If you run the protocol above before I do, send me your figures and I will publish them here with credit.

The gotchas that cost real time

Attribute discovery loads your class. Annotation discovery did not.

This is the one that will surprise you, and it follows directly from the opening section. Reading an annotation meant tokenising a file as text — the class was never loaded, its parents never resolved, its traits never touched. Reading an attribute means reflection, and reflection means PHP must actually resolve the class, including every base class, interface and trait it references.

So a plugin extending a base class from a module that is not installed used to be harmless during discovery. Now it is a parse failure. This bites hardest on plugins written for optional dependencies — the "if you also have Views" or "if you also have Commerce" integrations.

Core added a fallback classloader in 11.2 that handles missing traits, covering a good share of cases. The more robust answer for conditional integrations is to define the plugin dynamically in an alter hook rather than relying on discovery to survive a class it cannot resolve.

Arguments must be constant expressions

Attribute arguments are evaluated by the compiler, so they are limited to what the compiler can evaluate: literals, constants, class constants, enum cases, arrays of those, and — since 8.1 — new. Not variables, not arbitrary function calls, nothing computed at runtime.

In practice this only bites when an annotation contained something clever. Most did not. But if a definition computed a label or read from configuration, that logic must move into the plugin manager or an alter hook.

Targets and promoted properties

#[\Attribute(\Attribute::TARGET_CLASS)] restricts where an attribute may legally appear, and PHP enforces it. Constructor-promoted properties are the genuinely ambiguous case — simultaneously a parameter and a property — so an attribute intended for properties needs TARGET_PROPERTY | TARGET_PARAMETER to appear on promoted ones. The error message when you get this wrong does not mention promotion, which is what makes it cost an hour.

Add Attribute::IS_REPEATABLE if the same attribute can appear more than once on a target. Without it, the second occurrence is fatal.

They are yours to use, not just Drupal's

Attributes are a language feature, not framework plumbing. You can attach structured metadata to your own code and read it back in about twenty lines, with no library involved.

#[\Attribute(\Attribute::TARGET_METHOD)]
final class RequiresPermission {

  public function __construct(
    public readonly string $permission,
  ) {}

}

class ReportController {

  #[RequiresPermission('view financial reports')]
  public function quarterly(): array {
    // ...
  }

}

Reading it:

$method = new \ReflectionMethod(ReportController::class, 'quarterly');

foreach ($method->getAttributes(RequiresPermission::class) as $attribute) {
  $required = $attribute->newInstance()->permission;
  // check $required against the current user
}

Or skip the instantiation entirely if you only need the value:

$args = $attribute->getArguments();
$required = $args[0] ?? $args['permission'];

That distinction is the one to internalise. getArguments() reads compiled data; newInstance() runs your constructor and whatever validation is in it. Scanning many classes wants the former, acting on one known attribute wants the latter.

Useful places for your own: routing metadata, permission requirements, serialisation hints, event listener registration, feature flags on service methods. Anywhere you maintain a parallel YAML file describing code that already exists, an attribute puts the description next to the thing it describes — the same argument Drupal made about plugins.

What to actually do

If you maintain contrib: add attribute classes to your plugin types now, keep the annotation classes for backward compatibility, and convert implementations as your minimum core version allows. A plugin can carry both — discovery checks for the attribute class first and falls back to the annotation, and referencing an attribute class that does not exist is not an error on older core. One release can support Drupal 10.1 and 11.4 from the same file.

If you maintain a site: nothing is broken and nothing will be for two major versions. But go and check whether opcache.save_comments is set to 0 anywhere in your stack — on a site still running annotations that is a live bug waiting for a cache rebuild, and the error it produces points at entirely the wrong thing.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.