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.
What you’ll build
Section titled “What you’ll build”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.
Prerequisites
Section titled “Prerequisites”Step 1: Write the block plugin
Section titled “Step 1: Write the block plugin”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:
<?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.idis the machine name,admin_labelis what the block placement UI shows, andcategorygroups it there. The label and category areTranslatableMarkupobjects, not bare strings, because attribute values are evaluated before the plugin is instantiated and still must be translatable. Attributes replaced the legacy@Blockannotation: same data, but plain PHP that your IDE and static analysis understand. The plugins concept page explains discovery; the namespace pathPlugin/Block/is what makes the block manager find this class. - The class extends
\Drupal\Core\Block\BlockBaseand implements\Drupal\Core\Plugin\ContainerFactoryPluginInterface. That interface is dependency injection for plugins: itscreate()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 injectedcurrent route matchservice and returns markup throughformatPlural(), 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/1than on/node/2, sogetCacheContexts()addsroute. 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 innode:1-style tags (so editing the event invalidates the block) and the custom tag fromRsvpStorage::cacheTag(). Part 3’sRsvpStorage::add()calls thecache tags invalidatorwith 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.
Step 3: Place the block
Section titled “Step 3: Place the block”Plugin discovery is cached, so rebuild first:
ddev drush cache:rebuildThen 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.
Step 4: Watch the tags work
Section titled “Step 4: Watch the tags work”Now prove the invalidation works through the most aggressive cache layer Drupal has, the anonymous page cache:
- Open
/node/1in 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. - In your normal window, as the administrator, RSVP at
/node/1/rsvpwith a fresh email address. The submit handler callsRsvpStorage::add(), which invalidatesevent_rsvp:node:1. - 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.
Troubleshooting
Section titled “Troubleshooting”“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.
The block never renders
Section titled “The block never renders”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.
The count is stale for anonymous users
Section titled “The count is stale for anonymous users”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 same count shows on every event page
Section titled “The same count shows on every event page”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.
Next steps
Section titled “Next steps”- 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.