PHP Attributes in Drupal 11: Replacing Annotations in Plugins and Hooks

For years Drupal used docblock annotations — special comments parsed at runtime by the Doctrine Annotations library — to declare plugins, entity types, and field formatters. PHP 8.0 introduced native attributes as a first-class language feature, and Drupal 11 now supports them everywhere annotations were used. Drupal 11.1 went further, making PHP attributes the preferred mechanism and marking the annotations approach for eventual deprecation.

This article covers the full migration: why attributes are better, the attribute classes that replace each annotation type, and concrete before-and-after code examples for plugins, blocks, field formatters, and hooks.


Why PHP Attributes Beat Docblock Annotations

Docblock annotations (@Block, @Plugin, etc.) are parsed as strings at runtime by a third-party library. They have no language-level support:

  • IDEs can not validate them without a dedicated plugin.
  • PHPStan and Psalm can not type-check them.
  • A typo in an annotation silently fails — or produces a cryptic error at cache-rebuild time.
  • They add a runtime parsing overhead that PHP native attributes avoid.

PHP attributes (#[AttributeClass(...)]) are parsed by the PHP engine itself. IDEs understand them, static analysis tools can check them, and autocomplete works for their constructor parameters out of the box. They are also faster — PHP compiles attribute metadata into the opcode cache, while docblock parsing happens on every cache rebuild.


Block Plugins: @Block → #[Block]

Block plugins are the most common place developers encounter annotations. The old @Block docblock:

<?php

namespace Drupal\my_module\Plugin\Block;

use Drupal\Core\Block\BlockBase;

/**
 * Provides a recent articles block.
 *
 * @Block(
 *   id = "my_module_recent_articles",
 *   admin_label = @Translation("Recent Articles"),
 *   category = @Translation("Content"),
 * )
 */
class RecentArticlesBlock extends BlockBase
{
    public function build(): array
    {
        return ['#markup' => 'Recent articles here.'];
    }
}

The Drupal 11 PHP attribute equivalent:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Plugin\Block;

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

#[Block(
    id: 'my_module_recent_articles',
    admin_label: new TranslatableMarkup('Recent Articles'),
    category: new TranslatableMarkup('Content'),
)]
final class RecentArticlesBlock extends BlockBase
{
    public function build(): array
    {
        return ['#markup' => 'Recent articles here.'];
    }
}

Key differences:

  • The attribute is placed directly on the class with #[...] syntax — no docblock.
  • Translatable strings use new TranslatableMarkup('...') instead of the @Translation() annotation macro.
  • Named arguments (id:, admin_label:) give you IDE autocomplete and make the code self-documenting.
  • Import the attribute class explicitly with a use statement — it's a real PHP class, not a magic string.

Action Plugins: @Action → #[Action]

<?php
// Old annotation style
/**
 * @Action(
 *   id = "my_module_send_notification",
 *   label = @Translation("Send notification"),
 *   type = "node",
 *   confirm = TRUE,
 * )
 */
<?php
// PHP attribute style
use Drupal\Core\Action\Attribute\Action;
use Drupal\Core\StringTranslation\TranslatableMarkup;

#[Action(
    id: 'my_module_send_notification',
    label: new TranslatableMarkup('Send notification'),
    type: 'node',
    confirm: TRUE,
)]

Field Formatter Plugins: @FieldFormatter → #[FieldFormatter]

<?php
// Old annotation style
/**
 * @FieldFormatter(
 *   id = "my_module_fancy_text",
 *   label = @Translation("Fancy text"),
 *   field_types = {"string", "string_long"},
 * )
 */
<?php
// PHP attribute style
use Drupal\Core\Field\Attribute\FieldFormatter;
use Drupal\Core\StringTranslation\TranslatableMarkup;

#[FieldFormatter(
    id: 'my_module_fancy_text',
    label: new TranslatableMarkup('Fancy text'),
    field_types: ['string', 'string_long'],
)]

Field Widget Plugins: @FieldWidget → #[FieldWidget]

<?php
use Drupal\Core\Field\Attribute\FieldWidget;
use Drupal\Core\StringTranslation\TranslatableMarkup;

#[FieldWidget(
    id: 'my_module_fancy_widget',
    label: new TranslatableMarkup('Fancy widget'),
    field_types: ['string'],
    multiple_values: FALSE,
)]

Views Plugin: @ViewsField → #[ViewsField]

<?php
use Drupal\views\Attribute\ViewsField;

#[ViewsField('my_module_custom_field')]
class MyCustomField extends FieldPluginBase { ... }

Hooks: From .module to #[Hook]

The most impactful change in Drupal 11 for module developers is the #[Hook] attribute. Traditionally, every hook implementation had to be a free function in the .module file:

<?php
// my_module.module (old approach)

function my_module_node_presave(\Drupal\Core\Entity\EntityInterface $node): void
{
    if ($node->getType() === 'article') {
        $node->set('field_processed', TRUE);
    }
}

function my_module_cron(): void
{
    \Drupal::service('my_module.cleanup')->run();
}

With #[Hook], move these into a class under src/Hook/:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Hook;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Hook\Attribute\Hook;

final class MyModuleHooks
{
    public function __construct(
        private readonly \Drupal\my_module\Service\CleanupService $cleanup,
    ) {}

    #[Hook('node_presave')]
    public function nodePressave(EntityInterface $node): void
    {
        if ($node->getType() === 'article') {
            $node->set('field_processed', TRUE);
        }
    }

    #[Hook('cron')]
    public function cron(): void
    {
        $this->cleanup->run();
    }
}

Drupal automatically discovers hook classes by scanning for #[Hook] attributes. There is no registration step. Notice that the hook class can accept constructor injection — your cron handler no longer needs to call \Drupal::service() directly. This makes the code testable.

Hooks That Still Require .module

A small number of hooks must remain in the .module file because they are invoked before the service container or class autoloader are fully available:

  • hook_install(), hook_uninstall()
  • hook_requirements()
  • hook_schema() (use .install file)
  • Theme registration hooks (hook_theme()) — though Drupal core is working to lift this restriction

For everything else, prefer #[Hook] in a dedicated class.


Dependency Injection in Hook Classes

Because hook classes are instantiated by the service container, they support full constructor injection. Register the hook class as a service in my_module.services.yml with the autoconfigure: true flag and Drupal handles the rest:

services:
  _defaults:
    autowire: true
    autoconfigure: true

  Drupal\my_module\Hook\MyModuleHooks:
    arguments:
      - '@my_module.cleanup'

Or let Drupal auto-discover the class by naming it after the module's namespace convention (Drupal 11.1+). Check your Drupal version's service discovery configuration — newer versions may not need the explicit service definition at all.


Custom Plugin Types: Defining Your Own Attribute

When you define a custom plugin type, you also define the attribute that describes plugins of that type. Here is a minimal custom plugin type with its attribute class:

<?php
// src/Attribute/DataProcessor.php

declare(strict_types=1);

namespace Drupal\my_module\Attribute;

use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;

#[\Attribute(\Attribute::TARGET_CLASS)]
final class DataProcessor extends Plugin
{
    public function __construct(
        public readonly string $id,
        public readonly TranslatableMarkup $label,
        public readonly string $input_type = 'any',
    ) {
        parent::__construct($id);
    }
}

Plugins using this attribute:

<?php

namespace Drupal\my_module\Plugin\DataProcessor;

use Drupal\my_module\Attribute\DataProcessor;
use Drupal\Core\StringTranslation\TranslatableMarkup;

#[DataProcessor(
    id: 'json_processor',
    label: new TranslatableMarkup('JSON Processor'),
    input_type: 'json',
)]
final class JsonProcessor implements DataProcessorInterface
{
    // ...
}

Migration Strategy for Existing Modules

If you maintain a module that uses docblock annotations, you do not need to convert everything at once. Drupal 11 supports both syntaxes simultaneously. A safe migration path:

  1. Start with new code. Any new plugin or hook you write uses PHP attributes from day one.
  2. Convert high-churn files. Plugins you modify regularly are worth converting because you get IDE support and type checking immediately.
  3. Use a search tool to find remaining annotations. grep -r "@Block\|@Action\|@FieldFormatter" web/modules/custom/ finds everything still using the old style.
  4. Convert before the Drupal version that deprecates annotations. The deprecation timeline is not confirmed as of Drupal 11, but the direction is clear. Converting now avoids a future scramble.

Quick Conversion Reference

Old annotationPHP attribute classNamespace
@BlockBlockDrupal\Core\Block\Attribute\Block
@ActionActionDrupal\Core\Action\Attribute\Action
@FieldFormatterFieldFormatterDrupal\Core\Field\Attribute\FieldFormatter
@FieldWidgetFieldWidgetDrupal\Core\Field\Attribute\FieldWidget
@FieldTypeFieldTypeDrupal\Core\Field\Attribute\FieldType
@ViewsFieldViewsFieldDrupal\views\Attribute\ViewsField
@ViewsFilterViewsFilterDrupal\views\Attribute\ViewsFilter
@ContentEntityTypeContentEntityTypeDrupal\Core\Entity\Attribute\ContentEntityType
hook in .module#[Hook('hook_name')]Drupal\Core\Hook\Attribute\Hook

Summary Checklist

  • PHP attributes are first-class citizens in Drupal 11 — use them for all new plugin and hook code.
  • Import the attribute class with a use statement; it is a real PHP class, not a magic string.
  • Replace @Translation("...") in attributes with new TranslatableMarkup('...').
  • Use named arguments in attributes (id: 'foo') for clarity and IDE support.
  • Move hook implementations from .module into a src/Hook/ class with #[Hook] attributes.
  • Hook classes support constructor injection — inject services rather than calling \Drupal::service().
  • A handful of hooks (hook_install, hook_schema) still require the procedural style.
  • Both annotation and attribute styles work simultaneously in Drupal 11 — migrate incrementally.
  • Run drush cache:rebuild after any plugin or hook class change.

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.