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
usestatement — 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.installfile)- 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:
- Start with new code. Any new plugin or hook you write uses PHP attributes from day one.
- Convert high-churn files. Plugins you modify regularly are worth converting because you get IDE support and type checking immediately.
- Use a search tool to find remaining annotations.
grep -r "@Block\|@Action\|@FieldFormatter" web/modules/custom/finds everything still using the old style. - 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 annotation | PHP attribute class | Namespace |
|---|---|---|
@Block | Block | Drupal\Core\Block\Attribute\Block |
@Action | Action | Drupal\Core\Action\Attribute\Action |
@FieldFormatter | FieldFormatter | Drupal\Core\Field\Attribute\FieldFormatter |
@FieldWidget | FieldWidget | Drupal\Core\Field\Attribute\FieldWidget |
@FieldType | FieldType | Drupal\Core\Field\Attribute\FieldType |
@ViewsField | ViewsField | Drupal\views\Attribute\ViewsField |
@ViewsFilter | ViewsFilter | Drupal\views\Attribute\ViewsFilter |
@ContentEntityType | ContentEntityType | Drupal\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
usestatement; it is a real PHP class, not a magic string. - Replace
@Translation("...")in attributes withnew TranslatableMarkup('...'). - Use named arguments in attributes (
id: 'foo') for clarity and IDE support. - Move hook implementations from
.moduleinto asrc/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:rebuildafter any plugin or hook class change.