Skip to content

TutorialFor module developersDrupal 11

Build your first module 5: a block plugin with cache tags

A count of attendees belongs on the event page itself, not on an admin report. In this part you build that as a block plugin, and you solve the problem every dynamic block has: how to show live data on a fully cached site. The answer is cacheability metadata, and you already wired up half of it in part 3.

Part 4 finished the data and configuration layer. This part adds RsvpCountBlock, a plugin that site builders can place in any theme region. On an event page it renders “3 people are attending.”; on other pages it renders nothing. It declares a route cache context and per-node cache tags, so the count updates the moment someone RSVPs, even for anonymous visitors served from the page cache.

  • Parts 1 through 4 completed, with at least one RSVP recorded for the DrupalCon node.

Blocks are plugins: interchangeable units of behavior that declare themselves to a manager through a PHP attribute, and that site builders compose through the UI. Create web/modules/custom/event_rsvp/src/Plugin/Block/RsvpCountBlock.php:

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;
}
}

Take it section by section:

  • The #[Block] attribute is the plugin’s registration. id is the machine name, admin_label is what the block placement UI shows, and category groups it there. The label and category are TranslatableMarkup objects, not bare strings, because attribute values are evaluated before the plugin is instantiated and still must be translatable. Attributes replaced the legacy @Block annotation: same data, but plain PHP that your IDE and static analysis understand. The plugins concept page explains discovery; the namespace path Plugin/Block/ is what makes the block manager find this class.
  • The class extends \Drupal\Core\Block\BlockBase and implements \Drupal\Core\Plugin\ContainerFactoryPluginInterface. That interface is dependency injection for plugins: its create() carries three extra parameters ($configuration, $plugin_id, $plugin_definition) that the plugin system needs, which the factory forwards to the constructor along with your two services. Controllers and forms taught you the pattern; this is its plugin-shaped variant.
  • build() finds the node with the injected current route match service and returns markup through formatPlural(), which picks the singular or plural string per language rules. When the current route has no node parameter, it returns an empty array and Drupal renders no block at all, so placing the block site-wide is safe. The alternative is to declare a node context in the #[Block] attribute and let the block system supply the node and its cacheability; Blocks shows that variation. This tutorial reads the route match so you see the cache context that approach needs spelled out in step 2.

That covers what the block shows. The two getCache*() overrides decide when a cached copy may be reused, and they deserve their own walkthrough.

Step 2: Understand the cacheability metadata

Section titled “Step 2: Understand the cacheability metadata”

Drupal caches aggressively at several layers (render cache, dynamic page cache, page cache for anonymous users). Your block does not opt out of any of them. Instead it describes itself precisely with two pieces of metadata:

  • A cache context: route. A context names a circumstance the output varies by. This block shows a different count on /node/1 than on /node/2, so getCacheContexts() adds route. Drupal then caches one copy per route rather than one copy total. Without it, whichever node was rendered first would donate its count to every page.
  • Cache tags: the node’s own tags plus event_rsvp:node:<nid>. A tag names a piece of data the output was built from. getCacheTags() merges in node:1-style tags (so editing the event invalidates the block) and the custom tag from RsvpStorage::cacheTag(). Part 3’s RsvpStorage::add() calls the cache tags invalidator with that same tag on every new RSVP. The full circle: the block declares what it depends on, the storage service announces when that data changes, and every cache layer in between drops exactly the stale copies and nothing else.

This is the architectural payoff of routing all writes through one service in part 3. No code in the block ever clears a cache; no code in the storage knows blocks exist. They only share a tag. The caching concept page is the canonical treatment of contexts, tags, and max-age.

Plugin discovery is cached, so rebuild first:

Terminal window
ddev drush cache:rebuild

Then place the block: go to Administer → Structure → Block layout/admin/structure/block, find the Content region (or your theme’s equivalent), select Place block, search for “RSVP count”, and select Place block next to it. Save with the defaults.

Now prove the invalidation works through the most aggressive cache layer Drupal has, the anonymous page cache:

  1. Open /node/1 in a private/incognito browser window, logged out. The standard profile serves anonymous pages from the page cache; the first load primes it, so reload once. This window now sees cached HTML.
  2. In your normal window, as the administrator, RSVP at /node/1/rsvp with a fresh email address. The submit handler calls RsvpStorage::add(), which invalidates event_rsvp:node:1.
  3. Reload the private window. The count incremented, with no cache clear and no waiting.

If you have ever solved this kind of staleness by disabling caching for a block or page, this is the upgrade: full caching everywhere, surgical invalidation when data changes.

“RSVP count” does not appear in the Place block list

Section titled ““RSVP count” does not appear in the Place block list”

Plugin definitions are cached; run ddev drush cache:rebuild. If it is still missing, confirm the file path is src/Plugin/Block/RsvpCountBlock.php. The Plugin/Block namespace segment is how discovery finds block plugins, so a typo there hides the plugin silently.

build() returns an empty array on routes without a node parameter. Make sure you are looking at a node page. Also check the block was placed in a region your theme actually renders, on the Block layout page for the active theme.

Confirm the new RSVP went through the form (the invalidation lives in RsvpStorage::add(); rows inserted by hand with SQL bypass it, which is precisely why all writes must go through the service). Then check that you did not previously mark the block with custom visibility or a manual cache clear hack from earlier experiments.

The route cache context is missing or the cache was primed before you added it. Verify getCacheContexts() matches the listing, then ddev drush cache:rebuild to drop the old copies.

  • Part 6: permissions and access checks replaces the administrator-only access with real permissions.
  • Caching is the canonical page on contexts, tags, and max-age.
  • Blocks covers configurable blocks, block forms, and derivatives.
  • Plugins explains the discovery system behind the attribute.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.