Skip to content

How-to guideFor module developersDrupal 11

Blocks

A block plugin is PHP that renders something site builders can place in any theme region. This page shows how to define one with the #[Block] attribute, give it per-placement configuration, keep it cacheable, and grow it into context-aware blocks and whole families of derived blocks (one block generated per menu, per content type, and so on).

A block that appears in the Place block dialog under Administer → Structure → Block layout/admin/structure/block and renders output your code controls. Pick the right tool first: a block plugin is developer-built behavior in code. If editors should write and edit the block’s content in the UI, use a content block instead (the Block Content module, block_content entities, created at /admin/content/block); that requires no code at all. The two meet only at placement time.

  • A custom module installed on a Drupal 11 site.
  • Plugins explains discovery and attributes; blocks are the most-built plugin type.
  • Caching explains the tags and contexts this page attaches.
  1. Create the plugin class in src/Plugin/Block/. The #[Block] attribute on the class is the entire registration; the namespace-based location is what makes discovery find it:

    web/modules/custom/docs_snippets/modules/module_howtos_a/src/Plugin/Block/AnnouncementBlock.php
    <?php
    declare(strict_types=1);
    namespace Drupal\module_howtos_a\Plugin\Block;
    use Drupal\Core\Block\Attribute\Block;
    use Drupal\Core\Block\BlockBase;
    use Drupal\Core\Form\FormStateInterface;
    use Drupal\Core\StringTranslation\TranslatableMarkup;
    /**
    * Shows a site-editor-configurable announcement.
    *
    * Each placement of this block stores its own configuration, edited
    * through the form that blockForm() adds to the generic block settings.
    */
    #[Block(
    id: 'module_howtos_a_announcement',
    admin_label: new TranslatableMarkup('Announcement'),
    category: new TranslatableMarkup('Documentation examples'),
    )]
    final class AnnouncementBlock extends BlockBase {
    /**
    * {@inheritdoc}
    */
    public function defaultConfiguration(): array {
    return [
    'message' => '',
    ];
    }
    /**
    * {@inheritdoc}
    */
    public function blockForm($form, FormStateInterface $form_state): array {
    $form['message'] = [
    '#type' => 'textarea',
    '#title' => $this->t('Announcement text'),
    '#default_value' => $this->configuration['message'],
    '#required' => TRUE,
    ];
    return $form;
    }
    /**
    * {@inheritdoc}
    */
    public function blockSubmit($form, FormStateInterface $form_state): void {
    $this->configuration['message'] = $form_state->getValue('message');
    }
    /**
    * {@inheritdoc}
    */
    public function build(): array {
    return [
    '#markup' => $this->configuration['message'],
    ];
    }
    }

    \Drupal\Core\Block\Attribute\Block takes the plugin id, an admin_label, and a category for the placement dialog. The base class \Drupal\Core\Block\BlockBase supplies the generic machinery (label handling, the settings form shell, access); your class fills in build().

    Per-placement configuration is three small overrides: defaultConfiguration() declares the keys and their defaults, blockForm() adds your elements to the block’s settings form, and blockSubmit() copies submitted values into $this->configuration. Each placed instance stores its own copy, so the same announcement block can say different things in different regions.

  2. Describe the settings in a config schema. Block configuration is configuration like any other, and configuration without a schema fails validation and typed-data access:

    web/modules/custom/docs_snippets/modules/module_howtos_a/config/schema/module_howtos_a.schema.yml
    # Schema for the settings of every placed announcement block. The key is
    # block.settings.<plugin ID>; block_settings is the base type all block
    # plugin configuration extends.
    block.settings.module_howtos_a_announcement:
    type: block_settings
    label: 'Announcement block settings'
    mapping:
    message:
    type: text
    label: 'Announcement text'
  3. Install the module and place the block: drush pm:install module_howtos_a, then Administer → Structure → Block layout, select Place block in a region, and search for “Announcement”. The form from blockForm() appears in the placement dialog.

  4. Attach cacheability. A block render result is cached, so the block must declare what its output depends on (contexts) and what invalidates it (tags). This block reads the node from the current route and counts RSVPs for it:

    web/modules/custom/event_rsvp/src/Plugin/Block/RsvpCountBlock.php
    <?php
    declare(strict_types=1);
    namespace Drupal\event_rsvp\Plugin\Block;
    use Drupal\Core\Block\Attribute\Block;
    use Drupal\Core\Block\BlockBase;
    use Drupal\Core\Cache\Cache;
    use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
    use Drupal\Core\Routing\RouteMatchInterface;
    use Drupal\Core\StringTranslation\TranslatableMarkup;
    use Drupal\event_rsvp\RsvpStorage;
    use Drupal\node\NodeInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    /**
    * Shows how many people have responded to the event on this page.
    *
    * The #[Block] attribute replaces the old @Block annotation: plain PHP
    * that your IDE and static analysis understand.
    */
    #[Block(
    id: 'event_rsvp_count',
    admin_label: new TranslatableMarkup('RSVP count'),
    category: new TranslatableMarkup('Event RSVP'),
    )]
    final class RsvpCountBlock extends BlockBase implements ContainerFactoryPluginInterface {
    /**
    * Constructs the RSVP count block.
    *
    * @param array $configuration
    * The plugin configuration.
    * @param string $plugin_id
    * The plugin ID.
    * @param mixed $plugin_definition
    * The plugin definition.
    * @param \Drupal\event_rsvp\RsvpStorage $rsvpStorage
    * The RSVP storage service.
    * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch
    * The current route match, used to find the node being viewed.
    */
    public function __construct(
    array $configuration,
    string $plugin_id,
    mixed $plugin_definition,
    protected readonly RsvpStorage $rsvpStorage,
    protected readonly RouteMatchInterface $routeMatch,
    ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    }
    /**
    * {@inheritdoc}
    */
    public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    return new static(
    $configuration,
    $plugin_id,
    $plugin_definition,
    $container->get('event_rsvp.storage'),
    $container->get('current_route_match'),
    );
    }
    /**
    * {@inheritdoc}
    */
    public function build(): array {
    $node = $this->getNode();
    if (!$node) {
    return [];
    }
    $count = $this->rsvpStorage->countForNode((int) $node->id());
    return [
    '#markup' => $this->formatPlural(
    $count,
    '1 person is attending.',
    '@count people are attending.',
    ),
    ];
    }
    /**
    * {@inheritdoc}
    */
    public function getCacheContexts(): array {
    // The output depends on which page (route) the block appears on:
    // a different node means a different count.
    return Cache::mergeContexts(parent::getCacheContexts(), ['route']);
    }
    /**
    * {@inheritdoc}
    */
    public function getCacheTags(): array {
    $tags = parent::getCacheTags();
    if ($node = $this->getNode()) {
    // Invalidate the cached block whenever the node is saved, or when
    // RsvpStorage invalidates its custom tag after a new RSVP.
    $tags = Cache::mergeTags($tags, $node->getCacheTags());
    $tags = Cache::mergeTags($tags, [RsvpStorage::cacheTag((int) $node->id())]);
    }
    return $tags;
    }
    /**
    * Returns the node from the current route, if there is one.
    */
    protected function getNode(): ?NodeInterface {
    $node = $this->routeMatch->getParameter('node');
    return $node instanceof NodeInterface ? $node : NULL;
    }
    }

    getCacheContexts() adds route because the output differs per route (a different node means a different count); getCacheTags() adds the node’s tags plus a custom tag the storage service invalidates on writes. Skipping this step does not make the block uncacheable, it makes it wrong: the first rendered copy gets served everywhere. The same snippet shows service injection for plugins, which differs from controllers: implement \Drupal\Core\Plugin\ContainerFactoryPluginInterface, whose create() receives the plugin’s $configuration, $plugin_id, and $plugin_definition ahead of the container services.

  • The block appears in the Place block dialog under your category, and its settings form shows your fields.
  • Place the announcement block, save a message, and see it rendered in the chosen region.
  • Change the message; the new text appears immediately (the block’s configuration is part of the placed block’s config entity, whose cache tag is invalidated on save).
  • drush config:get block.block.announcement (use the machine name you chose at placement) shows your message under settings.

Declaring context_definitions in the attribute hands your block objects from the environment, with placement and cacheability handled by the block system:

web/modules/custom/docs_snippets/modules/module_howtos_a/src/Plugin/Block/AuthorBioBlock.php
<?php
declare(strict_types=1);
namespace Drupal\module_howtos_a\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\Context\EntityContextDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\node\NodeInterface;
/**
* Shows the author of the node being viewed.
*
* Declaring a required node context means the block system hands the
* block the node without any route-matching code of its own, and skips
* the block on pages where no node is available.
*/
#[Block(
id: 'module_howtos_a_author_bio',
admin_label: new TranslatableMarkup('Author bio'),
category: new TranslatableMarkup('Documentation examples'),
context_definitions: [
'node' => new EntityContextDefinition('entity:node', new TranslatableMarkup('Node')),
],
)]
final class AuthorBioBlock extends BlockBase {
/**
* {@inheritdoc}
*/
public function build(): array {
$node = $this->getContextValue('node');
assert($node instanceof NodeInterface);
$owner = $node->getOwner();
return [
'#markup' => $this->t('Written by @name.', ['@name' => $owner->getDisplayName()]),
// The node arrived as context, so its cacheability (cache tags such
// as node:42) is attached automatically by ContextAwarePluginTrait.
// The author is loaded here, so its tags must be attached by hand.
'#cache' => [
'tags' => $owner->getCacheTags(),
],
];
}
}

Like RsvpCountBlock above, this block can be placed anywhere. The difference is who does the work: with a declared context, the placement form maps the context (“Node from URL”), the block system skips the block where the context is empty, and the context’s cacheability (the route cache context and the node’s cache tags) is added for you. RsvpCountBlock does each of those by hand.

When you need one block per thing (per menu, per content type), do not write N classes. Give one base plugin a deriver; the deriver multiplies the definition at discovery time:

web/modules/custom/docs_snippets/modules/module_howtos_a/src/Plugin/Derivative/AddContentBlockDeriver.php
<?php
declare(strict_types=1);
namespace Drupal\module_howtos_a\Plugin\Derivative;
use Drupal\Component\Plugin\Derivative\DeriverBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Derives one "Add content link" block per content type.
*
* The deriver runs at plugin discovery time and multiplies the base
* definition: each derivative appears in the block UI as its own block.
*/
final class AddContentBlockDeriver extends DeriverBase implements ContainerDeriverInterface {
/**
* Constructs the deriver.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
*/
public function __construct(
protected readonly EntityTypeManagerInterface $entityTypeManager,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, $base_plugin_id): static {
return new static(
$container->get('entity_type.manager'),
);
}
/**
* {@inheritdoc}
*/
public function getDerivativeDefinitions($base_plugin_definition): array {
$node_types = $this->entityTypeManager->getStorage('node_type')->loadMultiple();
foreach ($node_types as $id => $type) {
$this->derivatives[$id] = $base_plugin_definition;
$this->derivatives[$id]['admin_label'] = new TranslatableMarkup('Add @type link', ['@type' => $type->label()]);
// If the content type is deleted, its derived block goes with it.
$this->derivatives[$id]['config_dependencies']['config'] = [$type->getConfigDependencyName()];
}
return $this->derivatives;
}
}

The block consuming the derivative reads getDerivativeId() to know which variant it is:

web/modules/custom/docs_snippets/modules/module_howtos_a/src/Plugin/Block/AddContentBlock.php
<?php
declare(strict_types=1);
namespace Drupal\module_howtos_a\Plugin\Block;
use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\module_howtos_a\Plugin\Derivative\AddContentBlockDeriver;
/**
* Links to the add form of one content type.
*
* The plugin IDs are derived: module_howtos_a_add_content:article,
* module_howtos_a_add_content:page, and so on for every content type.
*/
#[Block(
id: 'module_howtos_a_add_content',
admin_label: new TranslatableMarkup('Add content link'),
category: new TranslatableMarkup('Documentation examples'),
deriver: AddContentBlockDeriver::class,
)]
final class AddContentBlock extends BlockBase {
/**
* {@inheritdoc}
*/
public function build(): array {
// The part after the colon in the plugin ID: the node type machine name.
$node_type = $this->getDerivativeId();
return [
'#type' => 'link',
'#title' => $this->t('Add @type', ['@type' => $node_type]),
'#url' => Url::fromRoute('node.add', ['node_type' => $node_type]),
];
}
}

Each derivative shows up in the placement dialog as its own block. Core’s “Menu” blocks work exactly like this: one system_menu_block plugin, one derivative per menu.

Override blockAccess(AccountInterface $account) and return an \Drupal\Core\Access\AccessResult, including its cacheability (for example AccessResult::allowedIfHasPermission(...)). Visibility conditions configured in the UI still apply on top.

You can change block definitions with hook_block_alter() (relabel, swap classes), rendered output with hook_block_view_alter() and hook_block_build_alter(), and access decisions with hook_block_access(), all from your #[Hook] class.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.