Crafting custom Drupal blocks with plugin annotations
Drupal blocks have always been the workhorses of page layout, whether you are arranging a university course finder in Brisbane, a council landing page in Hobart, or a content sidebar for a regional news outlet. Modern Drupal encourages developers to register reusable pieces of layout through the plugin system rather than relying on the old procedural hook approach. That change opens the door to object-oriented design, autoloaded classes, and consistent metadata across the entire site.
A plugin annotation is a structured comment block written in a docblock above a PHP class. Drupal scans the codebase looking for these annotations during cache rebuilds and uses the metadata to register the class with the correct plugin manager. For blocks, the relevant manager is BlockPluginManager, which extends the broader plugin discovery pipeline. When the manager finds an annotation matching the expected interface, the block appears in the Block Layout screen for site editors to position, configure, and translate without writing a single line of PHP.
This approach matters because it replaces scattered hook implementations with predictable discovery. Site builders across Australia, from solo freelancers in Perth to enterprise teams at places such as the Australian Bureau of Meteorology, rely on consistent interfaces when handing projects over to in-house editors. Annotations keep the contract between developer and editor explicit, which reduces friction during onboarding and ongoing maintenance.
The walkthrough that follows builds a working block plugin from scratch, compares it with the legacy hook alternative, and finishes with practical recommendations drawn from real Drupal camp talks delivered in Melbourne and Sydney over the past few years. You will end up with a reusable pattern that fits any custom module, whether you serve a small business site or a multi-site platform for a national retail brand.
Understanding the block plugin architecture in modern Drupal
Every block plugin in the object-oriented system extends Drupal\Core\Block\BlockBase or implements BlockPluginInterface directly. BlockBase provides sensible defaults for access checks, configuration handling, and cache metadata, which means most custom blocks can be built by overriding just two or three class methods. The interface itself defines the contract: build() produces the render array, blockForm() exposes admin fields, and blockValidate() enforces rules. Together these methods cover roughly ninety percent of the functionality site builders actually use.
The discovery pipeline runs through Drupal\Core\Block\BlockPluginManager, which delegates annotation parsing to the core discovery component. The annotation class is Drupal\Core\Block\Attribute\Block in newer codebases that adopt the PHP 8 attribute syntax, or Drupal\Core\Block\Annotation\Block in older ones that still rely on docblock parsing. This class tells Drupal how to interpret the metadata that follows. When the manager scans a module directory for src/Plugin/Block, it picks up any PHP file with the right annotation and registers the class automatically. No YAML registration of individual blocks is required, although the module itself still needs a .info.yml file to be enabled.
For developers maintaining sites for organisations that span AEST and AWST, the annotation approach has another quiet advantage. Because the class is autoloaded through standard PSR-4 conventions, IDEs such as PhpStorm provide accurate autocomplete, jump-to-definition, and static analysis support. That kind of tooling is invaluable when juggling dozens of custom blocks across a single platform, or when a Melbourne agency inherits a codebase from a previous vendor and needs to understand it quickly during a tight deadline.
Preparing the module skeleton and folder layout
Before writing any PHP, the module needs a basic skeleton. Inside the Drupal installation, create a directory such as web/modules/custom/greeting_block or modules/custom/greeting_block depending on the composer setup. Drupal camp talks in Brisbane frequently stress the importance of the custom folder to keep bespoke code separate from contributed modules, which makes updates cleaner and reduces the risk of accidentally touching core code during a Drush operation.
The first file to create is greeting_block.info.yml. A minimal version looks like the following snippet, declaring metadata that Drupal needs at install time.
name: 'Greeting Block'
type: module
core_version_requirement: ^9 || ^10
package: 'Custom'
description: 'Displays a personalised greeting based on the time of day.'
Next, create the src/Plugin/Block subdirectory. This is the namespace Drupal expects when discovering block plugins. Place a class file inside it, conventionally named after the block itself, such as GreetingBlock.php. The namespace must mirror the folder structure, beginning with Drupal\greeting_block\Plugin\Block. Getting this convention wrong is the leading cause of "block does not appear in the layout screen" headaches during Australian government projects where staging environments often lag behind production builds.
Writing the annotation and the block class
The annotation lives directly above the class declaration and is the single source of truth for the block's metadata. A clean example looks like this.
namespace Drupal\greeting_block\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Provides a personalised greeting block.
*
* @Block(
* id = "greeting_block",
* admin_label = @Translation("Personalised greeting"),
* category = @Translation("Custom"),
* )
*/
class GreetingBlock extends BlockBase {
public function build() {
$hour = (int) date('G');
$greeting = $hour < 12 ? $this->t('Good morning') : $this->t('Good afternoon');
return [
'#markup' => $greeting,
'#cache' => ['max-age' => 0],
];
}
}
The id is the machine name used in config export files. The admin_label is what editors see in the layout UI, and category groups the block under a heading in the block placement sidebar. Picking categories that match how an Australian client thinks about their site, rather than how the developer would organise code, saves a lot of training time during handover.
To make the block configurable, add blockForm() and blockSubmit() methods. blockForm() returns a Form API array describing the configuration fields, blockSubmit() persists them, and blockValidate() enforces rules such as character limits. Configuration is stored against the block instance, so the same plugin can show different content in different regions across the site without duplicating code.
For more advanced cases, implement ContainerFactoryPluginInterface so the class can pull services from the container through a create() method. Dependency injection through the constructor keeps the block testable in isolation, which is a practice Australian agency teams frequently cite during code review sessions at Drupalcamp Melbourne as a marker of senior-level work. Without it, you end up reaching for Drupal::service() calls inside methods, which couple the block to global state and make unit tests painful to write.
Comparing plugin annotations with older approaches
Before settling on the approach, it helps to see how the plugin-based block compares with the legacy methods it replaced. The table below summarises the practical trade-offs across discovery, Drupal version support, code style, and caching behaviour.
| Aspect | Plugin annotation (current) | hook_block_info() (legacy) | YAML-only registration |
|---|---|---|---|
| Discovery mechanism | PHP docblock scanned at cache rebuild | Procedural hook in .module file |
*.yml config entity |
| Drupal version support | 8, 9, 10, 11 | Pre-Drupal 8 primarily | 8, 9, 10, 11 |
| Object-oriented design | Class with methods | Theme functions and callbacks | Limited, mostly entities |
| IDE autocomplete and static analysis | Strong | Weak | Moderate |
| Access to Drupal services | Native through injection | Requires Drupal::service() boilerplate |
Native |
| Suitability for new projects | Excellent | Avoid | Niche use only |
For any project started today, the annotation route is the right choice. The legacy hook path is still visible in older Australian government sites built on Drupal 7, and migrating those projects usually means rewriting the block layer entirely. The YAML-only path suits configuration entities that have their own admin UI, but for simple layout pieces it adds ceremony without much payoff.
If you maintain a multi-site platform, the annotation approach also simplifies sharing plugins between sites. Drop the same custom module into each site's modules/custom folder, enable it, and the blocks are immediately available in the layout. With the legacy hook approach, you would need to copy .module files into each installation, which is tedious and easy to forget. Plugin derivatives, where a single class generates multiple block instances from configuration, take this further and are worth exploring once the basic annotation pattern feels comfortable.
Caching, deployment and practical recommendations
Once the block class is in place, run drush cr to flush the discovery cache. If the block does not appear in the layout screen, double-check the namespace, the id in the annotation, and that the parent module is enabled. Australian hosting providers such as Catalyst Network Services and Serversaurus often run multiple site environments per account, so make sure the cache rebuild runs in the right environment before chasing phantom bugs.
Cache contexts matter when the block displays user-specific or page-specific content. Override getCacheContexts() to declare what varies the output. Returning ['user'] makes it vary per user, returning ['url.path'] makes it vary per URL. Without these declarations, the block can show one user's greeting to everyone, which is a common review comment during Drupal South workshops held in Adelaide. Permissions are inherited from the block base class, but custom blocks can override blockAccess() to add role-based logic, returning AccessResult::allowed() or AccessResult::forbidden() as needed.
A handful of habits separate hobby projects from professional deliverables, and the list below captures the ones that consistently come up in code reviews across the Australian Drupal community.
- Always declare
core_version_requirementin the.info.ymlfile so composer and Drush can warn about incompatible installs. - Use translation wrappers such as
@Translation()for the admin label and any user-facing strings insidebuild(). - Override
getCacheContexts()whenever block output depends on the current user, node, or URL. - Inject services through
ContainerFactoryPluginInterfacerather than callingDrupal::service()inside methods, keeping the class unit-testable. - Keep configuration keys snake_case and document them in a comment near
blockSubmit()for future maintainers. - Run
drush config:exportafter configuring the block on a development site so the placement survives site rebuilds.
Following these habits keeps custom blocks predictable for whoever inherits the project next, and that reliability is what most Australian clients remember when they recommend a developer to the next team.