Drupal 9: Creating A Block To Render A Node Field

Drupal 9: Creating A Block To Render A Node Field

13th March 2022 - 10 minutes read time

Drupal 9 gives you a lot of flexibility to place node fields into different areas of the site, but there are some limitations. You could use the field display manager to change the order and format of the fields, or install the layout builder module and organise fields into sections.

The problem comes when you need to display some fields outside of the limits of the node template. For example, you might want to show a header image within the page template, or print a curated list of links in the sidebar. These situations exist outside of the node template so you need a different way of adding those fields to those parts of the page.

There are just some situations where you just need to render out a field to a different part of the page.

The best way I have found to do this is to use a Drupal block. Since a block can be placed anywhere on the page and can be made aware of the context of the page we can quite easily use a block to pull out the data from the node and print it anywhere we like.

Let's start with a very basic block implementation that just adds enough information for Drupal to register it as a block.

<?php

namespace Drupal\mymodule\Plugin\Block;

use Drupal\Core\Block\BlockBase;

/**
 * Provides a 'Article Header' block.
 *
 * @Block(
 *  id = "mymodule_article_header",
 *  label = "Article Header",
 *  admin_label = @Translation("Article Header"),
 * )
 */
class ArticleHeaderBlock extends BlockBase {

  /**
   * {@inheritdoc}
   */
  public function build() {
  }

}

This block does absolutely nothing, so our next step is to inject a couple of dependencies.

To do this we need to implement the ContainerFactoryPluginInterface, which allows us to include the create() and __construct() methods to define and use the dependencies we want. You can add these methods in if you like, but Drupal will ignore them unless the plugin extends this interface.

As a side note, if the plugin you are looking at implements ContainerFactoryPluginInterface then you can use the same methods to inject any service you need.

The only service we need to inject that isn't included as default is the 'current_route_match' service. This allows us to detect the current route and to pull in the object that was loaded as part of route autoloader. Using this service allows us to grab the current node object on any node page.

<?php

namespace Drupal\mymodule\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Routing\RouteMatchInterface;

/**
 * Provides a 'Article Header' block.
 *
 * @Block(
 *  id = "mymodule_article_header",
 *  label = "Article Header",
 *  admin_label = @Translation("Article Header"),
 * )
 */
class ArticleHeaderBlock extends BlockBase implements ContainerFactoryPluginInterface {

  /**
   * The current route match.
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $routeMatch;

  /**
   * Constructs a new BookNavigationBlock instance.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
   * @param mixed $plugin_definition
   *   The plugin implementation definition.
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The current route match.
   */
  public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteMatchInterface $route_match) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);

    $this->routeMatch = $route_match;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('current_route_match')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function build() {
  }

}

In order to detect if we have a node as part of the route we need to first add the Drupal\node\NodeInterface namespace.

use Drupal\node\NodeInterface;

Using the current_route_match service we pull out the node parameter from the route. We can then use NodeInterface to ensure that the $node variable now contains an instance of a node object. Extra checks can be added here to ensure that the node is a particular type to prevent us from trying to render a field that doesn't exist. It is possible to be a little bit relaxed in this section since we can use the block placement settings to ensure that the block is only shown on certain node types. You should, however, always be checking your inputs.

  public function build() {
    /** @var \Drupal\node\NodeInterface $node */
    $node = $this->routeMatch->getParameter('node');

    if (!($node instanceof NodeInterface)) {
      return [];
    }

    $build = [];

   // Build block render array here.

    return $build;
  }

As to what you do next, it's more or less up to you. In my case I needed to render an image field in the block so I rendered out the field using the default view mode and passed this to the block render array. This is done using the following mechanism.

  public function build() {
    /** @var \Drupal\node\NodeInterface $node */
    $node = $this->routeMatch->getParameter('node');

    if (!($node instanceof NodeInterface)) {
      return [];
    }

    $build = [];

    // Render the article header using the 'default' display mode.
    $image = $node->get('field_article_header')->view('default');

    // Pass the rendered field to the render array.
    $build['header'] = $image;

    return $build;
  }

This works, but there is a bug with this code. Once we view the first page and render the block all subsequent page views will have the same content in the block. This is because the default behaviour of the Drupal block is to be cached and so the first time it is rendered the cache is set and all other pages will just load that cache.

What we need to do is tell the block about the situation that the block is being rendered in. Since we need to use the Drupal cache system to do this we need to include the Drupal\Core\Cache\Cache class as a new namespace option for the block class.

use Drupal\Core\Cache\Cache;

Using this class we can communicate to Drupal about the cache of the block using the getCacheTags() and getCacheContexts() methods.

The getCacheTags() method is used to create cache tags for the block. These are used to invalidate the cache of the block if the node is updated, which means that if we save the node we then clear the cache of the block. Here, we set the tag "node:" and the ID of the node, but also including the cache tags of the parent block plugin.

  public function getCacheTags() {
    // With this when your node change your block will rebuild.
    if ($node = \Drupal::routeMatch()->getParameter('node')) {
      // If there is node add its cachetag.
      return Cache::mergeTags(parent::getCacheTags(), ['node:' . $node->id()]);
    }
    else {
      // Return default tags instead.
      return parent::getCacheTags();
    }
  }

The cache tags are only used when invalidating the cache of the block, so we also need to use the getCacheContexts() method to say how the block should be cached on the site. Here, we set the cache context to be route so that the cache is set on every page that the block appears on.

  public function getCacheContexts() {
    // Every new route this block will rebuild.
    return Cache::mergeContexts(parent::getCacheContexts(), ['route']);
  }

With that code written the only thing left to do is to configure the block to sit in the region needed. Once it's in place the block will render the content of the selected field into the selected region.

The cache for the block exists for this page only, and when we update the node the block cache is automatically cleared to make way for the updated content.

Comments

Permalink

Thanks for writing this up - you should look into context definitions, you can say the block needs a node in the annotation and avoid needing to inject the route match

If you do this, you don't need to worry about cache tags or contexts as the context API does it for you

👍

larowlan (Mon, 03/14/2022 - 11:04)

Permalink

Hi, thank you for sharing this with us. Your whole website is a reference point for me. About this post i want to ask you about this part of the code

  /** @var \Drupal\node\NodeInterface $node */
    $node = $this->routeMatch->getParameter('node');

    if (!($node instanceof NodeInterface)) {
      return [];
    }

Do i need to use this since i can filter the block visibility in the admin structure block config page (admin/structure/block/manage/block_id_name)?

 

nikitas (Mon, 03/28/2022 - 20:41)

Permalink

Hi Nikitas,

Thank you very much!

You are right that you can technically leave that if statement out since you will be placing the block in a particular position, but I try to approach code from a position of safety. For example, if a user happened to add that block to a page that didn't have a node in the parameter (however unlikely) then the page would crash.

With that check in there the block is more robust and will not error when placed on an incorrect page.

Permalink

good document , but why dont you add a file names ?

sajith (Sat, 07/23/2022 - 06:43)

Permalink

Hi Sajith, Thanks for getting in touch and thanks for reading!

You are right, I should have added some filenames to the article, especially for people new to Drupal. I get used to the file structure in Drupal and forget to add that information to the article.

You can work out the filenames based on the Drupal namespaces, which use PSR4 autoloading namespacing (see https://www.php-fig.org/psr/psr-4/).
The block I have created has the fully qualified namespace of "Drupal\mymodule\Plugin\Block\ArticleHeaderBlock”. This can be split in the following way

  • Drupal\mymodule - means that this code lives in the mymodule module directory.
  • Plugin\Block - means that the class lives in the ‘src\Plugin\Block’ directory within the module directory.
  • ArticleHeaderBlock - is the name of the class and this will therefore be the name of the file, or AricleHeaderBlock.php.

In other words the name of the file should be AricleHeaderBlock.php and it should live in the “src/Plugin/Block” directory within your mymodule directory.

I hope that clears thing up a little :)

Add new comment

The content of this field is kept private and will not be shown publicly.